From 01506f61f74f16d40de46aa14aabeabe167c1822 Mon Sep 17 00:00:00 2001 From: decay71 <119651599+decay71@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:04:40 +0000 Subject: [PATCH] Release 1.20b-pre --- README.md | 38 +- multiace/VERSION | 2 +- multiace/config/extended/ace.cfg | 10 - multiace/docs/GEN1_FLASH.md | 2 +- multiace/docs/GEN1_TAG_TUNNEL.md | 137 +- multiace/i18n/de.json | 26 + multiace/i18n/en.json | 26 + multiace/install_multiace.sh | 6 +- multiace/klipper/extras/ace.py | 1321 +++++++++++++++++--- multiace/klipper/extras/ace_bg_swap.py | 13 +- multiace/klipper/extras/ace_gen1_tunnel.py | 163 ++- multiace/tools/git-hooks/post-commit | 1 + multiace/uninstall_multiace.sh | 1 + multiace/web/backend/ace1_flash.py | 38 +- multiace/web/backend/main.py | 29 +- multiace/web/frontend/app.js | 229 +++- multiace/web/frontend/index.html | 363 +++++- multiace/web/frontend/style-new.css | 1251 ++++++++++++++++++ multiace/web/frontend/style.css | 26 +- tests/gen1_flasher_selfcheck.py | 2 +- tests/gen1_tag_tunnel_selfcheck.py | 828 +++++++++--- tests/pair_dedupe_selfcheck.py | 60 + 22 files changed, 4049 insertions(+), 523 deletions(-) create mode 100644 multiace/web/frontend/style-new.css create mode 100644 tests/pair_dedupe_selfcheck.py diff --git a/README.md b/README.md index d6c7ca25..c03a9ec8 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,28 @@ # mUlt1ACE -> [!IMPORTANT] -> **multiACE is in the running for the Snapmaker U1 Innovation Fund.** The fund backs -> open-source projects for the U1. Community voting is open until Sep 30. Pick your -> favorite projects. -> -> [![Vote here](https://img.shields.io/badge/Vote%20here-0d9488?style=for-the-badge)](https://www.snapmaker.com/innovation-fund#vote) - Started as a SnapACE fork, it has grown to over 5 times the original size, with around 90% of the code now its own and many unique features: [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/K3K610R4F9) [![Guides & Downloads](visitbutton.png)](https://postapocalyptic-diy.com/multiace/) -## What's new in multiACE 1.11b +## What's new in multiACE 1.20b-pre (prerelease) + +This is a prerelease: new features to try out before the next release. Please report anything odd. + +**A big thank you to our new collaborators.** Three developers joined the project and brought their own features with them: + +- **Simon-CR** - the ACE2-Open community firmware for the ACE 2 Pro, which lets multiACE read and write spool tags. multiACE now also runs on other Klipper printers, not only the Snapmaker U1 (his Voron is the first one), and a watchdog notices when an ACE silently stops answering and reconnects it. +- **Tareku99** - multiACE as a ready-made package for the paxx Extended Firmware, so the firmware installs and updates it for you. A big thank you to [paxx12](https://github.com/paxx12) for the plan to ship multiACE with the rolling releases of the Extended Firmware. +- **Godless50** - the ACE Pro gets its community firmware: flash it straight from the web UI, and read the tags of your spools on an ACE Pro too. Auto-dry can also follow an external humidity sensor. + +**New look for the web UI.** The web UI opens in a new design - the classic one is still there, one click in the Config tab. -Fixes a regression in 1.10b: The Snapmaker and Polymaker vendors in the filament picker show again on firmware 1.6.0 and newer. +**Read spool tags on the ACE Pro.** With the community firmware, press *Read* in the filament picker and the spool's tag is identified and matched to your spool list. (Needs the community firmware on the ACE Pro.) + +**More robust connection.** If an ACE drops off, multiACE keeps trying to reconnect it in the background; a disconnected unit is shown as offline instead of a confusing state, and you get no pause when nothing is printing. + +**Also:** clearer messages when a spool has no tag code, small web UI and log fixes. ## What's new in multiACE 1.10b @@ -664,6 +671,19 @@ All content is reviewed by humans before inclusion. - **Popstar (forum.snapmaker.com)** - **the default PLA swap temperature is set to 220 °C** + +- **[Simon-CR](https://github.com/Simon-CR)** + - **Other Klipper printers** - multiACE runs on printers other than the Snapmaker U1. + - **Connection watchdog** - notices a silent ACE and reconnects it. + - **ACE2-Open community firmware** for the ACE 2 Pro - reading and writing spool tags. + +- **[Tareku99](https://github.com/Tareku99)** + - **paxx Extended Firmware package** - multiACE as a package the firmware installs and updates. + +- **[Godless50](https://github.com/Godless50)** + - **ACE Pro firmware flashing** from the web UI. + - **Tag reading on the ACE Pro** through his community firmware. + - **External humidity for auto-dry.** diff --git a/multiace/VERSION b/multiace/VERSION index 5669da1d..269c8a8a 100644 --- a/multiace/VERSION +++ b/multiace/VERSION @@ -1 +1 @@ -1.11b +1.20b-pre diff --git a/multiace/config/extended/ace.cfg b/multiace/config/extended/ace.cfg index cf0afb61..e43d66f8 100644 --- a/multiace/config/extended/ace.cfg +++ b/multiace/config/extended/ace.cfg @@ -88,16 +88,6 @@ pa_sync: true #tag_write_format: openspool #tag_write_uid_sku: true -# Gen-1 (ACE Pro) tag tunnel: when true, a slot the unit's own reader could -# not identify gets ONE opportunistic page read through the community -# firmware's RC522 tunnel (CV1.3.87x builds), so third-party spools surface -# with their card UID (OpenSpool tags also with material/colour). false = -# the unit keeps its normal status-only tag path; ACE_TAG_READ remains the -# explicit probe either way. On a unit without the tunnel this option only -# produces one klippy.log line. -# Live: ACE_SET_TAG_TUNNEL ENABLE=0|1 -#gen1_tag_tunnel: false - # Humidity-controlled drying. Only an ACE 2 measures; an ACE Pro can only # FOLLOW a master (picked per unit in the web drying panel). These are the # defaults; per-unit overrides via ACE_SET_AUTO_DRY (web drying panel). diff --git a/multiace/docs/GEN1_FLASH.md b/multiace/docs/GEN1_FLASH.md index f97f7279..3fd2cc1d 100644 --- a/multiace/docs/GEN1_FLASH.md +++ b/multiace/docs/GEN1_FLASH.md @@ -82,7 +82,7 @@ sequence, but nothing on the wire: | entry id | image | size | CRC-16 | md5 | |---|---|---|---|---| -| `1.3.863-opencubic` | OpenCubic ACE 1 Pro CFW v1.1.1 release asset `ACE_V1.3.863_20260716.bin` | 113720 | `0xC110` | `9f7b9a678a96caf98d6a08842d3ff971` | +| `1.3.871-tunnel` | Godless50/ACE-PRO-v1.-NFC-UID asset `ACE_V1.3.863_tunnel_ops.bin` (OpenCubic CFW base + UID stub + RC522 tunnel, reports CV1.3.871) | 114632 | `0x1AC9` | `219df3df77f7c7e1e15a79d580a2379e` | | `1.3.863-stock` | clean stock `ACE_V1.3.863_20250518.bin` (rollback target) | 105652 | `0xDEFB` | `dcd04589dcadd5b4feab66d33e772531` | To add an image: flash a dry run with it, read size/CRC/MD5 off the diff --git a/multiace/docs/GEN1_TAG_TUNNEL.md b/multiace/docs/GEN1_TAG_TUNNEL.md index 4ef5f27a..cdbb19bd 100644 --- a/multiace/docs/GEN1_TAG_TUNNEL.md +++ b/multiace/docs/GEN1_TAG_TUNNEL.md @@ -1,4 +1,4 @@ -# Gen-1 (ACE Pro) tag tunnel - `ACE_TAG_READ`, `ACE_SET_TAG_TUNNEL` +# Gen-1 (ACE Pro) tag tunnel - `ACE_TAG_READ` The ACE Pro's own reader understands **Anycubic tags only**: a third-party spool (OpenSpool NDEF, a blank NTAG, a Bambu/Snapmaker MIFARE chip) arrives @@ -34,17 +34,25 @@ Companion documents: NTAG capability container additionally triggers a bounded read of the OpenSpool user area (pages 4..39), decoded by the shared `ace_rc522` OpenSpool decoder. -* **Falls back automatically** (when enabled): a slot that is occupied and - whose tag the firmware did not identify - or whose SKU matches no table - entry - gets **one opportunistic read per insert**. The result lands in +* **Reads only on demand.** Every tunnel TRANSCEIVE blocks the firmware + for about 7 s (its lamps blink, its gates flicker), and a full read + holds about ten of them. So nothing reads automatically, neither at + connect nor after an insert (a session during an insert broke the + insert and took the unit off the bus, HW 2026-10-03): a tag is read + only by `ACE_TAG_READ`, i.e. the web Read button. The result lands in multiACE's own per-unit store, never in `_info_per_ace`. Binding is **gated on attribution**: the two slots of an antenna pair share one RF path, so the card UID is offered to the **existing** tag-bind path (`_spool_bind_by_tag(..., unbind=False)`) **only when the partner slot - (`slot ^ 1`) reads empty in the same status**. An occupied OR - unknown/absent partner still stores and surfaces the read but does not - bind (a wrong first binding has no repair path on a Gen 1); the operator - probe `ACE_TAG_READ` is the explicit override and always binds. + (`slot ^ 1`) reads empty in the same status, or when the partner's + card UID is already known** (its last tunnel read, or a UID code on the + spool bound there). A known partner card is put to sleep with HLTA + during the read, so the next SELECT answers with the other card; a + result can then never be the partner's. An occupied partner with an + unknown card still stores and surfaces the read but does not bind (a + wrong first binding has no repair path on a Gen 1); the operator probe + `ACE_TAG_READ` is the explicit override, always binds and also skips a + known partner card. `unbind=False` on purpose: a tunnel read must never release a binding the vendor path owns. * **Surfaces it.** `get_status`: the slot's `uid` / `tag_format` are filled @@ -76,38 +84,89 @@ ACE_TAG_READ ACE= SLOT=<0..3> [PAGE=] [multiACE] ACE 0 slot 2 page 0: 04 22 52 FC 51 C8 2A 81 32 48 00 00 E1 10 6D 00 [multiACE] ACE 0 slot 2: UID 04225251C82A81 (ntag) - third-party tag ``` -* It **moves nothing** (no lane rotation) and is safe while printing; the - tag must face the coil, so a read can fail on a stationary spool. -* It works with the enable flag **off** - the command itself is the - explicit consent. The result feeds the same store/bind/status path as an - automatic read. +* When no card answers and the lane may move (no print running, the slot + feeds no head), it rotates the spool forward in 20 mm steps until a card + answers (at most 650 mm, below the shortest path to a combiner), reads + it and pulls the lane back by the same distance. Otherwise it reads in + place. +* It is the only tunnel read; nothing reads automatically. * On an ACE 2 the command keeps its previous meaning (rotate + read + bind); the Gen-1 branch is only taken for a non-V2 protocol. -## The enable flag +## Always on -Automatic fallback traffic needs an explicit opt-in: - -``` -ACE_SET_TAG_TUNNEL ENABLE=0|1 [PERSIST=0|1] -``` - -* Default **off** (`#gen1_tag_tunnel: false` in `[ace]`); the setter is - live + write-through, `PERSIST=0` = RAM only until restart. -* With it off, the **only** tunnel traffic is a command the user typed. -* On a unit without the tunnel the flag is inert (one `klippy.log` line, - no console noise, no repeated probes). +`ACE_TAG_READ` uses the tunnel on every ACE Pro whose firmware matches +the tunnel builds (`CV1.3.87x`) **and** answers a probe op through the +stub; a stock unit never receives tunnel traffic, so there is no switch. To go +without it, flash the stock firmware. The former `gen1_tag_tunnel` option +is still read and ignored, so an existing config line does not halt +Klipper; `ACE_SET_TAG_TUNNEL` only reports that the switch is gone. ## Limits * **Community firmware required.** The tunnel exists only on the community build `CV1.3.87x` (verified reference `CV1.3.871`). Stock and the UID-only community image are never touched. -* **The tag must face the coil.** A Gen-1 has no host-side motor control - (`feed_filament`/`unwind_filament` are refused by the stock firmware), so - an automatic read cannot rotate a spool to find the tag. The one attempt - per insert is timed to the firmware's own insert procedure, which already - rotates the spool; if the read misses, use `ACE_TAG_READ` to retry. +* **Searching for the tag.** The tag only answers while it faces the coil. + When `ACE_TAG_READ` finds no card and the lane may + move, it rotates the spool forward (`feed_filament`, 20 mm + steps, at most 650 mm, after the slot reports `ready`), stop at the first + card and pull the lane back by the distance moved (`unwind_filament`; + the ACE Pro has no decoder, so it is the commanded distance). Never + during a print, never on a slot that feeds a head. A read starts only + once no slot of the unit reports `preload` (the firmware's own pull-in, + 17-25 s measured). The + pull-back waits up to 60 s while the unit is busy with another slot + (it answers FORBIDDEN then). +* **Unreadable card at the field edge.** A card that answers SELECT but + whose page read fails is re-read further in (+20, +40 mm) and just + before the hit (-20 mm), like the ACE 2 centring. +* **Whose card?** When the partner slot holds an unknown card, the + partner lane is rotated (20 mm steps, at most 200 mm, then back): the + card leaves the field -> it is the partner's (stored for the partner, + our lane is searched with it skipped); it still answers -> ours, bind. + For `ACE_TAG_READ` only; the partner must be idle + and feed no head. +* **Unit-wide wait.** The ACE Pro refuses motor commands while any slot + of the unit still moves, so a search starts only when every slot is + ready or empty. +* **Firmware identity is vetted.** The community firmware reads tags on + its own, but cannot say which bay of the shared antenna answered (or + replays an old record). While the partner slot is occupied, our own + read decides: our OpenSpool read replaces the firmware identity, and a + card of ours whose user pages were ALL read without an OpenSpool record + hides a firmware OpenSpool identity. Until such a read exists (pending, + running, or only the UID came through) the firmware identity stays + shown. +* **Never during an insert.** A tunnel session holds the reader the + firmware needs to pull in and identify a new spool. No session starts + while any slot of the unit reports `preload` or `shifting`, and a + running session stops at its next op when an insert starts or a gate + of the unit changes; the reader is released at once and the read is + retried when the unit is quiet. A session overlapping an insert made + the preload drop the new spool and crashed the ACE off the USB. + Lanes the session moved itself are not counted: the ACE Pro reports + our own search step as `shifting` too, and a real re-insert on such a + lane still flips its gate. While a stopped read waits for its retry, + the slot keeps the identity it showed before. +* **Only real moves count.** A search step counts only when the ACE + answers `success`; `empty` ends the search. A spool pulled or + re-inserted during the search ends it, and the new spool is never + pulled back. +* **Survives a restart.** The reads are persisted (`ace__gen1_tag_reads`, + save_variables) and restored at startup; an empty slot evicts them, a + fresh read replaces them. A spool swapped while the printer was off + keeps the old UID until its card is read again (same as the ACE 2). +* **Never during a print.** A tunnel session holds the unit's reader; + `ACE_TAG_READ` stays available during a print (read in place, no + rotation). +* **A TRANSCEIVE blocks the firmware ~6.9 s** on the tunnel build (every + page read, data or not; FA log 2026-10-03). The reply wait for op 3 is + 10 s, and any op without a reply ends the session (release only), so + no further op is queued into a unit that is still blocked. +* **One read per unit at a time.** A manual `ACE_TAG_READ` issued while + another read runs on the same unit is queued behind it (at most 180 s) + instead of being refused. * **Two antennas, two bays each; reader channel = bit-swap.** Antenna 1 covers slots 0 and 1 (reader channels 0 and 2), antenna 2 covers slots 2 and 3 (channels 1 and 3). The reader **channel** for a slot is @@ -116,7 +175,7 @@ ACE_SET_TAG_TUNNEL ENABLE=0|1 [PERSIST=0|1] `slot ^ 1`, whose channel is `channel ^ 2`. An earlier live run saw readers 0/2 and 1/3 answer with the same UID - that is the shared RF path, and the reason the automatic bind is gated on the partner slot - reading empty (see above). Two slots reading the same UID cannot both + reading empty or carrying a known card (see above). Two slots reading the same UID cannot both bind: the existing duplicate guard refuses the second one, and an unattributed read is stored but never bound. * **Read cost.** One page read is ~25 tunnel commands (~0.5-1 s on the @@ -148,29 +207,27 @@ packing/signed conversion, the slot -> reader-channel bit-swap map nothing; a matching-but-silent firmware gets one probe; a dead link times out bounded), both genuine live captures (`04 22 52 FC ...` and `53 42 70 E9 ...`) yielding their bytes and UIDs, an OpenSpool NDEF decode -through the reused `ace_rc522` decoder, and the ace.py wiring (flag -gating, one attempt per occupancy, the shared-antenna bind gate - partner -occupied/unknown/absent -> stored but not bound - own store, no +through the reused `ace_rc522` decoder, and the ace.py wiring (one +attempt per occupancy, the shared-antenna bind gate - partner occupied +with an unknown card -> stored but not bound, known card -> halted - own store, no `_info_per_ace` writes, get_status surfacing, unchanged V2 path). ## Open points for the maintainer -1. **Flag name / default.** Implemented as `gen1_tag_tunnel`, default - `false`, setter `ACE_SET_TAG_TUNNEL`. Happy to rename or default it on. -2. **Surface third-party spools automatically?** The automatic read binds +1. **Surface third-party spools automatically?** The automatic read binds by card UID only when a table entry already carries that UID **and the partner slot on the shared antenna reads empty**; it never creates entries and never releases a vendor binding. If the maintainer prefers report-only (no bind call at all), the `bind=False` path is already the non-binding one. -3. **Web UI.** The new `tag_tunnel` status block and the slot `uid` / +2. **Web UI.** The new `tag_tunnel` status block and the slot `uid` / `tag_format` fill are already in `get_status`; the web backend passes the slot `uid`/`tag_format` through today. Whether to add a Config tab toggle and a "third-party tag" badge is a UI decision. -4. **Retry policy.** One attempt per insert is deliberate and cheap; a +3. **Retry policy.** One attempt per insert is deliberate and cheap; a bounded retry ladder (or a "read on next rotation" hook) would raise the hit rate on spools whose tag parks away from the coil. -5. **Firmware acceptance.** The version pre-gate accepts `CV1.3.87x`. +4. **Firmware acceptance.** The version pre-gate accepts `CV1.3.87x`. A later tunnel build (e.g. `CV1.3.872`) would pass the gate; the probe then decides. If a future build changes the op contract, the constant needs revisiting (the reference is `CV1.3.871`). diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 5dd90e79..897361b2 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -96,6 +96,11 @@ "save_current": "Aktuellen speichern…", "snapshot_name_prompt": "Snapshot-Name (A-Z, 0-9, _-):", "snapshot_select_or_new": "Auswählen / Neu", + "nt_head_settings": "Kopf-Einstellungen", + "nt_head_mode": "Head-Modus", + "nt_source": "Quelle", + "nt_feeder_note": "Lädt über den Stock-Seitenvorschub, der ACE rührt ihn nicht an.", + "nt_unload_first": "erst entladen", "feeder_hint": "Feeder-Modus: dieser Kopf lädt/entlädt über seinen Stock-Seitenvorschub; der ACE rührt ihn nicht an.", "feeder_unload_first": "Diesen Kopf erst entladen, um den Feeder-Modus zu ändern.", "head_ace": "ACE", @@ -141,6 +146,7 @@ "acefw_force": "Force", "acefw_force_hint": "Auch flashen, wenn die Version schon installiert ist (Test/Reparatur). Das Byte-Gate der getesteten Versionen gilt trotzdem.", "acefw_gen1": "Gen 1 (ACE Pro)", + "acefw_gen1_unknown": "Diese Datei passt zu keinem getesteten Gen-1-Image (md5) - flashen nicht moeglich. Ein Dry Run geht trotzdem.", "acefw_gen1_intro": "Gen-1-Geräte (ACE Pro) werden über den IAP-Bootloader geflasht: reine .bin-Images, kein .swu-Passwort, kein ACE2-Open-Patch. multiACE liefert keine Firmware mit und verlinkt keine - das OpenCubic-CFW- oder das Stock-Image selbst bereitstellen. Rollback = das Stock-Image über dieselbe Karte flashen.", "acefw_gen1_note": "Niemals über /etc/init.d/S60klipper start|restart flashen - dieser Pfad führt den Update-Check des Druckers aus und flasht die Stock-Firmware über das CFW. Der serielle Port wird von Klipper für den Flash freigegeben und danach automatisch wieder übernommen.", "acefw_gen2": "Gen 2 (ACE 2 Pro)", @@ -165,6 +171,10 @@ "airprint_detection_hint": "Prüft jede Lade-Messung gegen die eigene Baseline der Lane (pro Lane UND pro Kopf) und überwacht den Gegendruck live beim Drucken (Airlog-Chew-Detektor). Zwei verdächtige Messungen pausieren den Druck resumierbar - bevor Luft gedruckt wird. Warnungen werden immer geloggt; dieser Schalter aktiviert nur die Pausen. Persistiert; überschreibt den [ace] resistance_pause Config-Default.", "airprint_detection_label": "Druck pausieren, wenn eine Lane nicht mehr fördert (Chew-/Clog-Schutz)", "baud": "Baud", + "ui_theme": "Design", + "ui_theme_classic": "Classic", + "ui_theme_new": "Neu", + "ui_theme_hint": "Gilt nur für diesen Browser, auch für die Panel-Ansicht (Fluidd-Kamerakachel).", "confirm_commands": "Befehle bestätigen", "confirm_commands_hint": "Rückfrage vor jeder Aktion, die Filament bewegt (Laden, Entladen, Alle laden, Alle entladen). Nur Web-UI - gcode, Display und Druck sind nie betroffen. Gilt sofort (kein Speichern nötig) und wird auf dem Drucker gespeichert, also auf jedem Gerät.", "confirm_commands_label": "Vor Laden/Entladen im Web-UI nachfragen", @@ -214,6 +224,11 @@ "per_slot_overrides": "Pro-Slot Bowden-Overrides", "pickup_cleaning": "Pickup-Cleaning (BG Swaps)", "pickup_cleaning_hint": "Nach einem gleichfarbigen Tool-Pickup ohne Swap/Flush entfernt ein kurzer Düsen-Wisch an der Discard-Position den Ooze-Faden des geparkten Kopfes (echte Swaps und Hintergrund-Ankünfte wischen bereits). Kostet einen Discard-Ausflug pro Pickup; wirkt auf den nächsten preflighteten Druck.", + "nt_confirm_commands_short": "Rückfrage vor Laden und Entladen. Nur Web-UI, gilt auf jedem Gerät.", + "nt_airprint_detection_short": "Pausiert den Druck bei zwei verdächtigen Messungen, bevor Luft gedruckt wird. Warnungen werden immer geloggt.", + "nt_quad_replenish_short": "Lädt bei Runout aus einem Slot mit gleichem Typ und gleicher Farbe nach und setzt den Druck fort.", + "nt_stock_first_short": "An: sofort auf einen anderen geladenen Kopf wechseln. Aus: erst die Slots der eigenen ACE leeren.", + "nt_pickup_cleaning_short": "Wischt die Düse nach einem Pickup ohne Swap. Gilt ab dem nächsten Preflight.", "pickup_cleaning_label": "Düse nach Tool-Pickups ohne Reinigungs-Move wischen", "pa_sync": "PA-Sync", "pa_sync_hint": "Wenn an, wird das Ergebnis der Stock-Flow-Kalibrierroutine auf die gebundene Spule gespeichert und der gespeicherte Spulen-Wert bei jedem Toolchange automatisch angewendet. Wenn aus, fasst multiACE Pressure Advance gar nicht an, der Slicer-/Handwert bleibt stehen. ACE_PA_CALIBRATE funktioniert in beiden Fällen.", @@ -334,6 +349,8 @@ "picker_title_head": "Filament setzen - T{head}", "picker_swatches": "Standardfarben", "picker_known": "Hier vorhanden", + "nt_own_color": "Eigene Farbe ...", + "nt_rfid_tag": "RFID-Tag", "head_read_rfid_hint": "Tag am Feeder-Leser dieses Kopfs lesen", "head_no_tag": "Kein Tag gelesen - liegt der Tag der Spule ueber dem Leser?" }, @@ -395,6 +412,8 @@ "multi_assign_sub": "ein Ziel pro Farbe, Kopien auf weiteren Köpfen", "copies_multi_hint": "Wirkt beim nächsten Upload: ab 2 (oder mit einem manuellen Kopf) ordnet der Preflight pro Farbe zu, wie im Head-Modus.", "copies_title": "Multifilament-Optimierung", + "nt_recommended": "empfohlen", + "nt_contamination": "Kontamination", "feeder": "Feeder", "flush_g_label": "g Kontamination", "forca_blocked": "Fuer die markierten Zeilen eine Spule einlegen, dann den Preflight erneut starten.", @@ -505,7 +524,10 @@ "correct_weight": "Gewicht korrigieren", "weight_dlg_msg": "Restgewicht laut Waage in Gramm. Identität und Verbrauch pflegt Spoolman.", "pa_btn_title": "Auf dieser Spule gespeicherter Pressure Advance", + "nt_add": "Spule anlegen", "pa_hint": "Gespeichert pro Düse ({Größe}_{Volumentyp}). Wird automatisch angewendet, solange diese Spule einen Kopf speist (PA-Sync).", + "nt_feeds_head": "speist T{head} · PA-Sync aktiv", + "nt_free": "frei", "pa_empty": "Noch keine Werte - die Flow-Kalibrierroutine füllt das, oder unten einen anlegen.", "pa_key_ph": "0.4_standard", "pa_value_ph": "0.222", @@ -554,6 +576,8 @@ "spoolman_sync": "Jetzt abgleichen", "spoolman_running": "gleicht ab …", "spoolman_result": "{pulled} importiert, {pushed} gemeldet", + "no_code_title": "Keine Tag-Kennung", + "no_code_msg": "Der Tag hat keine Kennung (keine SKU, keine UID). Diese Spule muss immer von Hand zugeordnet werden.", "sku_taken_title": "Tag-Code schon vergeben", "sku_taken_msg": "Der Tag-Code {sku} gehört bereits zu Spule #{id} ({spool}). Ein Code darf nur zu EINER Spule gehören, sonst kann das Tag nicht sagen, welche gemeint ist. Wähl einen eigenen Code für die neue Spule:", "sku_taken_hint": "Vorschlag. Wenn du diesen Code auf das Tag der neuen Spule schreibst, wird sie beim Einlegen wieder automatisch erkannt - sonst musst du sie von Hand zuordnen. Leer lassen = kein Code.", @@ -812,6 +836,7 @@ "ace_error_generic": "ACE-Fehler: {error}", "ace_error_stop_fa": "ACE[{ace}] Fehler beim Stoppen von feed_assist: {error}", "cascade_detected": "[multiACE] CASCADE erkannt: {count} ACEs in <1.5s ausgefallen (Cascades in dieser Sitzung: {total})", + "ace_idle_reconnected": "[multiACE] ACE {ace} ist wieder da - neu verbunden", "serial_write_recovered": "[multiACE] ACE {ace} Serial-Write nach Reconnect wiederhergestellt", "ace_serial_failed": "[multiACE] ACE {ace} Serial fehlgeschlagen: {error}", "fa_retry_send_failed": "[multiACE] FA start_feed_assist RETRY-Senden fehlgeschlagen: {error}", @@ -959,6 +984,7 @@ "spool_tag_material_mismatch": "[multiACE] Spule #{id} (ACE {ace} / Slot {slot}) sagt {table}, das RFID-Tag liest aber {tag} - es gilt das Tag. Bitte das Material in der Spulentabelle korrigieren.", "spool_tag_duplicate": "[multiACE] ACE {ace} / Slot {slot}: dieses Tag gehoert zu Spule #{id} ({spool}), die in ACE {oace} / Slot {oslot} liegt - das ist also eine ZWEITE Spule mit demselben Werkscode. Nicht zugeordnet. Leg ihr mit '+' einen eigenen Eintrag an (bis dahin wird ihr Verbrauch nicht erfasst).", "spool_tag_ambiguous": "[multiACE] ACE {ace} / Slot {slot}: der Tag-Code {sku} kommt mehrfach in der Spulentabelle vor, das Tag kann also nicht sagen WELCHE davon das ist - keine automatische Zuordnung. Waehl den richtigen Eintrag im Slot-Dialog, die Wahl bleibt dann bestehen.", + "spool_no_tag_code": "[multiACE] ACE {ace} / Slot {slot}: kein Tag-Code gelesen - Spule #{id} bindet sich noch nicht von selbst. Tag lesen (Lesen im Picker), der Code wird dann eingetragen.", "spool_sku_suffixed": "[multiACE] Spule #{id}: der Tag-Code war schon vergeben, gespeichert wurde {sku}. Hinweis: ihr Tag liest weiterhin den URSPRUENGLICHEN Code, diese Spule kann also nicht automatisch erkannt werden - von Hand zuordnen oder ein eigenes Tag schreiben.", "spool_tag_color_mismatch": "[multiACE] Spule #{id} (ACE {ace} / Slot {slot}) ist in der Spulentabelle {table}, das RFID-Tag liest aber {tag} - es gilt das Tag. Sind das zwei verschiedene Spulen mit gleichem Code, leg dieser hier einen eigenen Eintrag an.", "spool_bound_elsewhere": "[multiACE] Spule #{id} ({spool}) liegt in ACE {ace} / Slot {slot}, und der Slot ist belegt - dort erst herausnehmen, dann laesst sie sich hier zuordnen.", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index a98d79ab..4c73a000 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -96,6 +96,11 @@ "save_current": "Save current…", "snapshot_name_prompt": "Snapshot name (A-Z, 0-9, _-):", "snapshot_select_or_new": "Select / New", + "nt_head_settings": "Head settings", + "nt_head_mode": "Head mode", + "nt_source": "Source", + "nt_feeder_note": "Loads through the stock side feeder; the ACE does not touch it.", + "nt_unload_first": "unload first", "feeder_hint": "Feeder mode: this head loads/unloads via its stock side feeder; the ACE never touches it.", "feeder_unload_first": "Unload this head first to change feeder mode.", "head_ace": "ACE", @@ -141,6 +146,7 @@ "acefw_force": "Force", "acefw_force_hint": "Flash even if this version is already installed (test/repair). The tested-versions byte gate still applies.", "acefw_gen1": "Gen 1 (ACE Pro)", + "acefw_gen1_unknown": "This file matches no tested Gen 1 image (md5) - it cannot be flashed. A dry run still works.", "acefw_gen1_intro": "Gen 1 (ACE Pro) flashes use the unit's IAP bootloader: raw .bin images, no .swu password and no ACE2-Open patch. multiACE ships no firmware and links to none - supply the OpenCubic CFW or the stock image yourself. Rollback = flash the stock image through this same card.", "acefw_gen1_note": "Never flash via /etc/init.d/S60klipper start|restart - that path runs the printer's own update check and re-flashes the stock image over the CFW. The serial port is released from Klipper for the flash and handed back automatically.", "acefw_gen2": "Gen 2 (ACE 2 Pro)", @@ -165,6 +171,10 @@ "airprint_detection_hint": "Watches every load's coil measurement against the lane's own baseline (per lane AND per head) and monitors back-pressure live while printing (airlog chew detector). Two suspicious readings pause the print resumably - before air is printed. Warnings are always logged; this switch only arms the pauses. Persisted; overrides the [ace] resistance_pause config default.", "airprint_detection_label": "Pause the print when a lane stops flowing (chew/clog protection)", "baud": "Baud", + "ui_theme": "Design", + "ui_theme_classic": "Classic", + "ui_theme_new": "New", + "ui_theme_hint": "Applies to this browser only, including the panel view (Fluidd camera tile).", "confirm_commands": "Confirm commands", "confirm_commands_hint": "A confirmation dialog before any action that moves filament (load, unload, load all, unload all). Web UI only - gcode, display and print are never gated. Applies immediately (no Save needed) and is stored on the printer, so it holds on every device.", "confirm_commands_label": "Ask before load/unload in the web UI", @@ -214,6 +224,11 @@ "per_slot_overrides": "Per-slot bowden overrides", "pickup_cleaning": "Pickup-Cleaning (BG Swaps)", "pickup_cleaning_hint": "After a same-colour tool pickup with no swap/flush, a short nozzle wipe at the discard position removes the parked head's ooze thread (real swaps and background arrivals already wipe). Costs a discard excursion per pickup; applies to the next preflighted print.", + "nt_confirm_commands_short": "Asks before load and unload. Web UI only, applies on every device.", + "nt_airprint_detection_short": "Pauses the print on two suspicious readings, before air is printed. Warnings are always logged.", + "nt_quad_replenish_short": "On runout, reloads from a slot with the same type and colour and resumes the print.", + "nt_stock_first_short": "On: switch to another loaded head at once. Off: drain the slots of the head's own ACE first.", + "nt_pickup_cleaning_short": "Wipes the nozzle after a pickup without a swap. Applies from the next preflight.", "pickup_cleaning_label": "Wipe the nozzle after tool pickups that have no cleaning move", "pa_sync": "PA sync", "pa_sync_hint": "When on, the stock flow-calibration routine's result is stored onto the bound spool and the stored per-spool value is applied automatically on every tool change. When off, multiACE never touches pressure advance and the slicer/manual value stands. ACE_PA_CALIBRATE works either way.", @@ -334,6 +349,8 @@ "picker_title_head": "Set filament - T{head}", "picker_swatches": "Standard colours", "picker_known": "Already here", + "nt_own_color": "Own colour ...", + "nt_rfid_tag": "RFID tag", "head_read_rfid_hint": "Read the tag at this head's feeder reader", "head_no_tag": "No tag read - is the spool's tag sitting over the reader?" }, @@ -395,6 +412,8 @@ "multi_assign_sub": "one target per colour, copies on further heads", "copies_multi_hint": "Takes effect with the next upload: with 2 or more (or a manual head) the preflight assigns per colour, like head mode.", "copies_title": "Multifilament optimization", + "nt_recommended": "recommended", + "nt_contamination": "contamination", "feeder": "Feeder", "flush_g_label": "g contamination", "forca_blocked": "Load a spool for the marked rows, then run the preflight again.", @@ -505,7 +524,10 @@ "correct_weight": "Correct weight", "weight_dlg_msg": "Remaining weight in grams (scale). Identity and usage are managed by Spoolman.", "pa_btn_title": "Pressure advance stored on this spool", + "nt_add": "Add spool", "pa_hint": "Stored per nozzle ({size}_{volume type}). Applied automatically while this spool feeds a head (PA sync).", + "nt_feeds_head": "feeds T{head} · PA sync active", + "nt_free": "free", "pa_empty": "No stored values yet - the flow calibration routine fills this, or add one below.", "pa_key_ph": "0.4_standard", "pa_value_ph": "0.222", @@ -554,6 +576,8 @@ "spoolman_sync": "Sync now", "spoolman_running": "syncing …", "spoolman_result": "{pulled} imported, {pushed} reported", + "no_code_title": "No tag code", + "no_code_msg": "The tag has no code (no SKU, no UID). This spool always has to be assigned by hand.", "sku_taken_title": "Tag code already in use", "sku_taken_msg": "Tag code {sku} already belongs to spool #{id} ({spool}). A code may belong to only ONE spool, otherwise the tag cannot say which one is meant. Pick a code of your own for the new spool:", "sku_taken_hint": "A suggestion. Write this code onto the new spool’s tag and it will be recognised automatically again when inserted - otherwise you have to assign it by hand. Leave empty for no code.", @@ -812,6 +836,7 @@ "ace_error_generic": "ACE Error: {error}", "ace_error_stop_fa": "ACE[{ace}] Error stopping feed_assist: {error}", "cascade_detected": "[multiACE] CASCADE detected: {count} ACEs failed in <1.5s (total cascades this session: {total})", + "ace_idle_reconnected": "[multiACE] ACE {ace} is back - reconnected", "serial_write_recovered": "[multiACE] ACE {ace} serial write recovered after reconnect", "ace_serial_failed": "[multiACE] ACE {ace} serial failed: {error}", "fa_retry_send_failed": "[multiACE] FA start_feed_assist RETRY send failed: {error}", @@ -959,6 +984,7 @@ "spool_tag_material_mismatch": "[multiACE] Spool #{id} (ACE {ace} / Slot {slot}) says {table}, but its RFID tag reads {tag} - following the tag. Correct the material in the spool table.", "spool_tag_duplicate": "[multiACE] ACE {ace} / Slot {slot}: this tag belongs to spool #{id} ({spool}), which sits in ACE {oace} / Slot {oslot} - so this is a SECOND spool with the same factory code. Not assigned. Give it its own entry with '+' (its consumption is not tracked until you do).", "spool_tag_ambiguous": "[multiACE] ACE {ace} / Slot {slot}: tag code {sku} exists more than once in the spool table, so it cannot say WHICH of them this is - not assigned automatically. Pick the right entry in the slot dialog; that choice then sticks.", + "spool_no_tag_code": "[multiACE] ACE {ace} / Slot {slot}: no tag code read - spool #{id} does not bind by itself yet. Read the tag (Read in the picker), its code is then stored.", "spool_sku_suffixed": "[multiACE] Spool #{id}: the tag code was already taken, so it was stored as {sku}. Note: its tag still reads the ORIGINAL code, so this spool cannot be recognised automatically - assign it by hand, or write it an own tag.", "spool_tag_color_mismatch": "[multiACE] Spool #{id} (ACE {ace} / Slot {slot}) is {table} in the spool table, but its RFID tag reads {tag} - following the tag. If these are two different spools sharing a code, give this one its own entry.", "spool_bound_elsewhere": "[multiACE] Spool #{id} ({spool}) sits in ACE {ace} / Slot {slot} and that slot is occupied - take it out there first, then it can be assigned here.", diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index 23f9ed0b..a4458361 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -69,6 +69,7 @@ for f in \ "klipper/extras/ace_bg_swap.py" \ "klipper/extras/ace_tipform.py" \ "klipper/extras/ace_rc522.py" \ + "klipper/extras/ace_gen1_tunnel.py" \ "klipper/kinematics/extruder_ace.py" \ "config/extended/ace.cfg" \ "config/extended/multiace/ace_mode_switch.sh" \ @@ -154,7 +155,10 @@ cp "$INSTALL_DIR/klipper/extras/ace_tipform.py" "$EXTRAS_DIR/ace_tipform.py" # RC522 tag line - helper module imported lazily by ace.py; used only on # units running the ACE2-Open firmware. cp "$INSTALL_DIR/klipper/extras/ace_rc522.py" "$EXTRAS_DIR/ace_rc522.py" -chmod 644 "$EXTRAS_DIR/ace.py" "$EXTRAS_DIR/ace_protocol.py" "$EXTRAS_DIR/ace_protocol_v1.py" "$EXTRAS_DIR/ace_protocol_v2.py" "$EXTRAS_DIR/filament_feed_ace.py" "$EXTRAS_DIR/filament_switch_sensor_ace.py" "$EXTRAS_DIR/ace_bg_swap.py" "$EXTRAS_DIR/ace_tipform.py" "$EXTRAS_DIR/ace_rc522.py" +# Gen 1 tag tunnel - helper module imported lazily by ace.py; used only on +# ACE Pro units with the tunnel firmware and [ace] gen1_tag_tunnel on. +cp "$INSTALL_DIR/klipper/extras/ace_gen1_tunnel.py" "$EXTRAS_DIR/ace_gen1_tunnel.py" +chmod 644 "$EXTRAS_DIR/ace.py" "$EXTRAS_DIR/ace_protocol.py" "$EXTRAS_DIR/ace_protocol_v1.py" "$EXTRAS_DIR/ace_protocol_v2.py" "$EXTRAS_DIR/filament_feed_ace.py" "$EXTRAS_DIR/filament_switch_sensor_ace.py" "$EXTRAS_DIR/ace_bg_swap.py" "$EXTRAS_DIR/ace_tipform.py" "$EXTRAS_DIR/ace_rc522.py" "$EXTRAS_DIR/ace_gen1_tunnel.py" log " Klipper extras installed" cp "$INSTALL_DIR/klipper/kinematics/extruder_ace.py" "$KINEMATICS_DIR/extruder_ace.py" chmod 644 "$KINEMATICS_DIR/extruder_ace.py" diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index a17ed6fd..105b66ee 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -16,7 +16,7 @@ KNOWN_PROTOCOLS = (AceProtocolV1, AceProtocolV2) -MULTIACE_VERSION = "1.11b" +MULTIACE_VERSION = "1.20b-pre" # Engine API contract version (see docs/ENGINE_API.md). Distinct from # MULTIACE_VERSION (the product version): this only bumps on a breaking @@ -24,7 +24,7 @@ # external host can detect engine capability. Additive changes do not bump it. ACE_API_VERSION = 1 -MULTIACE_BUILD_TAG = "eed86c9b" +MULTIACE_BUILD_TAG = "fc514ff7" MULTIACE_BUNDLE_SHA1 = "40a00eb" @@ -1076,16 +1076,17 @@ def _parse_idx_list(key): # Config tab): a user choice lives in the config line. self.tag_write_uid_sku = config.getboolean('tag_write_uid_sku', True) self._tag_write_uid_sku_cfg = self.tag_write_uid_sku - # Gen-1 (ACE Pro) tag tunnel (ace_gen1_tunnel.py): when ON, a slot - # the firmware could not identify gets ONE opportunistic page read + # Gen-1 (ACE Pro) tag tunnel (ace_gen1_tunnel.py): a slot the + # firmware could not identify gets ONE opportunistic page read # through the community firmware's RC522 tunnel, so a third-party # spool surfaces with its card UID (and, for an OpenSpool tag, its - # material/colour). OFF by default: on a unit without the tunnel - # the option only produces one log line, and ACE_TAG_READ is the - # explicit probe either way. Read unconditionally (config-halt - # trap); write-through to the gen1_tag_tunnel line. - self.gen1_tag_tunnel = config.getboolean('gen1_tag_tunnel', False) - self._gen1_tag_tunnel_cfg = self.gen1_tag_tunnel + # material/colour). Always on: tunnel traffic only ever goes to a + # unit whose firmware matches the tunnel builds AND answers a probe + # op, so a stock unit never sees it. The old on/off option is gone; + # an existing line is still read (and ignored) so the config does + # not halt. + config.get('gen1_tag_tunnel', None) + self.gen1_tag_tunnel = True # Auto-dry: humidity-controlled drying. Only an ACE 2 can drive it - # it is the only one reporting a humidity reading (cmd 6 field 4); an @@ -1181,6 +1182,27 @@ def _parse_idx_list(key): self._gen1_tunnel_reads = {} self._gen1_tunnel_tried = {} self._gen1_tunnel_busy = set() + # Slot whose read is running per unit (the vet hides only that one). + self._gen1_tunnel_busy_slot = {} + # Lanes our own session moved (idx -> set of slots): the ACE Pro + # reports our feed/unwind as 'shifting', which is not an insert. + self._gen1_own_lanes = {} + # Slots whose automatic read was stopped (idx -> set): the vet keeps + # their last verdict until the retry has run. + self._gen1_tunnel_aborted = {} + # Gate-flip counter per (idx, slot): a tunnel session or a search + # stops when a gate of its unit changed under it. + self._gen1_gate_gen = {} + self._gen1_vet_said = {} + # Antenna-pair dedupe of firmware tag reads (ACE Pro): per unit, + # last seen occupancy and the insert time of each occupied slot. + self._pair_occupied = {} + self._pair_insert_at = {} + self._pair_dedupe_said = {} + # Slots seen EMPTY in this connection: only a real insert (empty -> + # present) may rotate the spool to search; a spool already in the + # slot at connect is read in place. + self._gen1_seen_empty = {} # Per-connection tag-rescan marker: set in _open_ace (boot AND # reconnect share that path), consumed by the first status merge # (_merge_v2_filament_info -> _v2_rfid_boot_rescan). V2 only - @@ -1317,6 +1339,8 @@ def _parse_idx_list(key): # Guard so a V2 reader+writer thread (or several ACEs) can't kick off # overlapping reconnects for the same unit. self._reconnecting_per_ace = {} + # Background reconnect after the ladder gave up: idx -> timer. + self._idle_reconnect_timers = {} self._info_per_ace = {} self._slot_overrides = {} @@ -1366,6 +1390,8 @@ def _parse_idx_list(key): # Known hole, same as the binding: a spool swapped while the # printer was off keeps the old identity until the next read. self._restore_tag_reads() + # Same for the ACE Pro tunnel reads (own store, own key). + self._restore_gen1_tag_reads() self._v2_velocity_timers = {} self._v2_velocity_state = {} @@ -1847,6 +1873,9 @@ def _parse_idx_list(key): 'start_time': time.monotonic(), } self._errno5_recent = [] + # Last connect failure per unit (e.g. "[Errno 13] Permission + # denied"), cleared on a successful connect; shown in the web. + self._connect_error_per_ace = {} self._info = { 'status': 'ready', @@ -2389,8 +2418,18 @@ def cmd_MULTIACE_SET_LANGUAGE(self, gcmd): shadow_val=lang) self.log_always('[multiACE] language set to %s%s' % (lang, sfx)) + def _raw(self, idx): + """Internal (0-based) index for internal log lines; same None/int + handling as _disp, only without the display offset.""" + if idx is None: + return '-' + try: + return int(idx) + except (TypeError, ValueError): + return idx + def _disp(self, idx): - """Apply display_index_base offset for log messages.""" + """Apply display_index_base offset for user-facing messages.""" if idx is None: return '-' try: @@ -4457,7 +4496,7 @@ def _on_print_start(self, *args): self._airlog_chew_latched.discard(_src_head) logging.info('[multiACE] [resistance] resume: cleared strikes/' 'latches of head %d only (its pause), other heads ' - 'keep theirs' % self._disp(_src_head)) + 'keep theirs' % self._raw(_src_head)) self._resistance_pause_source_head = None self._resistance_pause_pending = None # Post-(re)start no-op wipe window (RESUME_NOOP_WIPE_WINDOW const @@ -5001,7 +5040,7 @@ def _stop_fa_for_head(self, head, why=''): self._v2_active_rev_assist = False self._fa_trace('_v2_active_rev_assist cleared by pause stop') logging.info('[multiACE] pause: FA stop ACE %d slot %d %s(%s)' - % (self._disp(ace), self._disp(slot), + % (self._raw(ace), self._raw(slot), '' if ok else 'NOT ACCEPTED (3x) ', why)) self._fa_trace('pause stop FA on ACE %d slot %d accepted=%s' % (ace, slot, ok)) @@ -5471,7 +5510,7 @@ def _info_fallback(eventtime): ent[0], ent[1]) logging.info('[multiACE] ACE %d: get_info ' 'unanswered twice, using last known ' - 'firmware %s' % (self._disp(idx), + 'firmware %s' % (self._raw(idx), ent[1])) except Exception: pass @@ -5524,6 +5563,8 @@ def _open_worker(_p=protocol, _path=serial_path, idx, ACE_OPEN_TIMEOUT) logging.info('[multiACE] open ACE %d timed out ' '(serial still opening off-thread)' % idx) + self._connect_error_per_ace[idx] = ( + 'open timed out after %.0fs' % ACE_OPEN_TIMEOUT) return False self.reactor.pause(self.reactor.monotonic() + 0.05) if _open_res['err'] is not None: @@ -5533,6 +5574,7 @@ def _open_worker(_p=protocol, _path=serial_path, return False self._serials[idx] = ser self._connected_per_ace[idx] = True + self._connect_error_per_ace.pop(idx, None) self._serial_failed_per_ace[idx] = False self._last_rx_ts[idx] = time.monotonic() self._request_ids[idx] = 0 @@ -5653,15 +5695,22 @@ def _ready_timeout(eventtime): except Exception: _fire_ready() return True - except serial.serialutil.SerialException: + except serial.serialutil.SerialException as e: + # Keep the reason: a permission problem ([Errno 13]) and a + # vanished device read the same without it. + err = str(e) or type(e).__name__ self._usb_stats['connect_failures'] += 1 - self._usb_log.warning('CONNECT failed idx=%d SerialException', idx) - logging.info('[multiACE] Conn error idx=%d' % idx) + self._usb_log.warning('CONNECT failed idx=%d SerialException: %s', + idx, err) + logging.info('[multiACE] Conn error idx=%d: %s' % (idx, err)) + self._connect_error_per_ace[idx] = err return False except Exception as e: + err = str(e) or type(e).__name__ self._usb_stats['connect_failures'] += 1 - self._usb_log.warning('CONNECT failed idx=%d error=%s', idx, str(e)) - logging.info("ACE Error idx=%d: %s" % (idx, str(e))) + self._usb_log.warning('CONNECT failed idx=%d error=%s', idx, err) + logging.info("ACE Error idx=%d: %s" % (idx, err)) + self._connect_error_per_ace[idx] = err return False def _disconnect_from(self, idx): @@ -6160,6 +6209,17 @@ def _do_pause(eventtime): def _handle_per_ace_failure(self, idx, err): was_failed = self._serial_failed_per_ace.get(idx, False) self._serial_failed_per_ace[idx] = True + # The link is gone whichever path got here. The V2 threads pre-set + # the failed flag, so the disconnect below is skipped for them and + # the connected flag used to stay True: status and web kept showing + # the unit as present, and the background reconnect saw it as + # connected and stopped. + self._connected_per_ace[idx] = False + # A lost link: keep looking for the unit afterwards. A unit that + # answers but stays stuck is not retried - reopening it cannot help, + # it needs the power cycle its message asks for. + if err not in ('stuck_after_reconnects', 'wait_ace_ready_timeout'): + self._schedule_idle_reconnect(idx) if not was_failed: # warn, not error: the reconnect ladder runs next - a red alert # is reserved for its FINAL outcome (comms-loss pause / give-up). @@ -6192,7 +6252,15 @@ def _handle_per_ace_failure(self, idx, err): if s and s.get('ace_index') == idx] if not _feeds: logging.info('[multiACE] ACE %d comms lost but feeds no loaded ' - 'head - print not paused' % self._disp(idx)) + 'head - print not paused' % self._raw(idx)) + return + # Without a running print there is nothing to protect: a PAUSE is + # refused by stock ('Cannot pause while not printing') and the + # popup's "then RESUME" advice has no print to resume. An already + # paused print needs no second pause either. + if not self._is_actively_printing(): + logging.info('[multiACE] ACE %d comms lost while not printing - ' + 'no pause' % self._raw(idx)) return if not self._serial_failed_pause_sent: self._serial_failed_pause_sent = True @@ -6325,6 +6393,63 @@ def _reconnect_or_pause(self, idx, err): finally: self._reconnecting_per_ace[idx] = False + IDLE_RECONNECT_INTERVAL = 5.0 + + def _schedule_idle_reconnect(self, idx): + """Keep looking for a unit the reconnect ladder gave up on. The + ladder spans ~3 s, which covers a USB blip but not a power cycle + (a unit takes longer to enumerate again). Every + IDLE_RECONNECT_INTERVAL the device path is checked; once it exists + the unit is reopened through the same path as the ladder. Stops on + success, when something else reconnected the unit, or when it is + held for a firmware update.""" + timers = getattr(self, '_idle_reconnect_timers', None) + if timers is None or idx in timers: + return + + def _tick(eventtime): + nxt = eventtime + self.IDLE_RECONNECT_INTERVAL + if (not self._serial_failed_per_ace.get(idx, False) + or idx in getattr(self, '_fw_update_hold', ())): + timers.pop(idx, None) + return self.reactor.NEVER + if self._reconnecting_per_ace.get(idx, False): + return nxt + try: + path = str(self._ace_devices[idx]) + except Exception: + timers.pop(idx, None) + return self.reactor.NEVER + if not os.path.exists(path): + return nxt + self._reconnecting_per_ace[idx] = True + try: + ok = self._open_ace( + idx, on_ready=lambda i=idx: self._rearm_fa_after_reconnect(i)) + except Exception as e: + logging.info('[multiACE] idle reconnect ACE %d raised: %s' + % (self._raw(idx), e)) + ok = False + finally: + self._reconnecting_per_ace[idx] = False + if not ok: + return nxt + self._serial_failed_per_ace[idx] = False + self._usb_stats['errno5_recovered'] += 1 + timers.pop(idx, None) + self.log_always(self._t('msg.ace_idle_reconnected', + ace=self._disp(idx))) + return self.reactor.NEVER + + try: + timers[idx] = self.reactor.register_timer( + _tick, self.reactor.monotonic() + self.IDLE_RECONNECT_INTERVAL) + logging.info('[multiACE] ACE %d unreachable - retrying every ' + '%.0f s in the background' + % (self._raw(idx), self.IDLE_RECONNECT_INTERVAL)) + except Exception as e: + logging.info('[multiACE] idle reconnect schedule failed: %s' % e) + def _rearm_fa_after_reconnect(self, idx): """After a successful reconnect of ACE idx, resume feed-assist for the currently-active head IF it is sourced from this ACE and we were @@ -7038,6 +7163,170 @@ def _native_tag_format(self, sku): return fmt return '' + def _gen1_vet_firmware_ids(self, idx, result): + """ACE Pro with the tag tunnel: the firmware's own tag read cannot + tell which bay of the shared antenna answered (or replays an old + record). While the partner slot is occupied, our own tunnel read + for the slot decides: + - our read carries OpenSpool data -> our identity replaces it; + - our read found a card and read ALL its user pages without an + OpenSpool record while the firmware reports OpenSpool -> + contradiction, the firmware one is hidden; + - anything else (no read yet, read running, UID only) -> the + firmware identity is kept until a read contradicts it.""" + try: + cli = (getattr(self, '_gen1_tunnel_clients', None) or {}).get(idx) + sup = cli.support_state() if cli is not None else None + if not (sup and sup[1]): + return + slots = result.get('slots') or [] + reads = (getattr(self, '_gen1_tunnel_reads', None) or {}).get( + idx) or {} + tried = (getattr(self, '_gen1_tunnel_tried', None) or {}).get( + idx) or {} + busy_slot = (getattr(self, '_gen1_tunnel_busy_slot', None) + or {}).get(idx) + aborted = (getattr(self, '_gen1_tunnel_aborted', None) + or {}).get(idx) or () + said = self._gen1_vet_said.setdefault(idx, {}) + # Last verdict outside a running read (the hide while busy must + # not decide what a stopped read falls back to). + self._gen1_vet_settled = getattr(self, '_gen1_vet_settled', {}) + settled = self._gen1_vet_settled.setdefault(idx, {}) + for i, slot in enumerate(slots): + if not isinstance(slot, dict) or slot.get('rfid') != 2: + said.pop(i, None) + continue + partner = slots[i ^ 1] if (i ^ 1) < len(slots) else None + if not (isinstance(partner, dict) and not + self._is_empty_status(partner.get('status', ''))): + said.pop(i, None) + continue + ent = reads.get(i) or {} + fw_os = (slot.get('tag_format') == 'openspool') + if ent.get('uid') and ent.get('material'): + verdict = 'ours' + elif (ent.get('uid') and fw_os + and ent.get('userdata_complete')): + verdict = 'hide' + else: + verdict = 'keep' + if busy_slot != i: + settled[i] = verdict + if verdict == 'ours': + col = str(ent.get('color') or '').strip().lstrip('#') + try: + rgb = [int(col[k:k + 2], 16) for k in (0, 2, 4)] + except (ValueError, IndexError): + rgb = [0, 0, 0] + slot['type'] = ent.get('material', '') + slot['color'] = rgb + slot['colors'] = [rgb + [255]] + slot['brand'] = ent.get('brand', '') + slot['sku'] = '' + slot['tag_format'] = ent.get('format') or 'openspool' + elif verdict == 'hide': + slot['rfid'] = 1 + slot['type'] = '' + slot['color'] = [0, 0, 0] + slot['colors'] = [] + slot['sku'] = '' + slot['brand'] = '' + slot['tag_format'] = '' + if said.get(i) != verdict: + said[i] = verdict + logging.info( + '[multiACE] gen1 tag tunnel: ACE %d slot %d firmware ' + 'identity (partner occupied): %s', self._raw(idx), + self._raw(i), {'ours': 'replaced by our read', + 'hide': 'hidden, our read found no ' + 'OpenSpool record', + 'keep': 'kept'}[verdict]) + except Exception as e: + logging.info('[multiACE] gen1 tag tunnel: firmware id vet failed ' + '(ignored): %s' % e) + + def _pair_tag_key(self, slot): + """What a firmware tag read identifies a spool by: its code (SKU or + card UID), else for an OpenSpool tag material plus colour. None + when the slot carries no usable identity.""" + if not isinstance(slot, dict) or slot.get('rfid') != 2: + return None + sku = self._sku_canon(slot.get('sku') or '') + if sku: + return ('code', sku) + typ = str(slot.get('type') or '').strip().lower() + col = tuple((slot.get('color') or [])[:3]) + if typ: + return ('data', typ, col) + return None + + def _pair_dedupe_firmware_ids(self, idx, result): + """ACE Pro: the two slots of an antenna pair (slot ^ 1) share one + field, so the firmware often reports the partner's card for a + freshly inserted spool. The slot inserted LATER may not carry the + same identity as its partner: it shows '?' (no identity, no bind) + while the partner is still being identified, and stays so when + both report the same tag. Insert order, not report order, decides: + the firmware may identify the newer slot first. A spool present at + connect counts as the older one. Identical spools in one pair are + resolved by hand or by ACE_TAG_READ.""" + try: + if self._is_v2(idx): + return + slots = result.get('slots') or [] + now = self.reactor.monotonic() + prev = self._pair_occupied.setdefault(idx, {}) + ins = self._pair_insert_at.setdefault(idx, {}) + for i, slot in enumerate(slots): + if not isinstance(slot, dict): + continue + occ = not self._is_empty_status(slot.get('status', '')) + if occ and i in prev and not prev[i]: + ins[i] = now + elif not occ: + ins.pop(i, None) + prev[i] = occ + said = self._pair_dedupe_said.setdefault(idx, {}) + keys = [self._pair_tag_key(sl) for sl in slots] + for i, slot in enumerate(slots): + j = i ^ 1 + verdict = None + if (isinstance(slot, dict) and keys[i] is not None + and j < len(slots) and isinstance(slots[j], dict) + and ins.get(i, 0.0) > ins.get(j, 0.0)): + partner = slots[j] + p_busy = (partner.get('rfid') == 3 + or (partner.get('status') + in self.GEN1_INSERT_STATES + and partner.get('rfid') != 2)) + if keys[j] is not None and keys[j] == keys[i]: + verdict = 'same' + elif p_busy: + verdict = 'wait' + if verdict: + slot['rfid'] = 1 + slot['type'] = '' + slot['color'] = [0, 0, 0] + slot['colors'] = [] + slot['sku'] = '' + slot['brand'] = '' + slot['tag_format'] = '' + if said.get(i) != verdict: + said[i] = verdict + if verdict: + logging.info( + '[multiACE] ACE %d slot %d: firmware tag read ' + 'hidden (%s)', self._raw(idx), self._raw(i), + 'same tag as the earlier inserted slot %d - ' + 'the partner card' % self._raw(j) + if verdict == 'same' else + 'slot %d is still being identified' + % self._raw(j)) + except Exception as e: + logging.info('[multiACE] pair tag dedupe failed (ignored): %s' + % e) + def _strip_native_tag_names(self, idx, result): """Status-frame counterpart of the check in _v2_store_filament_read: a slot whose firmware-read sku is one of the on-chip decoder's @@ -7061,7 +7350,7 @@ def _strip_native_tag_names(self, idx, result): '[multiACE] [tag-data] ACE %d slot %d: %s tag decoded ' 'by the ACE firmware (sku field carries the format ' 'name, not an id) - dropped, bind by card UID only', - self._disp(idx), self._disp(n), fmt) + self._raw(idx), self._raw(n), fmt) slot['sku'] = '' slot['tag_format'] = fmt except Exception as e: @@ -7136,7 +7425,7 @@ def _v2_store_filament_read(self, idx, slot, res, host=True, uid='', '[multiACE] [tag-data] ACE %d slot %d: %s tag decoded by the ' 'ACE firmware (sku field carries the format name, not an id) ' '- binding by card UID %s', - self._disp(idx), self._disp(slot), _native, uid or '?') + self._raw(idx), self._raw(slot), _native, uid or '?') info['fmt'] = _native info['sku'] = '' # Firmware UID sentinel (Bambu MIFARE on 46O+, any unreadable tag @@ -7152,7 +7441,7 @@ def _v2_store_filament_read(self, idx, slot, res, host=True, uid='', '[multiACE] [tag-data] ACE %d slot %d: firmware UID ' 'sentinel (version 0x%04X) - sku field is the card UID ' '%s, not a spool id', - self._disp(idx), self._disp(slot), + self._raw(idx), self._raw(slot), int((res.get('tag') or {}).get('field2', 0)), _suid) info['sku'] = '' info['fmt'] = 'mifare' @@ -7176,7 +7465,7 @@ def _v2_store_filament_read(self, idx, slot, res, host=True, uid='', logging.info( '[multiACE] [tag-data] ACE %d slot %d ' 'sku=%s type=%s: %s', - self._disp(idx), self._disp(slot), + self._raw(idx), self._raw(slot), info['sku'], ftype, _tag) # A fresh tag read is the ONE moment the physical spool # identifies itself - bind it here (once per read, not @@ -7215,10 +7504,10 @@ def _drop_device_tag_reads(self, idx): reads stay - they were restored from save_variables at init and the first connect would wipe the whole per-unit dict right after. The empty-slot rule in the merge remains their only eviction. - Gen-1 tunnel reads are session truth like a device read (the tag - may have been swapped while the unit was gone): dropped here.""" - self._gen1_tunnel_reads.pop(idx, None) + Gen-1 tunnel reads are host reads too: they stay, the empty slot + evicts them (status tick); a fresh read replaces them.""" self._gen1_tunnel_tried.pop(idx, None) + self._gen1_seen_empty.pop(idx, None) slots = self._v2_filament_info_per_ace.get(idx) if not slots: return @@ -7228,6 +7517,59 @@ def _drop_device_tag_reads(self, idx): if not slots: self._v2_filament_info_per_ace.pop(idx, None) + VARS_GEN1_TAG_READS = 'ace__gen1_tag_reads' + _GEN1_PERSIST_KEYS = ('uid', 'format', 'material', 'color', 'brand', + 'page0', 'why', 'bound', 'userdata_complete') + + def _persist_gen1_tag_reads(self): + """Write the ACE Pro tunnel reads to save_variables, so a restart + keeps the card UID of a spool that still sits in its slot.""" + try: + out = {} + for idx, slots in (getattr(self, '_gen1_tunnel_reads', None) + or {}).items(): + for slot, ent in (slots or {}).items(): + if ent and ent.get('uid'): + out['%d_%d' % (int(idx), int(slot))] = { + k: ent[k] for k in self._GEN1_PERSIST_KEYS + if k in ent} + if self.save_variables is not None: + self.save_variable(self.VARS_GEN1_TAG_READS, out, write=True) + except Exception as e: + logging.info('[multiACE] gen1 tag tunnel: persist failed ' + '(ignored): %s' % e) + + def _restore_gen1_tag_reads(self): + try: + if self.save_variables is None: + return + sv = self.save_variables.allVariables.get( + self.VARS_GEN1_TAG_READS, None) + if isinstance(sv, str): + sv = json.loads(sv) + if not isinstance(sv, dict): + return + now = self.reactor.monotonic() + n = 0 + for key, ent in sv.items(): + try: + idx, slot = (int(x) for x in str(key).split('_', 1)) + except ValueError: + continue + if not isinstance(ent, dict) or not ent.get('uid'): + continue + e = {k: ent[k] for k in self._GEN1_PERSIST_KEYS if k in ent} + e['ts'] = now + e['restored'] = True + self._gen1_tunnel_reads.setdefault(idx, {})[slot] = e + n += 1 + if n: + logging.info('[multiACE] gen1 tag tunnel: %d read(s) ' + 'restored from save_variables', n) + except Exception as e: + logging.info('[multiACE] gen1 tag tunnel: restore failed ' + '(ignored): %s' % e) + def _persist_tag_reads(self): """Write every HOST read of the cache to save_variables (one dict, 'ace_slot' -> identity). Device-coupled cmd13 reads are not stored - @@ -7326,8 +7668,8 @@ def _rescan_flush_binds(self, idx): cache = self._v2_filament_info_per_ace.get(idx) or {} done = [s for s in sorted(targets) if s in cache] logging.info('[multiACE] [spool] rescan ACE %d: resolving bindings ' - 'for slot(s) %s', self._disp(idx), - ','.join(str(self._disp(s)) for s in done) or 'none') + 'for slot(s) %s', self._raw(idx), + ','.join(str(self._raw(s)) for s in done) or 'none') self._resolve_tag_binds( idx, [(s, (cache.get(s) or {}).get('sku'), cache.get(s)) for s in done]) @@ -7351,7 +7693,7 @@ def _resolve_tag_binds(self, idx, items): except Exception as e: logging.info('[multiACE] [spool] bind ACE %d slot %d failed ' '(ignored): %s' - % (self._disp(idx), self._disp(slot), e)) + % (self._raw(idx), self._raw(slot), e)) def _v2_rfid_boot_rescan(self, idx, slots): """Once per CONNECTION (boot and reconnect - armed in _open_ace, @@ -7394,8 +7736,8 @@ def _v2_rfid_boot_rescan(self, idx, slots): return logging.info( '[multiACE] [spool] tag rescan on connect: ACE %d slot(s) ' - '%s (occupied, no device read)', self._disp(idx), - ','.join(str(self._disp(t)) for t in targets)) + '%s (occupied, no device read)', self._raw(idx), + ','.join(str(self._raw(t)) for t in targets)) # Collect first, decide afterwards (see _v2_store_filament_read). # The timer is the safety net: a dropped identify would otherwise # leave the set non-empty forever and no slot of this unit would @@ -7408,7 +7750,7 @@ def _flush(eventtime, _idx=idx): logging.info( '[multiACE] [spool] rescan ACE %d: %d slot(s) never ' 'answered - deciding with what arrived', - self._disp(_idx), + self._raw(_idx), len(self._rescan_bind_pending.get(_idx) or ())) self._rescan_flush_binds(_idx) return self.reactor.NEVER @@ -7420,8 +7762,8 @@ def _ident_cb(self, response, _idx=idx, _slot=i): if not res.get('type'): logging.info( '[multiACE] [spool] rescan ACE %d slot %d: ' - 'no tag data (%r)', self._disp(_idx), - self._disp(_slot), (response or {}).get('msg')) + 'no tag data (%r)', self._raw(_idx), + self._raw(_slot), (response or {}).get('msg')) return # Shared antenna: the reply may be the NEIGHBOUR's # parked tag. Same sku as the neighbour's cached read @@ -7433,8 +7775,8 @@ def _ident_cb(self, response, _idx=idx, _slot=i): logging.info( '[multiACE] [spool] rescan ACE %d slot %d: ' 'identify answered with the neighbour\'s tag ' - '(sku %s) - ignored', self._disp(_idx), - self._disp(_slot), res.get('sku')) + '(sku %s) - ignored', self._raw(_idx), + self._raw(_slot), res.get('sku')) return # Device-coupled (host=False): a bare identify is not # a verified read of THIS slot, so it must not be @@ -8045,6 +8387,8 @@ def callback(self, response): prev_slots = prev_info.get('slots', []) self._merge_v2_filament_info(idx, result) self._strip_native_tag_names(idx, result) + self._gen1_vet_firmware_ids(idx, result) + self._pair_dedupe_firmware_ids(idx, result) self._v1_tag_bind_from_status(idx, result) # Gen-1 third-party fallback: a slot the firmware could not # identify (or whose SKU matches no table entry) gets ONE @@ -8145,7 +8489,7 @@ def callback(self, response): target_heads = self._get_heads_for_ace_slot(idx, i) if target_heads: logging.info(self._t('msg.find_rfid_target_heads', - ace=self._disp(idx), slot=self._disp(i), + ace=self._raw(idx), slot=self._raw(i), heads=target_heads)) logging.info(self._t('msg.raw_slot_dump', slot=new_slot)) new_type = new_slot.get('type', 'PLA') @@ -8234,7 +8578,7 @@ def callback(self, response): push_brand = new_slot.get('brand', 'Generic') push_subtype = new_slot.get('subtype', '') logging.info(self._t('msg.find_rfid_fallback', - slot=self._disp(i), head=fb_head)) + slot=self._raw(i), head=fb_head)) logging.info(self._t('msg.raw_slot_dump', slot=new_slot)) self._ptc_push_guarded( fb_head, push_type, push_color, push_brand, @@ -8246,6 +8590,10 @@ def callback(self, response): # Diagnostic (klippy.log only): every real gate flip, so an # "insert did nothing" can be told apart from "insert not # seen". Skips the UNKNOWN->x startup step (noise). + if _gate_prev != gate_list[i]: + _gk = (idx, i) + self._gen1_gate_gen[_gk] = \ + self._gen1_gate_gen.get(_gk, 0) + 1 if _gate_prev != gate_list[i] and _gate_prev != GATE_UNKNOWN: logging.info('[multiACE] [gate] ACE %d slot %d: %s -> ' '%s (active=%s status=%r rfid=%s)' @@ -8550,14 +8898,14 @@ def callback(self, response): '[multiACE] head %d: cleared SpoolLink ' 'spool_id=%d stamp - multiace world ' 'counts itself (mode=%s)' - % (self._disp(_rh), _rid, + % (self._raw(_rh), _rid, getattr(self, 'spool_mode', 'local'))) except Exception as e: self._ptc_stamp_clear_block[_rh] = _rid logging.warning( '[multiACE] head %d: spool_id stamp clear ' 'rejected - not repeating for this stamp: ' - '%s' % (self._disp(_rh), e)) + '%s' % (self._raw(_rh), e)) except Exception: pass try: @@ -10861,7 +11209,7 @@ def _force_official_inject(self, head, params, saved): logging.info( '[multiACE] head %d is flagged official - forcing ' 'our identity through (identity_priority=multiace)' - % self._disp(head)) + % self._raw(head)) elif _n == FORCE_OFFICIAL_MAX: self._force_official_count[head] = _n + 1 logging.warning( @@ -10869,7 +11217,7 @@ def _force_official_inject(self, head, params, saved): 'after %d forced pushes - something is re-stamping ' 'it. Leaving it alone; set identity_priority: ' 'spoollink to stop trying.' - % (self._disp(head), FORCE_OFFICIAL_MAX)) + % (self._raw(head), FORCE_OFFICIAL_MAX)) return saved def _spoollink_active(self): @@ -10923,7 +11271,7 @@ def _spoollink_send(self, head, smid, why): '[multiACE] [spoollink] head %d: feeder reader has ' 'spool_id=%d, our binding says %d - reader wins, ' 'not sending (%s)' - % (self._disp(head), _cur, int(smid), why)) + % (self._raw(head), _cur, int(smid), why)) self._spoollink_sent.pop(head, None) return False wh = self.printer.lookup_object('webhooks') @@ -10936,12 +11284,12 @@ def _spoollink_send(self, head, smid, why): 'n': int(ent.get('n', 0)) + 1} logging.info('[multiACE] [spoollink] head %d -> resolver ' 'spool_id=%d (%s, attempt %d)' - % (self._disp(head), smid, why, + % (self._raw(head), smid, why, self._spoollink_sent[head]['n'])) return True except Exception as e: logging.warning('[multiACE] [spoollink] head %d resolver call ' - 'failed (%s): %s' % (self._disp(head), why, e)) + 'failed (%s): %s' % (self._raw(head), why, e)) return False def _spoollink_clear(self, head): @@ -10963,34 +11311,34 @@ def _spoollink_clear(self, head): if fd is not None and hasattr(fd, 'request_clear_filament_info'): fd.request_clear_filament_info(int(head)) logging.info('[multiACE] [spoollink] head %d cleared via ' - 'filament_detect' % self._disp(head)) + 'filament_detect' % self._raw(head)) return True logging.info('[multiACE] [spoollink] head %d: filament_detect ' '%s - trying the direct PTC clear' - % (self._disp(head), + % (self._raw(head), 'not found' if fd is None else 'has no request_clear_filament_info')) except Exception as e: logging.info('[multiACE] [spoollink] head %d filament_detect ' 'clear failed (%s) - trying the direct PTC clear' - % (self._disp(head), e)) + % (self._raw(head), e)) try: ptc = self.printer.lookup_object('print_task_config', None) cb = getattr(ptc, '_rfid_filament_info_update_cb', None) if ptc is None or cb is None: logging.info('[multiACE] [spoollink] head %d clear: no PTC ' 'callback either - falling back to the normal ' - 'push' % self._disp(head)) + 'push' % self._raw(head)) return False from extras import filament_protocol as _fp cb(int(head), dict(_fp.FILAMENT_INFO_STRUCT), is_clear=True) logging.info('[multiACE] [spoollink] head %d cleared via direct ' - 'PTC callback' % self._disp(head)) + 'PTC callback' % self._raw(head)) return True except Exception as e: logging.info('[multiACE] [spoollink] head %d clear failed on ' 'both rungs (falling back to normal push): %s' - % (self._disp(head), e)) + % (self._raw(head), e)) return False def _spoollink_verify(self, head): @@ -11014,7 +11362,7 @@ def _spoollink_verify(self, head): logging.info( '[multiACE] [spoollink] head %d: feeder reader set ' 'spool_id=%d (ours was %s) - reader wins, resends ' - 'stopped' % (self._disp(head), _cur, ent.get('sid'))) + 'stopped' % (self._raw(head), _cur, ent.get('sid'))) return now = self.reactor.monotonic() if now - float(ent.get('t', 0.)) < SPOOLLINK_RESEND_S: @@ -11027,7 +11375,7 @@ def _spoollink_verify(self, head): logging.warning( '[multiACE] [spoollink] head %d never took spool_id=%s ' 'after %d sends - is the SpoolLink service healthy?' - % (self._disp(head), ent.get('sid'), n)) + % (self._raw(head), ent.get('sid'), n)) except Exception: pass @@ -11127,13 +11475,13 @@ def _wrap_set_print_filament_config(self, gcmd): logging.info( '[multiACE] head %d keeps spool id %d - same ' 'identity, the binding still fits' - % (self._disp(_ph), _sid)) + % (self._raw(_ph), _sid)) elif _sid > 0: logging.info( '[multiACE] head %d drops spool id %d - a ' 'different filament is in it now, the binding ' 'would point at the wrong spool' - % (self._disp(_ph), _sid)) + % (self._raw(_ph), _sid)) # --- SpoolLink mode: the resolver is the setter for a head # whose slot has a bound Spoolman spool (decision B). Our # own push is converted into ONE resolver call (deduped on @@ -11188,7 +11536,7 @@ def _wrap_set_print_filament_config(self, gcmd): logging.info( '[multiACE] head %d is flagged official - leaving it ' 'to SpoolLink (identity_priority=spoollink)' - % self._disp(_ph)) + % self._raw(_ph)) elif ('FORCE' not in params and self._identity_mode() == 'multiace'): # NON-ACE heads force too: a factory-tag read can stamp @@ -11874,7 +12222,7 @@ def cmd_ACE_SET_AUTO_DRY(self, gcmd): # successfully applied setting left no trace and "did it even arrive?" # was unanswerable from a log. logging.info('[multiACE] auto-dry ACE %d: %s' - % (self._disp(idx), eff)) + % (self._raw(idx), eff)) # One message per role: the parameter sets are disjoint, so a single # line would always print half of it as noise. NOTE master is an # INDEX - `if eff['master']` would read -1 (= none) as truthy. A Pro @@ -11944,7 +12292,7 @@ def cmd_ACE_SET_HUMIDITY(self, gcmd): # feeder pushed X at time T" (and for expiry investigations). logging.info('[multiACE] external humidity ACE %d: %.1f%%rH%s, ' 'TTL %.0fs%s' - % (self._disp(idx), rh, temp_txt, ttl, + % (self._raw(idx), rh, temp_txt, ttl, '' if clamped is None else ' (clamped from %s)' % clamped)) self.log_always(self._t('msg.ace_humidity_set', @@ -12229,22 +12577,15 @@ def cmd_ACE_SET_PA_SYNC(self, gcmd): % ('ON' if enable else 'OFF', sfx)) cmd_ACE_SET_TAG_TUNNEL_help = ( - '[multiACE] Enable/disable the Gen-1 (ACE Pro) tag tunnel ' - '(ENABLE=0|1): ON gives a slot the firmware could not identify ONE ' - 'opportunistic tag read through the community firmware RC522 tunnel ' - '(CV1.3.87x) per insert, so third-party spools surface with their ' - 'card UID (and OpenSpool material/colour). ACE_TAG_READ works with ' - 'the flag off too. Live + write-through (writes the gen1_tag_tunnel ' - 'config line; PERSIST=0 = until restart).') + '[multiACE] Obsolete: the Gen-1 (ACE Pro) tag tunnel is always on ' + 'for units running the community tunnel firmware (CV1.3.87x); ' + 'flash the stock firmware to go without it.') def cmd_ACE_SET_TAG_TUNNEL(self, gcmd): - enable = bool(gcmd.get_int('ENABLE', 1, minval=0, maxval=1)) - self.gen1_tag_tunnel = enable - sfx = self._wt_persist(gcmd, 'gen1_tag_tunnel', _wt_fmt_bool(enable), - None, shadow_attr='_gen1_tag_tunnel_cfg', - shadow_val=enable) - self.log_always('[multiACE] Gen-1 tag tunnel %s%s' - % ('ON' if enable else 'OFF', sfx)) + # Kept so existing macros do not fail with "Unknown command". + self.log_always('[multiACE] the Gen-1 tag tunnel has no switch any ' + 'more: it is always on for an ACE Pro running the ' + 'tunnel firmware (CV1.3.87x) and inactive otherwise') def cmd_ACE_SET_TAG_WRITE(self, gcmd): """Defaults for ACE_TAG_WRITE / the picker's write button. Each @@ -12758,9 +13099,9 @@ def _spool_bind(self, key, sid): _a, _sl = k.split('_') logging.info('[multiACE] [spool] #%s moved from ACE %d / ' 'Slot %d to ACE %d / Slot %d' - % (sid, self._disp(int(_a)), self._disp(int(_sl)), - self._disp(int(key.split('_')[0])), - self._disp(int(key.split('_')[1])))) + % (sid, self._raw(int(_a)), self._raw(int(_sl)), + self._raw(int(key.split('_')[0])), + self._raw(int(key.split('_')[1])))) except Exception: pass return moved_from @@ -12965,7 +13306,7 @@ def _spool_head_reader_capture(self, head, info, is_clear): self._head_tag_seen[head] = cands[0] logging.info( '[multiACE] [spool] feeder read on head %d: card_uid=%s ' - 'sku_int=%s' % (self._disp(head), uid_hex or '-', + 'sku_int=%s' % (self._raw(head), uid_hex or '-', _sku_i or '-')) # Only the SPOOLLINK world defers to a SpoolLink resolution - # in multiace (local/spoolman) the heartbeat takes the stamp @@ -12975,7 +13316,7 @@ def _spool_head_reader_capture(self, head, info, is_clear): logging.info( '[multiACE] [spool] head %d: SpoolLink already resolved ' 'this card (spool_id=%d) - SL world, no table bind' - % (self._disp(head), self._ptc_spool_id_for(head))) + % (self._raw(head), self._ptc_spool_id_for(head))) return sid = spool = None code = cands[0] @@ -13087,6 +13428,24 @@ def _spool_bind_by_tag(self, ace_idx, slot, sku, unbind=True): _line += (' - slot stays bound to #%s %s via its ' 'sku %s' % (_kb, self._spool_label(_ks), (_ks.get('sku') or '-'))) + # The spool bound here carries no code at all (added or + # assigned before any read): the code read at its slot is + # its own - learn it instead of reporting a no-match. + key = self._spool_key(ace_idx, slot) + _lsid = self._spool_binding.get(key) + _lsp = (self._spools.get(_lsid) if _lsid is not None + else None) + if _lsp is not None and not self._sku_codes( + _lsp.get('sku')): + _lsp['sku'] = (sku or '').strip() + self._save_spool_db(backup=True) + logging.info('[multiACE] [spool] learned code %r for ' + 'spool #%s (bound to ACE %d slot %d, ' + 'had no code)', _lsp['sku'], _lsid, + self._raw(ace_idx), self._raw(slot)) + self.log_always('[multiACE] spool #%s: learned tag ' + 'code %s' % (_lsid, _lsp['sku'])) + return _lsp logging.info(_line) if (getattr(self, 'spoolman_url', '') or '').strip(): self.log_always(_line) @@ -13124,8 +13483,8 @@ def _spool_bind_by_tag(self, ace_idx, slot, sku, unbind=True): logging.info( '[multiACE] [spool] unbound #%s from ACE %d ' 'slot %d (tag %r matches nothing)', - _bound_sid, self._disp(ace_idx), - self._disp(slot), (sku or '').strip()) + _bound_sid, self._raw(ace_idx), + self._raw(slot), (sku or '').strip()) return None key = self._spool_key(ace_idx, slot) if self._spool_binding.get(key) != sid: @@ -13185,7 +13544,7 @@ def _spool_bind_by_tag(self, ace_idx, slot, sku, unbind=True): 'bound at ACE %d slot %d, but that slot reads ' 'sku %r - releasing for re-bind', self._spool_label(spool), sid, - self._disp(_held[0]), self._disp(_held[1]), + self._raw(_held[0]), self._raw(_held[1]), _held_read) else: if self._spool_conflict_said.get(key) != sid: @@ -13202,8 +13561,8 @@ def _spool_bind_by_tag(self, ace_idx, slot, sku, unbind=True): spool=self._spool_label(spool), id=sid, ace=self._disp(ace_idx), slot=self._disp(slot))) logging.info('[multiACE] [spool] bound #%s to ACE %d slot %d ' - 'by tag %r', sid, self._disp(ace_idx), - self._disp(slot), (sku or '').strip()) + 'by tag %r', sid, self._raw(ace_idx), + self._raw(slot), (sku or '').strip()) if _stale_held is not None: self._spool_retry_bind_at(*_stale_held) return spool @@ -13294,75 +13653,470 @@ def _gen1_tunnel_client(self, idx): logging.info('[multiACE] gen1 tag tunnel: client init failed ' '(%s)' % e) return None + # A tunnel session holds the reader the firmware needs for its own + # insert handling: stop it the moment an insert starts. + cli.abort_check = lambda i=idx: self._gen1_session_abort(i) self._gen1_tunnel_clients[idx] = cli return cli + # Slot states of an ACE Pro insert: the firmware pulls the filament in + # and identifies the tag with the same reader the tunnel holds. + GEN1_INSERT_STATES = ('preload', 'shifting') + + def _gen1_unit_gen(self, idx): + return tuple(self._gen1_gate_gen.get((idx, s), 0) for s in range(4)) + + def _gen1_insert_running(self, idx, slots=None, skip=()): + """The slot index of a running firmware insert on the unit, or + None. Slots in `skip` are not looked at.""" + try: + if slots is None: + slots = (self._info_per_ace.get(idx) or {}).get('slots') \ + or [] + for n, sl in enumerate(slots): + if n in skip: + continue + st = str((sl or {}).get('status', '') or '').lower() + if st in self.GEN1_INSERT_STATES: + return n + except Exception: + return None + return None + + def _gen1_session_begin(self, idx): + self._gen1_session_gen = getattr(self, '_gen1_session_gen', {}) + self._gen1_session_gen[idx] = self._gen1_unit_gen(idx) + self._gen1_own_lanes = getattr(self, '_gen1_own_lanes', {}) + self._gen1_own_lanes[idx] = set() + + def _gen1_session_abort(self, idx): + """Abort reason for a running tunnel session, or ''. Lanes the + session moved itself are skipped: the ACE Pro reports our own move + as 'shifting'. A real insert on such a lane flips its gate, which + the gate check below still catches.""" + own = (getattr(self, '_gen1_own_lanes', None) or {}).get(idx) or () + n = self._gen1_insert_running(idx, skip=own) + if n is not None: + return 'insert running on slot %d' % n + snap = (getattr(self, '_gen1_session_gen', None) or {}).get(idx) + if snap is not None and snap != self._gen1_unit_gen(idx): + return 'a gate of the unit changed' + return '' + + def _gen1_wait_no_insert(self, idx, timeout): + """Wait until no firmware insert runs on the unit.""" + t0 = self.reactor.monotonic() + while self._gen1_insert_running(idx) is not None: + if self.reactor.monotonic() - t0 > timeout: + return False + self.reactor.pause(self.reactor.monotonic() + 0.5) + return True + def _gen1_tunnel_status_tick(self, idx, result): - """Pro' status hook: decide whether any slot needs ONE tunnel read. - - Candidate = a slot that is occupied and whose tag the firmware did - NOT identify (rfid != 2 / empty sku) or whose SKU matches no table - entry. Per occupancy session (empty -> present) there is AT MOST - ONE attempt: the tag only answers while it faces the coil, the - firmware rotates the spool during its own insert procedure, and a - Gen-1 has no host-side motor control to search for it later. - ACE_TAG_READ is the explicit retry. Never runs on V2; never runs - with the feature off; never touches _info_per_ace. - - Binding is gated on ATTRIBUTION: the two slots of an antenna pair - (0/2 and 1/3) share one RF path, so a read can belong to either - bay. The automatic read binds the slot's spool ONLY when the - partner slot (slot ^ 1) reads EMPTY in the same status; an - occupied OR unknown/absent partner still stores and surfaces the - read but does not bind (a wrong first binding has no repair path - here - see the tunnel report, section 4).""" + """Pro' status hook: bookkeeping only, no tunnel session. + + Every tunnel transceive blocks the CV1.3.871 firmware for about + 7 s (its lamps blink, its gates flicker), and a full read holds + about ten of them. So nothing reads automatically, neither at + connect nor after an insert: a tag is read only by ACE_TAG_READ + (the web Read button). This hook only forgets the read of a slot + whose spool left, so the next spool there starts clean.""" try: - if not self.gen1_tag_tunnel or self._is_v2(idx): - return - if not self._connected_per_ace.get(idx): - return - if idx in self._gen1_tunnel_busy: + if self._is_v2(idx): return tried = self._gen1_tunnel_tried.setdefault(idx, {}) reads = self._gen1_tunnel_reads.get(idx) or {} - slots = result.get('slots') or [] - for i, slot in enumerate(slots): + n_reads = len(reads) + seen_empty = self._gen1_seen_empty.setdefault(idx, set()) + aborted = self._gen1_tunnel_aborted.setdefault(idx, set()) + for i, slot in enumerate(result.get('slots') or []): if not isinstance(slot, dict): continue if self._is_empty_status(slot.get('status', '')): - # Occupancy ended: the next spool is a new read. tried.pop(i, None) reads.pop(i, None) - continue - sku = slot.get('sku') if slot.get('rfid') == 2 else '' - if self._sku_canon(sku): - _, sp = self._spool_by_sku(sku) - if sp is not None: - continue # firmware read a tag we know - # No vendor tag, or one no entry carries: worth a probe. - if tried.get(i): - continue - # Shared-antenna attribution: the partner bay (slot ^ 1) - # must read empty; occupied OR unknown/absent is NOT - # empty (conservative - do not bind a possibly-wrong read). - partner = slots[i ^ 1] if (i ^ 1) < len(slots) else None - partner_empty = (isinstance(partner, dict) - and self._is_empty_status( - partner.get('status', ''))) - tried[i] = True - self._gen1_tunnel_schedule(idx, i, bind=partner_empty) - # ONE session per unit at a time: the next candidate (if - # any) gets its attempt on a following heartbeat. - break + seen_empty.add(i) + aborted.discard(i) if reads: self._gen1_tunnel_reads[idx] = reads elif idx in self._gen1_tunnel_reads: self._gen1_tunnel_reads.pop(idx, None) + if len(reads) != n_reads: + self._persist_gen1_tag_reads() # a spool left except Exception as e: logging.info('[multiACE] gen1 tag tunnel: status tick failed ' '(ignored): %s' % e) - def _gen1_tunnel_schedule(self, idx, slot, bind=True): + # Gen-1 search: the ACE Pro has no tag search of its own, so a slot + # whose card is not in front of the antenna gets its spool rotated + # forward in small steps, SELECT after each, then pulled back by the + # same amount. Bounded well below the shortest path to a combiner. + GEN1_SEARCH_STEP_MM = 20 + GEN1_SEARCH_MAX_MM = 650 + GEN1_SEARCH_SPEED = 30 + # After an insert the ACE Pro runs its own preload (status 'preload') + # for well over 20 s; the read waits for 'ready' before it starts. + GEN1_INSERT_READY_WAIT = 120.0 + # A manual ACE_TAG_READ waits this long for a running read to finish. + GEN1_MANUAL_BUSY_WAIT = 180.0 + + def _gen1_lane_movable(self, idx, slot): + """May the search rotate this lane? Not during a print, not when + the slot feeds a head.""" + try: + ps = self.printer.lookup_object('print_stats', None) + if ps is not None and (getattr(ps, 'state', '') or '').lower() \ + in ('printing', 'paused'): + return False + if self._get_heads_for_ace_slot(idx, slot): + return False + except Exception: + return False + return bool(self._connected_per_ace.get(idx)) + + def _gen1_print_active(self): + try: + ps = self.printer.lookup_object('print_stats', None) + return ps is not None and (getattr(ps, 'state', '') or '') \ + .lower() in ('printing', 'paused') + except Exception: + return False + + def _gen1_slot_status(self, idx, slot): + try: + slots = (self._info_per_ace.get(idx) or {}).get('slots') or [] + return str((slots[slot] or {}).get('status', '') or '') + except Exception: + return '' + + def _gen1_wait_ready(self, idx, slot, timeout): + """Wait until the slot reports 'ready' (the firmware's own insert + preload is over). Returns True when ready, False on timeout or + when the slot went empty. Logs how long it took.""" + t0 = self.reactor.monotonic() + first = self._gen1_slot_status(idx, slot) + while True: + st = self._gen1_slot_status(idx, slot) + if st == 'ready': + if st != first: + logging.info('[multiACE] gen1 tag tunnel: ACE %d slot %d ' + 'ready after %.1fs (was %s)', idx, slot, + self.reactor.monotonic() - t0, first or '?') + return True + if self._is_empty_status(st): + logging.info('[multiACE] gen1 tag tunnel: ACE %d slot %d ' + 'emptied while waiting for ready', idx, slot) + return False + if self.reactor.monotonic() - t0 > timeout: + logging.info('[multiACE] gen1 tag tunnel: ACE %d slot %d not ' + 'ready after %.0fs (%s)', + idx, slot, timeout, st or '?') + return False + self.reactor.pause(self.reactor.monotonic() + 0.5) + + # The pull-back after a search must not give up while the unit is busy + # with something else (a neighbour slot's preload answers FORBIDDEN). + GEN1_PULLBACK_PATIENCE = 60.0 + + def _gen1_move(self, idx, slot, mm, method, patience=None, gen=None): + """One V1 feed_filament / unwind_filament, waited for. Only + msg=success counts as a move. FORBIDDEN (busy) is retried a few + times, or for up to `patience` seconds when given; msg=empty (no + filament in the slot) ends at once. `gen` = the slot's gate counter + at search start: a gate flip (spool pulled or re-inserted) ends the + move unsent. Returns True when the move ran.""" + speed = self.GEN1_SEARCH_SPEED + until = (self.reactor.monotonic() + float(patience) + if patience else None) + attempt = 0 + while True: + attempt += 1 + if until is None: + if attempt > 4: + break + elif attempt > 1 and self.reactor.monotonic() > until: + break + if gen is not None and \ + self._gen1_gate_gen.get((idx, slot), 0) != gen: + logging.info('[multiACE] gen1 search: ACE %d slot %d gate ' + 'changed - %s %dmm not sent', idx, slot, + method, int(mm)) + return False + box = {} + + def _cb(*args, **kw): + box['r'] = kw.get('response', args[-1] if args else None) + + try: + self.send_request_to(idx, { + 'method': method, + 'params': {'index': slot, 'length': int(mm), + 'speed': speed}}, _cb) + except Exception as e: + logging.info('[multiACE] gen1 search: %s send failed on ACE ' + '%d: %s', method, idx, e) + return False + deadline = self.reactor.monotonic() + 3.0 + while 'r' not in box and self.reactor.monotonic() < deadline: + self.reactor.pause(self.reactor.monotonic() + 0.02) + resp = box.get('r') + msg = (str(resp.get('msg', '') or '').strip().upper() + if isinstance(resp, dict) else '') + ok = (isinstance(resp, dict) and resp.get('code', -1) == 0 + and msg in ('SUCCESS', 'OK', '')) + if isinstance(resp, dict) and msg == 'EMPTY': + logging.info('[multiACE] gen1 search: ACE %d slot %d %s ' + '%dmm refused - slot empty', idx, slot, + method, int(mm)) + return False + if ok: + try: + self._gen1_own_lanes.setdefault(idx, set()).add(slot) + except AttributeError: + pass + if attempt > 1: + logging.info('[multiACE] gen1 search: ACE %d slot %d %s ' + '%dmm accepted on attempt %d', idx, slot, + method, int(mm), attempt) + self.reactor.pause(self.reactor.monotonic() + + float(mm) / speed + 0.4) + return True + self.reactor.pause(self.reactor.monotonic() + 1.0) + logging.info('[multiACE] gen1 search: ACE %d slot %d %s %dmm ' + 'rejected', idx, slot, method, int(mm)) + return False + + # A card at the edge of the field answers SELECT but its page read + # fails: re-read further in, then just before the hit (the ACE 2 + # centres the same way). + GEN1_NUDGE_STEPS = (20, 20, -60) + # Partner rotation that decides whose card answers (like the ACE 2 + # neighbour rotation). + GEN1_PARTNER_MAX_MM = 200 + + def _gen1_unit_idle(self, idx): + """Every slot of the unit is idle (ready or empty): the ACE Pro + refuses motor commands unit-wide while any slot still moves.""" + try: + slots = (self._info_per_ace.get(idx) or {}).get('slots') or [] + for sl in slots: + st = str((sl or {}).get('status', '') or '') + if st and st != 'ready' and not self._is_empty_status(st): + return False + except Exception: + return True + return True + + def _gen1_wait_unit_idle(self, idx, timeout): + t0 = self.reactor.monotonic() + while not self._gen1_unit_idle(idx): + if self.reactor.monotonic() - t0 > timeout: + logging.info('[multiACE] gen1 tag tunnel: ACE %d still busy ' + 'after %.0fs', idx, timeout) + return False + self.reactor.pause(self.reactor.monotonic() + 0.5) + if self.reactor.monotonic() - t0 > 1.0: + logging.info('[multiACE] gen1 tag tunnel: ACE %d idle after ' + '%.1fs', idx, self.reactor.monotonic() - t0) + return True + + def _gen1_lane_shift(self, idx, slot, mm, patience=None, gen=None): + """Move a lane by mm (forward > 0, back < 0).""" + if not mm: + return True + return self._gen1_move(idx, slot, abs(mm), + 'feed_filament' if mm > 0 + else 'unwind_filament', patience=patience, + gen=gen) + + def _gen1_search_read(self, cli, idx, slot, skip_uids=(), say=None): + """Rotate the spool forward until a card answers, read it, pull the + lane back by the distance moved. A card that answers but cannot be + read is re-read at a few positions around the hit. Returns the read + dict or None.""" + if not self._gen1_lane_movable(idx, slot): + return None + if not (self._gen1_wait_ready(idx, slot, self.GEN1_INSERT_READY_WAIT) + and self._gen1_wait_unit_idle( + idx, self.GEN1_INSERT_READY_WAIT)): + logging.info('[multiACE] gen1 search: ACE %d slot %d not ' + 'ready (%s) - no search', idx, slot, + self._gen1_slot_status(idx, slot) or '?') + return None + pos = {'mm': 0} + res = None + # The slot's gate counter now: a pulled or re-inserted spool ends + # the search, and a different spool is never pulled back. + gen = self._gen1_gate_gen.get((idx, slot), 0) + if say: + say('[multiACE] ACE %d slot %d: rotating the spool to find the ' + 'tag' % (self._disp(idx), self._disp(slot))) + + def _unreadable(): + return (getattr(cli, 'last_reason', '') or '').startswith( + 'card selected') + + def _stop(): + return bool(getattr(cli, 'aborted', '')) or \ + bool(getattr(cli, 'no_reply', False)) or \ + self._gen1_gate_gen.get((idx, slot), 0) != gen + + def _nudge(): + for d in self.GEN1_NUDGE_STEPS: + if pos['mm'] + d < 0: + continue + if _stop() or not self._gen1_lane_shift(idx, slot, d, + gen=gen): + return None + pos['mm'] += d + r = cli.read_slot(slot, skip_uids=skip_uids) + if r: + logging.info('[multiACE] gen1 search ACE %d slot %d: ' + 're-read at %+dmm succeeded', idx, slot, + pos['mm']) + return r + return None + + try: + if _unreadable(): + res = _nudge() + moved_fwd = 0 + while res is None and moved_fwd < self.GEN1_SEARCH_MAX_MM \ + and not _unreadable() and not _stop(): + step = min(self.GEN1_SEARCH_STEP_MM, + self.GEN1_SEARCH_MAX_MM - moved_fwd) + if not self._gen1_lane_shift(idx, slot, step, gen=gen): + break + pos['mm'] += step + moved_fwd += step + res = cli.read_slot(slot, skip_uids=skip_uids) + if res is None and _unreadable(): + res = _nudge() + break + finally: + back = -pos['mm'] + if back and self._gen1_gate_gen.get((idx, slot), 0) != gen: + logging.info('[multiACE] gen1 search: ACE %d slot %d gate ' + 'changed during the search - no pull-back of ' + '%dmm', idx, slot, pos['mm']) + elif back and not self._gen1_lane_shift( + idx, slot, back, patience=self.GEN1_PULLBACK_PATIENCE, + gen=gen): + logging.warning('[multiACE] gen1 search: ACE %d slot %d ' + 'could not pull back %dmm', idx, slot, + pos['mm']) + logging.info('[multiACE] gen1 search ACE %d slot %d: %s after %dmm', + idx, slot, + ('card %s' % res.get('uid')) if res else + (getattr(cli, 'last_reason', '') or 'no card'), + pos['mm']) + return res + + def _gen1_whose_card(self, cli, idx, slot, uid): + """A card answered for `slot` while the partner lane (slot ^ 1) is + occupied by an unknown card: rotate the PARTNER and watch. The card + leaves the field -> it is the partner's ('partner'); it still + answers after GEN1_PARTNER_MAX_MM -> ours ('ours'). None when the + partner may not move or the test could not run.""" + partner = slot ^ 1 + if not self._gen1_lane_movable(idx, partner): + return None + if not (self._gen1_wait_ready(idx, partner, 60.0) + and self._gen1_wait_unit_idle(idx, 60.0)): + return None + moved = 0 + verdict = None + try: + while moved < self.GEN1_PARTNER_MAX_MM: + if not self._gen1_lane_shift(idx, partner, + self.GEN1_SEARCH_STEP_MM): + break + moved += self.GEN1_SEARCH_STEP_MM + r = cli.read_slot(slot, userdata=False) + if r is None and (getattr(cli, 'last_reason', '') or '') \ + .startswith('no card'): + verdict = 'partner' + break + if r and r.get('uid') and r.get('uid') != uid: + verdict = 'partner' + break + else: + verdict = 'ours' + finally: + if moved and not self._gen1_lane_shift( + idx, partner, -moved, + patience=self.GEN1_PULLBACK_PATIENCE): + logging.warning('[multiACE] gen1 tag tunnel: ACE %d slot %d ' + 'could not pull back %dmm', idx, partner, + moved) + logging.info('[multiACE] gen1 tag tunnel: ACE %d slot %d card %s ' + '-> %s (partner slot %d moved %dmm)', idx, slot, uid, + verdict or 'undecided', partner, moved) + return verdict + + def _gen1_attribute(self, cli, idx, slot, res, search, why, skip_uids, + say=None): + """Decide whose card `res` is when the partner bay holds an + unknown card. Returns (res_for_slot, bind): the read to keep for + `slot` and whether it may bind. A card proven to be the partner's + is stored for the partner, and our lane is searched with that card + skipped (when `search`).""" + uid = (res or {}).get('uid', '') + verdict = self._gen1_whose_card(cli, idx, slot, uid) if uid else None + if verdict == 'ours': + return res, True + if verdict != 'partner': + return res, False + partner = slot ^ 1 + pres = dict(res) + pres['slot'] = partner + self._gen1_tunnel_store(idx, partner, pres, why='attributed', + bind=True) + skip = set(skip_uids or ()) | {uid} + mine = cli.read_slot(slot, skip_uids=skip) + if not mine and search and self._gen1_should_search(cli): + mine = self._gen1_search_read(cli, idx, slot, skip_uids=skip, + say=say) + return mine, bool(mine) + + @staticmethod + def _gen1_should_search(cli): + """Rotate to search when no card answered, only the partner slot's + known card did (ours is then out of the field), or a card answered + but could not be read (re-read around it).""" + why = getattr(cli, 'last_reason', '') or '' + return (why.startswith('no card') + or why.startswith('only the partner') + or why.startswith('card selected')) + + def _gen1_partner_uids(self, idx, partner): + """Card UIDs known to belong to `partner` (the other slot on the + shared antenna), upper hex: its last tunnel read and the codes of + the spool bound there that look like a card UID (8 or 14 hex + digits). Empty set = nothing known.""" + out = set() + try: + # Only an ATTRIBUTED read counts: a bound one, or one the + # operator asked for on that slot. A read stored unbound may + # belong to either bay. + ent = (self._gen1_tunnel_reads.get(idx) or {}).get(partner) or {} + if ent.get('uid') and (ent.get('bound') + or ent.get('why') == 'manual'): + out.add(str(ent['uid']).strip().upper()) + sid = self._spool_binding.get(self._spool_key(idx, partner)) + sp = self._spools.get(str(sid)) if sid is not None else None + for code in self._sku_codes((sp or {}).get('sku')): + c = code.replace(':', '').upper() + if len(c) in (8, 14) and all( + ch in '0123456789ABCDEF' for ch in c): + out.add(c) + except Exception as e: + logging.info('[multiACE] gen1 tag tunnel: partner UID lookup ' + 'failed (ignored): %s', e) + return out + + def _gen1_tunnel_schedule(self, idx, slot, bind=True, skip_uids=(), + search=False, attribute=False): """Queue ONE tunnel read in its own greenlet (the reply poll uses reactor.pause, which must not run in the heartbeat callback). `bind` carries the shared-antenna attribution decision from the @@ -13372,36 +14126,86 @@ def _gen1_tunnel_schedule(self, idx, slot, bind=True): if cli is None: return self._gen1_tunnel_busy.add(idx) + self._gen1_tunnel_busy_slot[idx] = slot def _run(eventtime): try: + self._gen1_session_begin(idx) + cli.aborted = '' if not cli.tunnel_available(): + if cli.aborted: + self._gen1_read_aborted(idx, slot, cli, search) return # one log line, then never again - res = cli.read_slot(slot) - if res: + if search: + # A fresh insert: let the firmware's preload finish + # first, the spool is still moving during it. + self._gen1_wait_ready(idx, slot, + self.GEN1_INSERT_READY_WAIT) + if self._is_empty_status( + self._gen1_slot_status(idx, slot)): + return + do_bind, do_attr, skip = bind, attribute, skip_uids + # The partner bay may have been filled while we waited: + # decide the attribution again from what sits there now. + pst = self._gen1_slot_status(idx, slot ^ 1) + if search and pst and not self._is_empty_status(pst) \ + and not skip: + skip = self._gen1_partner_uids(idx, slot ^ 1) + do_bind = bool(skip) + do_attr = not skip + # The gate snapshot the session's abort check compares + # against: taken after the waits above. + self._gen1_session_begin(idx) + res = cli.read_slot(slot, skip_uids=skip) + if not res and search and self._gen1_should_search(cli): + res = self._gen1_search_read(cli, idx, slot, + skip_uids=skip) + if res and do_attr and not do_bind: + res, do_bind = self._gen1_attribute( + cli, idx, slot, res, search, 'auto', skip) + if not res and cli.aborted: + self._gen1_read_aborted(idx, slot, cli, search) + elif res: self._gen1_tunnel_store(idx, slot, res, why='auto', - bind=bind) + bind=do_bind) else: logging.info( '[multiACE] gen1 tag tunnel: ACE %d slot %d: no ' - 'tag answered on the antenna (tag must face the ' - 'coil) - one attempt per insert, ACE_TAG_READ ' - 'retries on demand', self._disp(idx), - self._disp(slot)) + 'tag read (%s) - in-place only, ACE_TAG_READ ' + 'searches on demand', self._raw(idx), + self._raw(slot), + getattr(cli, 'last_reason', '') or 'no reason') except Exception as e: logging.info('[multiACE] gen1 tag tunnel: read failed on ' 'ACE %d slot %d (ignored): %s' - % (self._disp(idx), self._disp(slot), e)) + % (self._raw(idx), self._raw(slot), e)) finally: self._gen1_tunnel_busy.discard(idx) + self._gen1_tunnel_busy_slot.pop(idx, None) try: self.reactor.register_async_callback(_run) except Exception as e: self._gen1_tunnel_busy.discard(idx) + self._gen1_tunnel_busy_slot.pop(idx, None) logging.info('[multiACE] gen1 tag tunnel: schedule failed ' '(ignored): %s' % e) + def _gen1_read_aborted(self, idx, slot, cli, search): + """An automatic read stopped for a firmware insert: it does not use + up the slot's one attempt, the tick schedules it again once the + unit is quiet (a fresh insert keeps its search).""" + logging.info('[multiACE] gen1 tag tunnel: ACE %d slot %d: read ' + 'stopped (%s) - retried when the unit is idle', + self._raw(idx), self._raw(slot), cli.aborted) + try: + self._gen1_tunnel_tried.get(idx, {}).pop(slot, None) + self._gen1_tunnel_aborted.setdefault(idx, set()).add(slot) + if search: + self._gen1_seen_empty.setdefault(idx, set()).add(slot) + except Exception: + pass + def _gen1_tunnel_store(self, idx, slot, res, why='auto', bind=True): """Record one tunnel read and offer its card UID to the SHARED tag-bind path (unbind=False - the tunnel never releases a vendor @@ -13427,14 +14231,19 @@ def _gen1_tunnel_store(self, idx, slot, res, why='auto', bind=True): 'ts': self.reactor.monotonic(), 'why': why, 'bound': False, + # All NTAG user pages read: a missing OpenSpool record is + # then a fact, not a read failure. + 'userdata_complete': bool(res.get('userdata_complete')), } uid = ent['uid'] ent['bound'] = bool(uid and bind) self._gen1_tunnel_reads.setdefault(idx, {})[slot] = ent + if ent['uid']: + self._persist_gen1_tag_reads() if uid: logging.info( '[multiACE] [spool] gen1 tunnel read ACE %d slot %d: ' - 'card UID %s (%s)%s', self._disp(idx), self._disp(slot), + 'card UID %s (%s)%s', self._raw(idx), self._raw(slot), uid, ent['format'], ' - OpenSpool %s %s %s' % ( ent['material'] or '?', ent['color'] or '?', @@ -13448,13 +14257,13 @@ def _gen1_tunnel_store(self, idx, slot, res, why='auto', bind=True): 'slot on the shared antenna is occupied or ' 'unknown, so the read cannot be attributed to this ' 'slot; ACE_TAG_READ is the operator probe', - self._disp(idx), self._disp(slot), uid, + self._raw(idx), self._raw(slot), uid, ent['format']) else: logging.info( '[multiACE] gen1 tunnel read ACE %d slot %d: a card ' 'answered but no readable page (format %s) - no UID', - self._disp(idx), self._disp(slot), ent['format']) + self._raw(idx), self._raw(slot), ent['format']) except Exception as e: logging.info('[multiACE] gen1 tunnel store failed (ignored): %s' % e) @@ -13463,13 +14272,14 @@ def _gen1_tunnel_status(self, idx): """The additive get_status block for one unit.""" out = {'enabled': bool(getattr(self, 'gen1_tag_tunnel', False)), 'available': None, 'reads': {}} - cli = self._gen1_tunnel_clients.get(idx) + cli = getattr(self, '_gen1_tunnel_clients', {}).get(idx) if cli is not None: sup = cli.support_state() if sup is not None: out['available'] = bool(sup[1]) now = self.reactor.monotonic() - for slot, ent in (self._gen1_tunnel_reads.get(idx) or {}).items(): + for slot, ent in (getattr(self, '_gen1_tunnel_reads', {}).get(idx) + or {}).items(): out['reads'][str(slot)] = { 'uid': ent.get('uid', ''), 'format': ent.get('format', ''), @@ -14081,8 +14891,8 @@ def book_spool_use(self, head, mm, why='move'): '%s, used so far %.0fmm (%s)' % (head, sid, self._spool_label(spool), ('ACE %d / Slot %d' - % (self._disp(src['ace_index']), - self._disp(src['slot']))) + % (self._raw(src['ace_index']), + self._raw(src['slot']))) if src else 'head binding', prev_used, why)) logging.debug('[multiACE] [spool] %s: %.1fmm on head %d (%s)' @@ -14215,7 +15025,7 @@ def _auto_dry_release(self, idx, why): if had_ext: self._auto_dry_persist_ext() logging.info('[multiACE] auto-dry ownership released on ACE %d (%s)' - % (self._disp(idx), why)) + % (self._raw(idx), why)) def _auto_dry_persist(self): """Ownership must survive a restart - see the note at the field.""" @@ -14267,17 +15077,17 @@ def _cb(self, response): if response is not None and response.get('code', 0) != 0: logging.info('[multiACE] exhaust %s refused on ACE %d: %s' % ('open' if is_open else 'close', - self._disp(idx), response.get('msg'))) + self._raw(idx), response.get('msg'))) try: self.send_request_to(idx, {'method': 'set_valve', 'params': {'v1': bool(is_open), 'v2': bool(is_open)}}, _cb) logging.info('[multiACE] exhaust %s on ACE %d (%s)' % ('OPEN' if is_open else 'CLOSE', - self._disp(idx), why)) + self._raw(idx), why)) except Exception as e: logging.info('[multiACE] exhaust %s on ACE %d failed: %s' - % ('open' if is_open else 'close', self._disp(idx), e)) + % ('open' if is_open else 'close', self._raw(idx), e)) def _schedule_dry_exhaust_open(self, idx, why): """Open the drying exhaust a while AFTER the dryer really started. @@ -14314,7 +15124,7 @@ def _open(eventtime): self.reactor.register_timer(_open, self.reactor.monotonic() + delay) logging.info('[multiACE] exhaust: opening ACE %d in %.0fs (%s)' - % (self._disp(idx), delay, why)) + % (self._raw(idx), delay, why)) def _close_dry_exhaust(self, idx, why): """Close on every stop, and drop a not-yet-sent open. The ACE 2 shuts @@ -14358,7 +15168,7 @@ def _cb(self, response): + AUTO_DRY_SOFT_STEP_SECONDS} logging.info('[multiACE] auto-dry soft start ACE %d: %d C ' 'now, +%d every %d min up to %d C' - % (self._disp(idx), first, AUTO_DRY_SOFT_STEP, + % (self._raw(idx), first, AUTO_DRY_SOFT_STEP, int(AUTO_DRY_SOFT_STEP_SECONDS // 60), target)) else: @@ -14377,7 +15187,7 @@ def _cb(self, response): # a fired start/stop left NO trace and a later log could not say # whether the loop had acted at all. logging.info('[multiACE] auto-dry START ACE %d temp=%s (%s)' - % (self._disp(idx), first, why)) + % (self._raw(idx), first, why)) self.log_always(self._t('msg.auto_dry_start', ace=self._disp(idx), temp=first, why=why)) except Exception as e: @@ -14405,7 +15215,7 @@ def _cb(self, response): if self._auto_dry_follow_until.pop(idx, None) is not None: self._auto_dry_persist_follow() logging.info('[multiACE] auto-dry STOP ACE %d (%s)' - % (self._disp(idx), why)) + % (self._raw(idx), why)) self.log_always(self._t('msg.auto_dry_stop', ace=self._disp(idx), why=why)) except Exception as e: @@ -14483,7 +15293,7 @@ def _cb(self, response): st['current'] = nxt st['next'] = eventtime + AUTO_DRY_SOFT_STEP_SECONDS logging.info('[multiACE] auto-dry ramp ACE %d: %d C (target %d)' - % (self._disp(idx), nxt, st['target'])) + % (self._raw(idx), nxt, st['target'])) if nxt >= int(st['target']): self._auto_dry_ramp.pop(idx, None) @@ -14558,7 +15368,7 @@ def _auto_dry_tick(self, eventtime): self._auto_dry_seen[idx] = seen logging.info('[multiACE] auto-dry ACE %d: %.0f%%rH (%s) ' 'device_drying=%s ours=%s (start>=%s stop<=%s)' - % (self._disp(idx), rh, + % (self._raw(idx), rh, 'external' if ext is not None else 'internal', drying, ours, @@ -14680,6 +15490,49 @@ def _spool_sample_tick(self, eventtime): 'in grams (net, without the core); with ACE+SLOT the new spool is ' 'bound to that slot right away.') + def _slot_read_uid(self, idx, slot): + """The card UID last read at this slot: the ACE 2 host read + (rc522 registry) or the ACE Pro tunnel read. '' when none.""" + try: + uid = (getattr(self, '_rc_last_uid', None) or {}).get( + (idx, slot), '') + if not uid: + ent = ((getattr(self, '_gen1_tunnel_reads', None) or {}) + .get(idx) or {}).get(slot) or {} + uid = ent.get('uid', '') + return str(uid or '').replace(':', '').strip().upper() + except Exception: + return '' + + def _tag_read_capable(self, idx): + """Can multiACE read tags on this unit itself: ACE2-Open, or an + ACE Pro whose firmware answers the Gen-1 tunnel.""" + try: + if self._is_open_fw_idx(idx): + return True + cli = (getattr(self, '_gen1_tunnel_clients', None) or {}).get(idx) + sup = cli.support_state() if cli is not None else None + return bool(sup and sup[1]) + except Exception: + return False + + def _warn_no_tag_code(self, idx, slot, sid): + """Log line: the spool just placed at this slot has no code and no + tag was read there, so it cannot bind by itself. The web says so + once in a dialog when such a spool is created; no notice here.""" + try: + sp = self._spools.get(str(sid)) or {} + if self._sku_codes(sp.get('sku')) or self._slot_read_uid(idx, + slot): + return + if not self._tag_read_capable(idx): + return + logging.info('[multiACE] [spool] spool #%s at ACE %d slot %d has ' + 'no tag code yet', sid, self._raw(idx), + self._raw(slot)) + except Exception as e: + logging.info('[multiACE] [spool] no-code notice skipped: %s' % e) + def cmd_ACE_SPOOL_ADD(self, gcmd): sid = str(self._spool_next_id) self._spool_next_id += 1 @@ -14719,7 +15572,22 @@ def cmd_ACE_SPOOL_ADD(self, gcmd): if h is not None and self.head_uses_ace(h): raise self._ace_error(gcmd, self._t( 'msg.spool_head_not_feeder', head=self._disp(h)), code=200) + # The card UID read at that slot is the per-chip key: carry it next + # to the tag SKU, so the spool binds by either from then on (V1 and + # V2 alike). Not when another spool already lists it. + if a is not None and sl is not None: + _uid = self._slot_read_uid(a, sl) + if (_uid and self._sku_canon(_uid) + not in self._sku_codes(spool['sku']) + and self._spool_by_sku(_uid)[1] is None): + spool['sku'] = (('%s,%s' % (spool['sku'], _uid)) + if spool['sku'] else _uid) self._spools[sid] = spool + logging.info('[multiACE] [spool] added #%s (%s) sku=%r%s', sid, + self._spool_label(spool), spool['sku'], + (' bound to ACE %d slot %d' % (a, sl)) + if a is not None and sl is not None else + ((' bound to head %d' % h) if h is not None else '')) if a is not None and sl is not None: self._spool_bind(self._spool_key(a, sl), sid) elif h is not None: @@ -14732,6 +15600,8 @@ def cmd_ACE_SPOOL_ADD(self, gcmd): if _sku_suffixed: self.log_warn(self._t('msg.spool_sku_suffixed', id=sid, sku=spool['sku'])) + if a is not None and sl is not None: + self._warn_no_tag_code(a, sl, sid) cmd_ACE_SPOOL_SET_help = ( '[multiACE] Edit a spool: ACE_SPOOL_SET ID=n [WEIGHT=..] ' @@ -14915,8 +15785,9 @@ def cmd_ACE_SPOOL_PA(self, gcmd): 'physically rotates the lane, restored afterwards). ACE Pro (Gen 1, ' 'community firmware with the RC522 tunnel): read the tag directly - ' 'ACE_TAG_READ ACE=n SLOT=n [PAGE=n] prints the raw page bytes and ' - 'the card UID; moves nothing, safe during a print. PAGE defaults to ' - '0; the tag must face the coil.') + 'the card UID; when no card answers and the lane may move (idle, ' + 'slot feeds no head) it rotates the spool to find the tag and pulls ' + 'it back. PAGE defaults to 0.') def cmd_ACE_TAG_READ(self, gcmd): ace_idx = gcmd.get_int('ACE', self._active_device_index, @@ -15021,9 +15892,8 @@ def _run(eventtime): def _cmd_ace_tag_read_gen1(self, gcmd, ace_idx, slot): """ACE_TAG_READ on an ACE Pro (Gen 1): one tunnel read session. - The command itself is the explicit consent - it runs with the - gen1_tag_tunnel config flag off (the flag gates only the automatic - fallback). Synchronous is not an option in the gcode thread, so the + The explicit retry next to the automatic path's one attempt per + insert. Synchronous is not an option in the gcode thread, so the read runs in its own greenlet; the console gets the raw page bytes and the UID. The result also feeds the own-store/status surface and the SHARED tag bind (unbind=False) exactly like an automatic read.""" @@ -15031,15 +15901,16 @@ def _cmd_ace_tag_read_gen1(self, gcmd, ace_idx, slot): if not self._connected_per_ace.get(ace_idx): raise self._ace_error(gcmd, 'ACE %d is not connected' % self._disp(ace_idx), code=208) - if ace_idx in self._gen1_tunnel_busy: - raise self._ace_error(gcmd, 'a Gen-1 tag read is already running ' - 'on ACE %d' % self._disp(ace_idx), code=200) cli = self._gen1_tunnel_client(ace_idx) if cli is None: raise self._ace_error(gcmd, 'the Gen-1 tag tunnel helper ' '(ace_gen1_tunnel.py) is missing on this ' 'install - re-run the installer', code=200) - self._gen1_tunnel_busy.add(ace_idx) + # A running read (automatic or manual) is waited for in the + # greenlet below instead of refusing the command. + _waits = ace_idx in self._gen1_tunnel_busy + _claim = {'held': False} + self._gen1_manual_busy = True # the web picker's read bar # Same outcome contract as the V2 read (ace.py's tag_op status): # the web picker bar can report this op when/if it is offered on a # Gen 1. seq ties the result to the call. @@ -15051,6 +15922,30 @@ def _cmd_ace_tag_read_gen1(self, gcmd, ace_idx, slot): def _run(eventtime): try: + deadline = (self.reactor.monotonic() + + self.GEN1_MANUAL_BUSY_WAIT) + while ace_idx in self._gen1_tunnel_busy: + if self.reactor.monotonic() > deadline: + _out['ok'] = False + _out['msg'] = 'another tag read did not finish' + self.log_always( + '[multiACE] ACE %d: another tag read is still ' + 'running - retry later' % self._disp(ace_idx)) + return + self.reactor.pause(self.reactor.monotonic() + 0.5) + self._gen1_tunnel_busy.add(ace_idx) + self._gen1_tunnel_busy_slot[ace_idx] = slot + _claim['held'] = True + # Not while the firmware inserts a spool on this unit. + if not self._gen1_wait_no_insert( + ace_idx, self.GEN1_INSERT_READY_WAIT): + _out['ok'] = False + _out['msg'] = 'a spool insert is still running' + self.log_always( + '[multiACE] ACE %d: a spool insert is still running ' + '- retry the read afterwards' % self._disp(ace_idx)) + return + self._gen1_session_begin(ace_idx) if not cli.tunnel_available(): fw = (self._ace_models.get(ace_idx) or ('', '?'))[1] _out['ok'] = False @@ -15063,10 +15958,35 @@ def _run(eventtime): % (self._disp(ace_idx), fw, 'CV1.3.87x')) return - res = cli.read_slot(slot, page=page) + skip = self._gen1_partner_uids(ace_idx, slot ^ 1) + res = cli.read_slot(slot, page=page, skip_uids=skip) + if not res and page == 0 and self._gen1_should_search(cli): + res = self._gen1_search_read(cli, ace_idx, slot, + skip_uids=skip, + say=self.log_always) + # Partner occupied by an unknown card: rotate it to tell + # whose card this is (the operator read binds anyway when + # the test cannot decide). + if (res and page == 0 and not skip + and not self._is_empty_status( + self._gen1_slot_status(ace_idx, slot ^ 1))): + res, _b = self._gen1_attribute( + cli, ace_idx, slot, res, True, 'manual', skip, + say=self.log_always) + if not res and getattr(cli, 'aborted', ''): + _out['ok'] = False + _out['msg'] = 'read stopped: %s' % cli.aborted + self.log_always( + '[multiACE] ACE %d slot %d: tag read stopped (%s) ' + '- retry the read afterwards' + % (self._disp(ace_idx), self._disp(slot), + cli.aborted)) + return if not res: _out['ok'] = False - _out['msg'] = 'no tag answered (tag must face the coil)' + _out['msg'] = ('no tag answered (%s)' + % (getattr(cli, 'last_reason', '') + or 'tag must face the coil')) self.log_always( '[multiACE] ACE %d slot %d page %d: no tag answered ' 'on the antenna - the tag must face the coil, then ' @@ -15084,7 +16004,15 @@ def _run(eventtime): self._disp(slot), e)) logging.exception('[multiACE] gen1 tag tunnel: read_slot') finally: - self._gen1_tunnel_busy.discard(ace_idx) + self._gen1_manual_busy = False + if _claim['held']: + self._gen1_tunnel_busy.discard(ace_idx) + self._gen1_tunnel_busy_slot.pop(ace_idx, None) + # The console lines above never reach klippy.log. + logging.info('[multiACE] Gen-1 tag read ACE %d slot %d page ' + '%d: %s - %s', ace_idx, slot, page, + 'ok' if _out['ok'] else 'failed', + _out['msg'] or 'no tag read') self._tag_op_result = { 'ok': bool(_out['ok']), 'kind': 'read', 'seq': _seq, 'msg': (_out['msg'] or 'no tag read')[:200]} @@ -15092,11 +16020,13 @@ def _run(eventtime): try: self.reactor.register_async_callback(_run) except Exception as e: - self._gen1_tunnel_busy.discard(ace_idx) + self._gen1_manual_busy = False raise self._ace_error(gcmd, 'could not schedule the Gen-1 tag ' 'read (%s)' % e, code=200) - gcmd.respond_info('[multiACE] Gen-1 tag read started (ACE %d slot ' - '%d page %d)' % (self._disp(ace_idx), + gcmd.respond_info('[multiACE] Gen-1 tag read %s (ACE %d slot ' + '%d page %d)' % ('queued behind a running read' + if _waits else 'started', + self._disp(ace_idx), self._disp(slot), page)) def _gen1_tunnel_report(self, ace_idx, slot, res): @@ -15386,8 +16316,7 @@ def cmd_ACE_SPOOL_ASSIGN(self, gcmd): _learn = gcmd.get_int('LEARN_UID', 1, minval=0, maxval=1) if h is None and _learn: try: - _uid = (getattr(self, '_rc_last_uid', None) - or {}).get((a, sl), '') + _uid = self._slot_read_uid(a, sl) _sp = self._spools[sid] if _uid and (self._sku_canon(_uid) not in self._sku_codes(_sp.get('sku'))): @@ -15402,6 +16331,8 @@ def cmd_ACE_SPOOL_ASSIGN(self, gcmd): logging.info('[multiACE] [spool] UID learn skipped: %s' % _e) self._save_spool_db(backup=True) + if h is None: + self._warn_no_tag_code(a, sl, sid) # Feeder/manual heads never pass the toolchange PA hook (no # head_source), so a head binding applies the stored value here. if h is not None: @@ -15878,7 +16809,7 @@ def _discard_wipe(self, head, tag): 'INNER_ROUGHLY_CLEAN_NOZZLE_BASE_DISCARD ACTION=2') self.toolhead.wait_moves() logging.info('[multiACE] [%s] nozzle wipe done (head %s)' - % (tag, self._disp(head) if head is not None else '?')) + % (tag, self._raw(head) if head is not None else '?')) _wipe_ok = True except Exception as e: logging.info('[multiACE] [%s] wipe failed (continuing): ' @@ -15957,7 +16888,7 @@ def _perform_switch(self, gcmd, target, autoload): if not switching_ace and autoload: logging.info(self._t('msg.ace_already_active_loading', - ace=self._disp(target))) + ace=self._raw(target))) else: if target >= len(self._ace_devices) or not self._connected_per_ace.get(target, False): self.log_always(self._t('msg.ace_not_connected', @@ -16055,7 +16986,7 @@ def _perform_switch(self, gcmd, target, autoload): self.log_always(self._t('msg.switch_unload_complete')) logging.info(self._t('msg.switch_activating', - ace=self._disp(target))) + ace=self._raw(target))) self._set_active_idx(target) self._push_rfid_info() @@ -16146,8 +17077,8 @@ def _tag_read_blocked_by_neighbour(self, idx, slot, uid=''): logging.info('[multiACE] [rc522] ACE %d slot %d: ' 'dropped the stored read %s - it is the ' 'neighbour\'s card (slot %d)' - % (self._disp(idx), self._disp(slot), - uid, self._disp(nslot))) + % (self._raw(idx), self._raw(slot), + uid, self._raw(nslot))) except Exception: logging.exception('[multiACE] [rc522] stale read evict') return msg @@ -16871,7 +17802,7 @@ def _switch_ace_for_head(self, head_index): return False logging.info(self._t('msg.activating_ace_for_head', - ace=self._disp(target_ace), head=self._disp(head_index))) + ace=self._raw(target_ace), head=self._raw(head_index))) self._set_active_idx(target_ace) @@ -17175,7 +18106,7 @@ def _resistance_note(self, site, head, ace_idx, slot, delta, push=0, logging.info( '[multiACE] [resistance] lane ACE %d/Slot %d: %d ' 'strike(s) cleared after %d consecutive healthy reads' - % (self._disp(lane[0]), self._disp(lane[1]), + % (self._raw(lane[0]), self._raw(lane[1]), self._resistance_strikes.get(lane, 0), ok_l)) self._resistance_strikes.pop(lane, None) if (ok_h >= RESISTANCE_STRIKE_CLEAR_READS @@ -17183,7 +18114,7 @@ def _resistance_note(self, site, head, ace_idx, slot, delta, push=0, logging.info( '[multiACE] [resistance] head %d: %d head strike(s) ' 'cleared after %d consecutive healthy reads' - % (self._disp(head), + % (self._raw(head), self._resistance_head_strikes.get(head, 0), ok_h)) self._resistance_head_strikes.pop(head, None) if (self._resistance_strikes.get(lane) @@ -17193,7 +18124,7 @@ def _resistance_note(self, site, head, ace_idx, slot, delta, push=0, 'normal (delta=%.0f baseline=%.0f) - lane strikes=%d ' 'head strikes=%d kept (%d/%d healthy reads toward ' 'clear)' - % (self._disp(lane[0]), self._disp(lane[1]), delta, + % (self._raw(lane[0]), self._raw(lane[1]), delta, baseline if baseline else 0., self._resistance_strikes.get(lane, 0), self._resistance_head_strikes.get(head, 0), @@ -17225,7 +18156,7 @@ def _resistance_note(self, site, head, ace_idx, slot, delta, push=0, '%d/Slot %d delta=%.0f baseline=%s ratio=%s thr=%d strike=%d ' 'head_strikes=%d' % (site, ' (noisy pt)' if noisy else '', head, - self._disp(lane[0]), self._disp(lane[1]), + self._raw(lane[0]), self._raw(lane[1]), delta, ('%.0f' % baseline) if baseline else '-', ('%.2f' % ratio) if ratio else '-', int(abs_thr), strikes, h_strikes)) @@ -17650,7 +18581,7 @@ def _measure(push_mm): 'INNER_ROUGHLY_CLEAN_NOZZLE_BASE_DISCARD ACTION=2') self.toolhead.wait_moves() logging.info('[multiACE] [pick-check] nozzle wipe ' - 'done (head %d)' % self._disp(head)) + 'done (head %d)' % self._raw(head)) except Exception as we: logging.info('[multiACE] [pick-check] nozzle wipe ' 'failed (continuing): %s' % we) @@ -18248,7 +19179,7 @@ def cmd_ACE_LOAD_HEAD(self, gcmd): })) self._save_head_source() logging.info(self._t('msg.load_head_inferred_only_ace', - head=self._disp(head), slot=self._disp(slot))) + head=self._raw(head), slot=self._raw(slot))) else: self.log_error(self._t('msg.load_head_no_source_recorded', head=self._disp(head), count=len(self._ace_devices))) @@ -18550,7 +19481,7 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): sensor = self.printer.lookup_object( 'filament_motion_sensor e%d_filament' % head, None) if sensor and not sensor.get_status(0)['filament_detected']: - logging.info(self._t('msg.unload_sensor_no_filament', head=self._disp(head))) + logging.info(self._t('msg.unload_sensor_no_filament', head=self._raw(head))) source = self._head_source.get(head) if source is None: @@ -18578,7 +19509,7 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): % self._disp(ace_index), code=208, head=head) else: - logging.info(self._t('msg.unload_head_no_mapping', head=self._disp(head))) + logging.info(self._t('msg.unload_head_no_mapping', head=self._raw(head))) active_idx = self._active_device_index @@ -19559,7 +20490,7 @@ def cmd_ACE_SWAP_HEAD(self, gcmd): if ace_index != self._active_device_index: logging.info(self._t('msg.swap_switching_ace', - ace=self._disp(ace_index))) + ace=self._raw(ace_index))) if not self._switch_ace_for_head_target(ace_index): raise self._ace_error(gcmd, 'Failed to connect to ACE %d' % self._disp(ace_index), @@ -19912,6 +20843,8 @@ def cmd_ACE_HEAD_STATUS(self, gcmd): for i, device in enumerate(self._ace_devices): marker = ' << ACTIVE' if i == self._active_device_index else '' + if not self._connected_per_ace.get(i, False): + marker += ' -- OFFLINE (not connected)' protocol_cls = self._ace_path_protocol.get(device) proto_name = protocol_cls.NAME if protocol_cls else '?' model, firmware = self._ace_models.get(i, ('?', '?')) @@ -20552,7 +21485,7 @@ def _unload_all_heads(self, gcmd): source = self._head_source.get(head) if source and source['ace_index'] != self._active_device_index: logging.info(self._t('msg.switching_ace_for_retract', - ace=self._disp(source['ace_index']), head=self._disp(head))) + ace=self._raw(source['ace_index']), head=self._raw(head))) switched = False for attempt in range(5): if self._switch_ace_for_head_target(source['ace_index']): @@ -20904,6 +21837,8 @@ def cmd_ACE_LIST(self, gcmd): self.log_always(self._t('msg.found_n_aces', count=len(self._ace_devices))) for i, device in enumerate(self._ace_devices): active = ' << ACTIVE' if i == self._active_device_index else '' + if not self._connected_per_ace.get(i, False): + active += ' -- OFFLINE (not connected)' self.log_always(self._t('msg.ace_list_simple', ace=self._disp(i), device=device, active=active)) @@ -21546,6 +22481,8 @@ def get_status(self, eventtime=None): aces.append({ 'idx': i, 'connected': self._connected_per_ace.get(i, False), + 'connect_error': (getattr(self, '_connect_error_per_ace', {}) + .get(i, '')), 'protocol': getattr(protocol, 'NAME', '') if protocol else '', # Model + firmware from the connect handshake (get_info) - # captured in _ace_models since ever, now surfaced so the @@ -21632,7 +22569,8 @@ def get_status(self, eventtime=None): # Running tag op (read/write/insert) - the web shows a plain # "writing..." bar while busy and reports only the outcome. # str keys. - 'tag_op': {'busy': bool(getattr(self, '_tag_read_busy', False)), + 'tag_op': {'busy': bool(getattr(self, '_tag_read_busy', False) + or getattr(self, '_gen1_manual_busy', False)), 'kind': str(getattr(self, '_tag_op_kind', '') or ''), 'seq': int(getattr(self, '_tag_op_seq', 0) or 0), 'result': dict(getattr(self, '_tag_op_result', None) @@ -21687,9 +22625,6 @@ def get_status(self, eventtime=None): ('tag_write_uid_sku', getattr(self, 'tag_write_uid_sku', None), getattr(self, '_tag_write_uid_sku_cfg', None)), - ('gen1_tag_tunnel', - getattr(self, 'gen1_tag_tunnel', None), - getattr(self, '_gen1_tag_tunnel_cfg', None)), ) if _cfgv is not None and _cur != _cfgv], # Spool table: str keys (orjson: int keys shut # the printer down), weights are estimates. diff --git a/multiace/klipper/extras/ace_bg_swap.py b/multiace/klipper/extras/ace_bg_swap.py index 0c68d06b..a2a224b7 100644 --- a/multiace/klipper/extras/ace_bg_swap.py +++ b/multiace/klipper/extras/ace_bg_swap.py @@ -334,6 +334,15 @@ def _say(self, msg): def _ext_name(self, head): return 'extruder' if head == 0 else 'extruder%d' % head + def _raw(self, idx): + # Internal (0-based) index for internal log lines. + if idx is None: + return '-' + try: + return int(idx) + except (TypeError, ValueError): + return idx + def _dh(self, idx): # Display index (1-based per display_index_base) for USER-FACING # message text only - the raw internal index confused even the @@ -512,7 +521,7 @@ def _dwell_fan(self, ace, head, on): f.set_speed_from_command(min(spd, 255) / 255.) logging.info('[multiACE] [bg] dwell fan ON S%d head %d ' '(was S%d)' - % (spd, self._dh(head), + % (spd, self._raw(head), int(round(prev * 255.)))) else: if head not in self._dwell_fan_prev: @@ -523,7 +532,7 @@ def _dwell_fan(self, ace, head, on): f.set_speed_from_command(prev) logging.info('[multiACE] [bg] dwell fan OFF head %d ' '(restored S%d)' - % (self._dh(head), int(round(prev * 255.)))) + % (self._raw(head), int(round(prev * 255.)))) except Exception as e: logging.info('[multiACE] [bg] dwell fan toggle failed ' '(ignored): %s' % e) diff --git a/multiace/klipper/extras/ace_gen1_tunnel.py b/multiace/klipper/extras/ace_gen1_tunnel.py index 140c037b..efc7fc93 100644 --- a/multiace/klipper/extras/ace_gen1_tunnel.py +++ b/multiace/klipper/extras/ace_gen1_tunnel.py @@ -34,10 +34,8 @@ # and 3). The reader CHANNEL for a slot is the bit-swap 0,1,2,3 -> 0,2,1,3, # i.e. channel = ((slot & 1) << 1) | ((slot >> 1) & 1) (slot_channel()). # The partner slot sharing the same antenna is SLOT ^ 1; the partner -# reader CHANNEL is CHANNEL ^ 2. The tag must face the coil: there is no -# host-side motor control on a Gen-1 that could rotate a spool to look for -# it (feed/unwind are refused by the stock firmware), so a read is -# opportunistic by construction. +# reader CHANNEL is CHANNEL ^ 2. The tag must face the coil; ace.py rotates +# the spool with feed/unwind to bring it there (_gen1_search_read). # # Read sequence (tunnel-verified host order, inside ONE acquire/release): # acquire -> SELECT -> TXMODE |= 0x80 -> RXMODE |= 0x80 -> @@ -83,6 +81,10 @@ OP_RELEASE = 8 # RC522 registers this client touches (the full map is in the tunnel docs). +REG_COMIRQ = 0x04 +COMIRQ_RX = 0x20 # ComIrqReg RxIRq: a reception ended +REG_ERROR = 0x06 +REG_FIFOLEVEL = 0x0A REG_BITFRAMING = 0x0D REG_TXMODE = 0x12 REG_RXMODE = 0x13 @@ -97,6 +99,10 @@ # NTAG READ(0x30) of `page` returns 16 bytes = pages page..page+3. NTAG_READ = 0x30 +# ISO14443-3 HLTA: puts the selected card to sleep. A halted card ignores +# the REQA that op 6 sends, so the other card on a shared antenna answers +# the next SELECT. No reply is expected. +HLTA = (0x50, 0x00) NTAG_CC_MAGIC = 0xE1 # capability container, page 3 byte 0 # The user pages an OpenSpool NDEF record lives in. 4..39 covers a NTAG213 # (0x12 * 8 = 144 user bytes) and the NDEF area of a NTAG216 alike; a @@ -108,9 +114,22 @@ ANYCUBIC_MAGIC = b'\x7b\x00' DEFAULT_TIMEOUT = 3.0 # seconds per tunnel command +# A TRANSCEIVE (op 3) holds the firmware until its own wait ends: ~6.9 s on +# the tunnel build, data or not. The reply wait must cover it, or the next +# op is queued into a unit that is still blocked. +XCV_TIMEOUT = 10.0 POLL_INTERVAL = 0.005 # reply poll granularity (greenlet-yield safe) +class SessionAborted(Exception): + """A tunnel session stopped by the caller's abort check.""" + + +class NoReply(Exception): + """An op got no reply in time: the session ends (release only), no + further op is sent into a unit that may still be blocked.""" + + def slot_channel(slot): """The RC522 reader channel for an ACE slot (bay order 0..3). @@ -211,6 +230,17 @@ def __init__(self, ace, idx=0, timeout=DEFAULT_TIMEOUT): self._support = None self._no_tunnel_said = set() self._release_fail_said = False + # Why the last read_slot() returned None, for the caller's log line. + self.last_reason = '' + # What the reader chip saw on the last failed page read. + self.last_page_diag = '' + # Optional callable set by ace.py: returns a reason string when the + # session must stop now (the firmware starts an insert of its own + # and needs the reader back). Checked before every op except the + # release. `aborted` carries the reason of the last abort. + self.abort_check = None + self.aborted = '' + self.no_reply = False # -- plumbing ---------------------------------------------------------- @@ -244,9 +274,20 @@ def _cb(*args, **kw): return box.get('r') def _op(self, op, a1=0, a2=0, reader=0, timeout=None): - """One packed op, result byte or None.""" - return parse_code(self._req(pack_index(op, a1, a2, reader), - timeout=timeout)) + """One packed op, result byte or None. Raises SessionAborted when + the abort check fires (never for the release).""" + if op != OP_RELEASE and self.abort_check is not None: + why = self.abort_check() + if why: + self.aborted = str(why) + raise SessionAborted(self.aborted) + if timeout is None and op == OP_PCD_XCV: + timeout = XCV_TIMEOUT + r = self._req(pack_index(op, a1, a2, reader), timeout=timeout) + if r is None and op != OP_RELEASE: + self.no_reply = True + raise NoReply('op %d got no reply' % op) + return parse_code(r) # -- support ----------------------------------------------------------- @@ -294,7 +335,15 @@ def tunnel_available(self, timeout=None): return False if self._support is not None and self._support[0] == fw: return bool(self._support[1]) - v = self._op(OP_READ_REG, REG_VERSION, reader=0, timeout=timeout) + try: + v = self._op(OP_READ_REG, REG_VERSION, reader=0, + timeout=timeout) + except SessionAborted as e: + # Not a verdict on the firmware: probe again next time. + self.last_reason = 'aborted: %s' % e + return False + except NoReply: + v = None ok = v is not None self._support = (fw, ok) if not ok: @@ -366,7 +415,25 @@ def read_page(self, page, reader=0, count=16, timeout=None): # partial reply or NO reply (the FIFO then still holds the previous # transceive - never hand that to the decoder). if bits != FULL_FRAME_BITS: - return None + # What the chip saw instead. On the reference unit op 5 can come + # back without a code although the reply arrived (HW 2026-10-03: + # ComIrqReg 0x64 = RxIRq, ErrorReg 0, FIFOLevel 16). A full, + # error-free 16-byte reception counts as the page then. + err = self._op(OP_READ_REG, REG_ERROR, reader=reader, + timeout=timeout) + irq = self._op(OP_READ_REG, REG_COMIRQ, reader=reader, + timeout=timeout) + lvl = self._op(OP_READ_REG, REG_FIFOLEVEL, reader=reader, + timeout=timeout) + self.last_page_diag = ('rx_bits=%s ErrorReg=%s ComIrqReg=%s ' + 'FIFOLevel=%s' % tuple( + '-' if v is None else '0x%02X' % v + for v in (bits, err, irq, lvl))) + full_fifo = (bits is None and err == 0 and irq is not None + and (irq & COMIRQ_RX) and lvl is not None + and (lvl & 0x7F) == count) + if not full_fifo: + return None out = bytearray() for i in range(count): b = self._op(OP_FIFO_READ, i, reader=reader, timeout=timeout) @@ -375,54 +442,122 @@ def read_page(self, page, reader=0, count=16, timeout=None): out.append(b) return bytes(out) + def halt(self, reader=0, timeout=None): + """Send HLTA to the currently selected card (TxCRC is on after a + read_page, which set TXMODE bit 7). The card answers nothing; the + op results are ignored. Best effort: if the halted card wakes up + again (a full reader bring-up re-powers the field), the caller sees + its UID again and treats the read as 'only the known card'.""" + self._op(OP_WRITE_REG, REG_BITFRAMING, 0x00, reader=reader, + timeout=timeout) + for i, b in enumerate(HLTA): + self._op(OP_FIFO_WRITE, i, b, reader=reader, timeout=timeout) + self._op(OP_PCD_XCV, len(HLTA), PCD_TRANSCEIVE, reader=reader, + timeout=timeout) + def read_openspool(self, reader): """Read the NTAG user pages and decode an OpenSpool NDEF record. Returns the identity dict or None. One 16-byte READ per 4 pages; stops at the first failed chunk (the record, when present, sits at - the start of the user area).""" + the start of the user area). `userdata_complete` tells whether + every chunk was read, i.e. whether a missing record is a fact.""" data = bytearray() page = OPENSPOOL_FIRST_PAGE + self.userdata_complete = False while page <= OPENSPOOL_LAST_PAGE: chunk = self.read_page(page, reader=reader) if chunk is None: break data += chunk page += 4 + else: + self.userdata_complete = True return AceTagReader._openspool_decode(bytes(data)) if data else None - def read_slot(self, slot, page=0, userdata=True): + def read_slot(self, slot, page=0, userdata=True, skip_uids=()): """The full operator/auto read: acquire -> SELECT on the slot's reader channel -> READ -> release. Returns a dict or None: {'slot', 'reader', 'page', 'data' (bytes), 'uid' (bare upper hex or ''), 'format' ('openspool' | 'anycubic' | 'ntag' | - 'unknown'), 'openspool' (dict or None), 'saved'} + 'unknown'), 'openspool' (dict or None), 'saved', + 'userdata_complete' (all user pages read)} `reader` is the RC522 channel (slot_channel(slot): 0,1,2,3 -> 0,2,1,3), never the raw slot. `userdata` also reads the OpenSpool user pages when page 0 shows a plain NTAG capability container. None = no card / no reply / no tunnel; the release runs on every - path.""" + path. + + `skip_uids`: UIDs (upper hex) already known to belong to the + partner slot on the shared antenna. When the selected card is one + of them it is halted and the field is selected once more, so the + other card can answer. Only for page 0 (the UID comes from it).""" slot = int(slot) & 0x3 + self.last_reason = '' + self.aborted = '' + self.no_reply = False + try: + return self._read_slot(slot, page, userdata, skip_uids) + except SessionAborted as e: + self.last_reason = 'aborted: %s' % e + return None + except NoReply as e: + self.last_reason = 'no reply (%s) - session ended' % e + return None + + def _read_slot(self, slot, page, userdata, skip_uids): channel = slot_channel(slot) page = int(page) & 0xFF saved = self.acquire() if saved is None: + self.last_reason = 'reader not acquired (op 7 got no reply)' return None try: if not self.select(channel): + self.last_reason = ('no card answered SELECT on channel %d' + % channel) return None + skip = {str(u).strip().upper() for u in (skip_uids or ()) + if u} if page == 0 else set() data = self.read_page(page, reader=channel) if data is None: + self.last_reason = ('card selected, but the page %d read ' + 'failed (%s)' % (page, + self.last_page_diag)) return None uid = uid_from_page0(data) if page == 0 else '' + if uid and uid in skip: + logging.info('[multiACE] gen1 tag tunnel: ACE %d slot %d: ' + 'partner card %s in the field - halting it', + self.idx, slot, uid) + self.halt(channel) + if not self.select(channel): + self.last_reason = ('only the partner slot\'s known ' + 'card %s answered' % uid) + return None + data = self.read_page(page, reader=channel) + if data is None: + self.last_reason = ('second card selected after ' + 'halting %s, but the page read ' + 'failed' % uid) + return None + uid = uid_from_page0(data) + if uid in skip: + self.last_reason = ('only the partner slot\'s known ' + 'card %s answered (halt did not ' + 'hold)' % uid) + return None fmt = classify_page0(data) out = {'slot': slot, 'reader': channel, 'page': page, 'data': data, 'uid': uid, 'format': fmt, - 'openspool': None, 'saved': saved} + 'openspool': None, 'saved': saved, + 'userdata_complete': False} if (userdata and uid and page == 0 and len(data) >= 16 and data[12] == NTAG_CC_MAGIC): op = self.read_openspool(channel) + out['userdata_complete'] = bool( + getattr(self, 'userdata_complete', False)) if op: out['openspool'] = op out['format'] = 'openspool' diff --git a/multiace/tools/git-hooks/post-commit b/multiace/tools/git-hooks/post-commit index 42efd888..99825e94 100755 --- a/multiace/tools/git-hooks/post-commit +++ b/multiace/tools/git-hooks/post-commit @@ -33,6 +33,7 @@ TRACKED_FILES=( "multiace/klipper/extras/ace_bg_swap.py" "multiace/klipper/extras/ace_tipform.py" "multiace/klipper/extras/ace_rc522.py" + "multiace/klipper/extras/ace_gen1_tunnel.py" "multiace/klipper/extras/ace_protocol.py" "multiace/klipper/extras/ace_protocol_v1.py" "multiace/klipper/extras/ace_protocol_v2.py" diff --git a/multiace/uninstall_multiace.sh b/multiace/uninstall_multiace.sh index 8893b501..46e48490 100755 --- a/multiace/uninstall_multiace.sh +++ b/multiace/uninstall_multiace.sh @@ -114,6 +114,7 @@ rm -f "$EXTRAS_DIR/ace.py" rm -f "$EXTRAS_DIR/ace_tipform.py" rm -f "$EXTRAS_DIR/ace_bg_swap.py" rm -f "$EXTRAS_DIR/ace_rc522.py" +rm -f "$EXTRAS_DIR/ace_gen1_tunnel.py" rm -f "$EXTRAS_DIR/ace_protocol.py" rm -f "$EXTRAS_DIR/ace_protocol_v1.py" rm -f "$EXTRAS_DIR/ace_protocol_v2.py" diff --git a/multiace/web/backend/ace1_flash.py b/multiace/web/backend/ace1_flash.py index 6bedd777..262fa77b 100644 --- a/multiace/web/backend/ace1_flash.py +++ b/multiace/web/backend/ace1_flash.py @@ -80,20 +80,20 @@ # size/CRC/MD5 off the result, verify on hardware, then add the entry. # Keep "tested" empty until a multiACE bench flash has actually run. KNOWN_FIRMWARE = { - "1.3.863-opencubic": { - "version": "1.3.863", - # What iap_upgrade tells the bootloader - the base the image is - # built on. All current Gen-1 images here are 1.3.863-based. + "1.3.871-tunnel": { + "version": "1.3.871", + # Reports CV1.3.871; the bootloader is announced the 1.3.863 base + # like every other Gen-1 image (the bench flasher always did). "announce": "1.3.863", - "label": "1.3.863 - OpenCubic CFW", - "file": "ACE_V1.3.863_20260716.bin", - "size": 113720, - "crc": 0xC110, - "md5": "9f7b9a678a96caf98d6a08842d3ff971", - "source": "OpenCubic ACE 1 Pro CFW v1.1.1 (2026-08-17) release " - "asset ACE_V1.3.863_20260716.bin, " - "md5 9f7b9a678a96caf98d6a08842d3ff971", - "tested": "", + "label": "CV1.3.871 - CFW + UID stub + RC522 tunnel", + "file": "ACE_V1.3.863_tunnel_ops.bin", + "size": 114632, + "crc": 0x1AC9, + "md5": "219df3df77f7c7e1e15a79d580a2379e", + "source": "Godless50/ACE-PRO-v1.-NFC-UID (OpenCubic CFW base + UID " + "stub + RC522 tunnel), asset ACE_V1.3.863_tunnel_ops.bin, " + "md5 219df3df77f7c7e1e15a79d580a2379e", + "tested": "2026-09-29 HW: ACE Pro V1.3.856 -> CV1.3.871 verified", }, "1.3.863-stock": { "version": "1.3.863", @@ -106,7 +106,7 @@ "source": "clean stock image ACE_V1.3.863_20250518.bin " "(OpenCubic originalFirmware), " "md5 dcd04589dcadd5b4feab66d33e772531", - "tested": "", + "tested": "2026-10-03 HW: ACE Pro CV1.3.871 -> V1.3.863 verified", }, } @@ -468,8 +468,14 @@ def _p(pct, msg): _p(100.0, "committing (iap_upgrade_finish) - ACE reboots") # The reply is optional: the commit reboots the unit, which can - # cut the answer off - the bench flasher expects exactly that. - transport.rpc("iap_upgrade_finish", {}, timeout=T_FINISH) + # cut the answer off - the bench flasher expects exactly that. The + # cut can also surface as a serial error (the USB device vanishes + # mid-read); that is the reboot, not a failure - the wait below + # reopens the port and proves the result. + try: + transport.rpc("iap_upgrade_finish", {}, timeout=T_FINISH) + except Exception as e: + _p(100.0, "link dropped at commit (%s) - unit rebooting" % e) _p(None, "waiting for the ACE to come back") deadline = time.time() + BOOT_WAIT diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index e370efb5..5eab0963 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -7,7 +7,7 @@ trusts every request that reaches it. Environment variables: -MOONRAKER_URL default http://127.0.0.1:7125 + MOONRAKER_URL default http://127.0.0.1:7125 MULTIACE_CONFIG_DIR printer_data/config directory MULTIACE_PRINTER_DATA printer data root MULTIACE_CFG_PATH legacy explicit config-file override @@ -20,6 +20,7 @@ from __future__ import annotations import ast +import hashlib import asyncio import json import logging @@ -294,6 +295,16 @@ def _int_or(v, default): return default +def _tag_read_possible(a: dict) -> bool: + fw = str(a.get("firmware", "") or "").strip().upper() + if fw.endswith("O"): + return True + avail = (a.get("tag_tunnel") or {}).get("available") + if avail is not None: + return bool(avail) + return fw.startswith("CV") + + def _community_fw_kind(fw): """Same rule as ace._community_fw_kind, for status from an older ace.py.""" fw = str(fw or "").strip().upper() @@ -586,6 +597,9 @@ def _head_has_filament(t: int): aces_out.append({ "idx": i, "connected": a.get("connected"), + # Last connect failure (Klipper, since 1.20b), e.g. + # "[Errno 13] Permission denied"; '' when none or older ace.py. + "connect_error": a.get("connect_error") or "", "protocol": a.get("protocol", ""), "model": a.get("model", ""), "firmware": a.get("firmware", ""), @@ -593,6 +607,11 @@ def _head_has_filament(t: int): # letter O (V1.1.3O) - same rule as ace._is_open_fw_idx. The # RC522 tag read/write live only there. "open_fw": str(a.get("firmware", "") or "").strip().upper().endswith("O"), + # Tag READ possible on this unit: ACE2-Open, or an ACE Pro on + # community firmware (CV...). The tunnel probe runs on the first + # read, so an unprobed CV unit shows the button; a failed probe + # (available False) hides it again. + "tag_read": _tag_read_possible(a), # Display-only badge: 'ace2_open' | 'gen1_cfw' | ''. Klipper sends # it since 1.20b; derived here from the firmware string for an # older ace.py (same rules). @@ -3027,8 +3046,12 @@ async def acefw_upload(file: UploadFile = File(...)) -> dict: guess = ace2_ota.guess_version(file.filename or "") except Exception: pass + # md5 of the upload: a Gen-1 image is identified by its bytes, not by + # its name (the tunnel build ships as ACE_V1.3.863_tunnel_ops.bin), so + # the card preselects the Gen-1 entry by md5. return {"ok": True, "name": _acefw["file"], "size": len(data), - "version_guess": guess} + "version_guess": guess, + "md5": hashlib.md5(data).hexdigest()} async def _acefw_run(ace: int, port: str, version: str, @@ -5219,7 +5242,7 @@ async def ws(websocket: WebSocket) -> None: # a 304 with no body - unnoticeable on a LAN. Deliberately only the shell: # the vendored Vue bundle and the icons change with a release, and it was # the stale shell that hid them. -_SHELL_PATHS = {"/", "/index.html", "/app.js", "/style.css"} +_SHELL_PATHS = {"/", "/index.html", "/app.js", "/style.css", "/style-new.css"} @app.middleware("http") async def _shell_revalidate(request: Request, call_next): diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 9c589763..241da61b 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -1293,6 +1293,64 @@ createApp({ // confirmations (window.confirm works fine inside an iframe) and i18n. const _q = new URLSearchParams(location.search); const panelMode = _q.get("panel") === "1"; + // Visual design, per browser: "classic" or "new". Pure presentation - + // style-new.css only applies under html[data-theme="new"], and the few + // extra elements it needs (spool gauges) render only in that design. + const uiTheme = ref((() => { + // New design by default; only an explicit "classic" keeps the old one. + try { return localStorage.getItem("multiace.uiTheme") === "classic" ? "classic" : "new"; } + catch (e) { return "new"; } + })()); + function setUiTheme(v) { + uiTheme.value = v === "new" ? "new" : "classic"; + try { localStorage.setItem("multiace.uiTheme", uiTheme.value); } catch (e) {} + } + watch(uiTheme, v => { + if (v === "new") document.documentElement.dataset.theme = "new"; + else delete document.documentElement.dataset.theme; + }, {immediate: true}); + // Spool gauge fill in percent: the bound spool's remaining weight over a + // 1000 g reference, never below 4 so a nearly empty spool still shows a + // sliver. null = no weight known (the gauge then shows full, no label). + function spoolPct(sp) { + if (!sp || sp.weight_g === undefined || sp.weight_g === null) return null; + return Math.max(4, Math.min(100, Math.round(Number(sp.weight_g) / 10))); + } + // New design: per-head settings (ACE, feeder/manual, BG) fold away + // behind a gear button on the toolhead card. View state only. + const thSettingsOpen = reactive({}); + function slotGaugePct(aceIdx, slot) { + if (!slot || slot.state === "empty") return 0; + const p = spoolPct(spoolForSlot(aceIdx, slot.idx)); + return p === null ? 100 : p; + } + function slotGaugeLabel(aceIdx, slot) { + if (!slot || slot.state === "empty") return ""; + const p = spoolPct(spoolForSlot(aceIdx, slot.idx)); + return p === null ? "" : p + " %"; + } + // Toolhead gauge: full when the head holds filament, empty otherwise. + function headGaugePct(th) { + return (th.head_source_known && !th.load_failed) || th.filament_at_extruder ? 100 : 0; + } + function headGaugeText(th) { + if (toolheadOps.value[th.idx]) return ""; + if (th.load_failed) return t("ui.dashboard.load_failed"); + if (th.material) return [th.material, subText(th.subtype)].filter(Boolean).join(" "); + if (!th.head_source_known && !th.filament_at_extruder && !th.feeder && !th.manual) + return t("ui.common.empty"); + return ""; + } + // Very dark filament colours would vanish in the dark gauge. + function gaugeColor(c) { + if (!c) return "#4a4f52"; + const m = /^#?([0-9a-f]{6})/i.exec(String(c)); + if (!m) return c; + const n = parseInt(m[1], 16); + const lin = x => { x /= 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); }; + const L = 0.2126 * lin(n >> 16) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255); + return L < 0.03 ? "#4a4f52" : "#" + m[1]; + } // The full UI is this same page without ?panel=1. Derived from the // current URL rather than hardcoded, so it stays right whether we are // served under /multiace/ or off the root in a dev setup. Opened in a @@ -2176,7 +2234,8 @@ createApp({ // upload, two buttons and a poll. The flash button goes through the // BIG RED own-risk dialog. --- const acefw = reactive({ace: "", version: "", password: "", - fileName: "", fileSize: 0, busy: false, + fileName: "", fileSize: 0, fileMd5: "", + busy: false, status: null, uiError: "", force: false, patchToOpen: false, patchTarget: ""}); // The patch target the backend PRESELECTS, and the whole ladder it can @@ -2251,6 +2310,7 @@ createApp({ if (!acefwVersionOptions.value.some( v => (v.id || v.version) === acefw.version)) acefw.version = ""; + acefwPreselect(); }); function acefwPickFile() { acefwInput.value && acefwInput.value.click(); } async function acefwUpload(files) { @@ -2264,19 +2324,31 @@ createApp({ if (!r.ok) throw new Error(body.detail || `HTTP ${r.status}`); acefw.fileName = body.name; acefw.fileSize = body.size; + acefw.fileMd5 = body.md5 || ""; + acefw.versionGuess = body.version_guess || ""; acefw.uiError = ""; - // Pre-select the version from the file name - but only when it is - // a TESTED one (the field is a select over the allowlist; an - // unknown guess would silently create an invalid selection) and - // only when it is unambiguous: several Gen-1 images share the - // 1.3.863 report string, and picking the wrong one would gate out - // the user's own file. - const matches = acefwVersionOptions.value.filter( - v => v.version === body.version_guess); - if (!acefw.version.trim() && matches.length === 1) - acefw.version = matches[0].id || matches[0].version; + acefwPreselect(); } catch (e) { acefw.uiError = `Upload: ${e.message || e}`; } } + // Gen 1: the image is identified by its md5 (names lie - the tunnel + // build is called ACE_V1.3.863_*), so an md5 match always sets the + // entry and no match clears it. Gen 2: guess from the file name, only + // when it is a tested, unambiguous version and nothing is picked yet. + const acefwGen1Match = computed(() => !acefw.fileMd5 ? null : + acefwGen1Versions.value.find(v => v.md5 === acefw.fileMd5) || null); + const acefwGen1Unknown = computed(() => + acefwIsV1.value && !!acefw.fileMd5 && !acefwGen1Match.value); + function acefwPreselect() { + if (acefwIsV1.value) { + if (!acefw.fileMd5) return; + acefw.version = acefwGen1Match.value ? acefwGen1Match.value.id : ""; + return; + } + const matches = acefwVersionOptions.value.filter( + v => v.version === acefw.versionGuess); + if (!acefw.version.trim() && matches.length === 1) + acefw.version = matches[0].id || matches[0].version; + } let _acefwTimer = null; async function _acefwPoll() { try { @@ -2343,7 +2415,7 @@ createApp({ } function acefwTest() { _acefwStart(true); } // What the confirm dialog names: the entry's label, not the raw key - // (a Gen-1 key is an image id like '1.3.863-opencubic'). + // (a Gen-1 key is an image id like '1.3.871-tunnel'). function acefwSelectedLabel() { const v = acefwVersionOptions.value.find( o => (o.id || o.version) === acefw.version); @@ -3055,6 +3127,37 @@ createApp({ for (const a of state.aces || []) for (const sl of (a.slots || [])) add(sl.color); return out.slice(0, 16); }); + // New design: the same known colours as chips with a name - the spool's + // title, or the slot's material for a slot without a table spool. + const knownColorChips = computed(() => { + const names = {}; + for (const sp of Object.values(state.spools || {})) { + const h = _hex6(sp.color); + if (h && !names[h]) names[h] = spoolTitle(sp); + } + for (const a of state.aces || []) for (const sl of (a.slots || [])) { + const h = _hex6(sl.color); + if (h && !names[h]) names[h] = [sl.material, sl.subtype].filter(Boolean).join(" "); + } + return knownColors.value.map(h => ({hex: h, name: names[h] || h})); + }); + // Hex field of the new colour block: only a complete #rrggbb is taken + // over, half-typed input stays in the field. + function pickerHexInput(v) { + let s = String(v || "").trim(); + if (!s.startsWith("#")) s = "#" + s; + if (/^#[0-9a-fA-F]{6}$/.test(s)) { picker.color = s.toLowerCase(); pickerTouch(); } + } + const pickerGaugePct = computed(() => { + const w = picker.weight; + if (w === "" || w === null || w === undefined) return 100; + return spoolPct({weight_g: Number(w)}) || 4; + }); + const pickerSubline = computed(() => [ + picker.material, picker.vendor, picker.subtype, + (picker.weight !== "" && picker.weight !== null && picker.weight !== undefined) + ? picker.weight + " g" : "", + ].filter(Boolean).join(" \u00b7 ")); function spoolMatchesSlot(sp, material, colorHex) { const hex = (c) => String(c || "").replace("#", "").toLowerCase(); const eq = (a, b) => String(a || "").toLowerCase() === String(b || "").toLowerCase(); @@ -3078,6 +3181,9 @@ createApp({ const score = spoolMatchesSlot(sp, mat, col); const bg = sp.color ? "#" + String(sp.color).replace("#", "") : ""; return {id: sp.id, score, bg, fg: bg ? textOn(bg) : "", + // New design dropdown: the same entry split into parts. + name: spoolTitle(sp), at, + meta: [sp.material, w].filter(Boolean).join(" \u00b7 "), // The tick means FULLY matching (material AND colour): the // colour is visible in the row itself, the material is not, // so it marks the one thing the fill cannot show. @@ -3166,9 +3272,21 @@ createApp({ // cancel means no entry is created at all. // A UID-only read (MIFARE, no identity) has no rfid_data but a uid - // that is the per-chip key the new spool must carry to bind next time. - const sku = await askFreeSku( - sl && sl.rfid_data && sl.rfid_data.sku ? sl.rfid_data.sku - : (sl && sl.uid ? sl.uid : "")); + const tagCode = (sl && sl.rfid_data && sl.rfid_data.sku + ? sl.rfid_data.sku : (sl && sl.uid ? sl.uid : "")); + // A slot spool without any tag code can never bind by itself: say so + // once, before it is created. + if (!isHead && !tagCode) { + const go = await new Promise(resolve => confirm({ + title: t("ui.spools.no_code_title"), + message: t("ui.spools.no_code_msg"), + okLabel: t("ui.spools.add"), + onOk: () => resolve(true), + onCancel: () => resolve(false), + })); + if (!go) return; + } + const sku = await askFreeSku(tagCode); if (sku === null) return; if (sku) args.SKU = sku; // Empty stays empty: no invented default, a wrong start weight @@ -3564,6 +3682,11 @@ createApp({ const a = (state.aces || []).find(x => x.idx === aceIdx); return !!(a && a.open_fw); } + // Tag READ on that unit: ACE2-Open or an ACE Pro with the Gen-1 tunnel. + function aceTagRead(aceIdx) { + const a = (state.aces || []).find(x => x.idx === aceIdx); + return !!(a && (a.open_fw || a.tag_read)); + } function aceHeadForAce(aceIdx) { const heads = state.ace_heads || []; const ha = state.head_ace || {}; @@ -4067,6 +4190,18 @@ createApp({ if (picker.head === null || picker.head === undefined) return ""; return String((state.head_tag_seen || {})[String(picker.head)] || ""); }); + // New design: Feeder / Manual tag in the head picker's title. + // New design: custom spool dropdown in the picker (colour chip per row). + const spoolDdOpen = ref(false); + function spoolDdPick(id) { spoolDdOpen.value = false; picker.spool = id; } + const spoolDdCurrent = computed(() => + spoolPickerOptions.value.find(o => o.id === picker.spool) || null); + const pickerHeadKind = computed(() => { + if (!picker.show || picker.head === null || picker.head === undefined) return ""; + const th = (state.toolheads || []).find(x => x.idx === picker.head); + if (!th) return ""; + return th.feeder ? "feeder" : (th.manual ? "manual" : ""); + }); const headRfidBusy = ref(false); const headRfidNote = ref(""); async function readHeadRfid() { @@ -6478,6 +6613,7 @@ createApp({ stage: String(msg.stage || ""), running: true}; }); preflight.report = Object.assign({local: true}, j.report || {}); + pfPlanSel.value = null; preflight.slicerOverrides = {}; preflight.slicerSwaps = null; preflight.headOverrides = {}; @@ -6514,6 +6650,7 @@ createApp({ throw new Error(msg); } preflight.report = await r.json(); + pfPlanSel.value = null; preflight.slicerOverrides = {}; preflight.slicerSwaps = null; preflight.headOverrides = {}; @@ -6615,6 +6752,60 @@ createApp({ } return out.join("\n"); } + // New design: the plans become comparison cards; one is selected, only + // its table shows and a single footer button prints it. Default is the + // first plan (as loaded), the same one the classic view lists first. + const pfPlanSel = ref(null); + function pfPlanList() { + const r = preflight.report; + if (!r || !r.plans || forcaMixed()) return []; + const order = r.head_mode ? ["loadout", "optimize", "layer", "color"] + : ["slicer", "optimize", "layer"]; + return order.filter(p => r.plans[p]); + } + function pfPlanFeasible(p) { + const r = preflight.report; + if (!r || !r.plans || !r.plans[p]) return false; + if (r.head_mode) return headPlanFeasible(p); + return !!r.plans[p].feasible && !(r.missing_materials || []).length; + } + function pfPlanSwaps(p) { + const r = preflight.report; + if (r.head_mode) return headPlanSwaps(p); + return p === "slicer" ? slicerSwapsDisplay() : (r.plans[p].swaps || 0); + } + function pfPlanFlush(p) { + return preflight.report && preflight.report.head_mode ? headPlanFlushG(p) : null; + } + function pfSelected() { + const list = pfPlanList(); + return list.includes(pfPlanSel.value) ? pfPlanSel.value : (list[0] || null); + } + // Tagged when a feasible plan saves swaps against the first one. + function pfRecommended() { + const list = pfPlanList().filter(pfPlanFeasible); + if (list.length < 2) return null; + let best = list[0]; + for (const p of list) if (pfPlanSwaps(p) < pfPlanSwaps(best)) best = p; + return best === list[0] ? null : best; + } + function pfPlanTitle(p) { + return t(preflight.report && preflight.report.head_mode + ? "ui.preflight.head_plan_" + p : "ui.preflight.plan_" + p); + } + function pfGoLabel() { + const p = pfSelected(); + if (!p) return ""; + if (preflight.sending === p) return t("ui.preflight.sending"); + return t(preflight.report.head_mode ? "ui.preflight.head_go_" + p + : "ui.preflight.go_" + p); + } + function pfPrintSelected() { + const p = pfSelected(); + if (!p || !pfPlanFeasible(p)) return; + if (preflight.report.head_mode) startPreflightPrint("head", p); + else startPreflightPrint(p); + } async function startPreflightPrint(mode, headPlan) { if (preflight.busy || preflight.sending) return; const rep = preflight.report; @@ -6991,11 +7182,11 @@ createApp({ calibrationVerifyConfigured, calibrationAction, calibrationApply, panelMode, panelAce, panelAceIdx, panelSlotHead, panelPages, panelPage, panelPageId, panelFeederHeads, setPanelPage, panelSlotHeadLoaded, panelSlotActive, panelSlotLabel, panelSlotOp, - panelMini, fullUiHref, + panelMini, fullUiHref, uiTheme, setUiTheme, spoolPct, gaugeColor, thSettingsOpen, slotGaugePct, slotGaugeLabel, headGaugePct, headGaugeText, slotTitle, switchAce, loadSlot, slotIsEmpty, loadFeederHead, slotLoadedInHead, loadAll, unloadHead, unloadAll, cancelUnloadAll, anyUnloading, setHeadManual, setHeadFeeder, setHeadAce, headToggle, headSelect, aceOptionsForHead, headAceOf, aceProtoTitle, aceCfwTitle, visibleAces, openHeadPicker, isToolheadOccupied, needsReload, toolheadOps, bgEnabledFor, setBgHead, setPickupCleaning, setConfirmCommands, setPaSync, setAutoDry, autoDryValue, autoDryInput, autoDryCommit, autoDryPairInvalid, autoDryFieldError, autoDryEnable, autoDrySetMaster, autoDryMasters, spoolmanUrl, spoolmanBusy, spoolmanStatusText, saveSpoolmanUrl, setSpoolmanAuto, spoolmanSync, spoolmanConnected, spoolmanUrlSet, setSpoolMode, smQuery, smRows, smBusy, smOpen, smSearchDebounced, smAdopt, smPing, smPingInfo, spoolmanPing, spoolQuery, smPick, smPickTarget, smAdoptStaged, - spoolBadgeCls, spoolBadgeLabel, headTileEmpty, setAirprintDetection, setQuadReplenish, setQuadFirst, setPurgeMatrix, FILAMENT_SWATCHES, knownColors, sameSwatch, pickerTouch, pickerRfidSku, pickerTagFormat, pickerCodeKind, pickerUidExtra, tagFormatLabel, pickerHeadTag, headRfidBusy, headRfidNote, readHeadRfid, + spoolBadgeCls, spoolBadgeLabel, headTileEmpty, setAirprintDetection, setQuadReplenish, setQuadFirst, setPurgeMatrix, FILAMENT_SWATCHES, knownColors, knownColorChips, pickerHexInput, pickerGaugePct, pickerSubline, sameSwatch, pickerTouch, pickerRfidSku, pickerTagFormat, pickerCodeKind, pickerUidExtra, tagFormatLabel, pickerHeadTag, pickerHeadKind, spoolDdOpen, spoolDdPick, spoolDdCurrent, headRfidBusy, headRfidNote, readHeadRfid, addFluiddCamera, fluiddCamBusy, fluiddCamMsg, spoolForm, spoolImportMode, spoolFileInput, spoolMaterials, spoolVendors, spoolSubtypes, @@ -7006,7 +7197,7 @@ createApp({ spoolSlotOptions, spoolSlotKey, spoolMoveLocked, spoolAssignTo, spoolPickerOptions, smRowTaken, smRowWhere, spoolWeightDialog, spoolSort, spoolNewTitle, tagWrite, tagWriteFormat, tagWriteUidSku, setTagWrite, slotOccupied, - tagWriteOutcome, dismissTagWriteOutcome, tagRead, aceOpenFw, + tagWriteOutcome, dismissTagWriteOutcome, tagRead, aceOpenFw, aceTagRead, paDlg, paClip, spoolPaDialog, paRowDel, paAddRow, paCopy, paPaste, paCalibrate, paCanCalibrate, paCalibrating, paLoading, paSpoolHead, paSpoolLoadPlan, paSave, paDirty, paKeyValid, paValueValid, paKeyOptions, pickerPaDialog, @@ -7016,6 +7207,7 @@ createApp({ acefwStatusBad, acefwPatchTarget, acefwPatchTargets, acefwTargetLabel, acefwCanPatch, acefwVersions, acefwGen1Versions, acefwIsV1, acefwVersionOptions, + acefwGen1Unknown, spoolCreateFromPicker, spoolExport, spoolImport, triggerSpoolImport, isPrinting, @@ -7037,6 +7229,7 @@ createApp({ headTargets, headTargetOptions, headEffectiveTargetId, headTargetLabel, headTargetColor, headTargetLabelById, onHeadTargetChange, headSwapsDisplay, hmDropOpen, hmDdToggle, hmDdClose, hmDdPick, + pfPlanSel, pfPlanList, pfPlanFeasible, pfPlanSwaps, pfPlanFlush, pfSelected, pfRecommended, pfPlanTitle, pfGoLabel, pfPrintSelected, headFeasible, headPlanFeasible, headPlanSwaps, headPlanBg, headPlanFlushG, headPlanBgLabel, headSlicerHex, headSlicerMat, headProposalLabel, headMaxCopies, headCopiesEffective, headCopyPlan, headCopyList, copySlotLabel, setPreflightCopies, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 7ad44c55..060f4e68 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -8,6 +8,17 @@ + + +
@@ -50,9 +61,13 @@
ACE {{ dispIdx(panelAce.idx) }} + {{ t('ui.common.offline') }} + - +
- +
+
+ :class="{primary: ace.idx === state.active_device, + clickable: ace.idx !== state.active_device && ace.connected !== false, + offline: ace.connected === false}" + @click="ace.idx !== state.active_device && ace.connected !== false && switchAce(ace.idx)">

ACE {{ dispIdx(ace.idx) }} + {{ t('ui.spools.label') }} {{ t('ui.spools.title') }} a local one via label/colour. --> + SM{{ sp.spoolman_id }} {{ sp.label }} - {{ sp.label ? ' · ' : '' }}{{ spoolDetails(sp) }} + {{ sp.label && uiTheme !== 'new' ? ' · ' : '' }}{{ spoolDetails(sp) }} {{ sp.sku }} - {{ spoolWeightLabel(sp) || '-' }} + {{ spoolWeightLabel(sp) || '-' }} {{ t('ui.config.confirm_commands_label') }} -

{{ t('ui.config.confirm_commands_hint') }}

+

{{ + uiTheme === 'new' ? t('ui.config.nt_confirm_commands_short') : t('ui.config.confirm_commands_hint') }}

+

+

{{ t('ui.config.airprint_detection') }}

-

{{ t('ui.config.airprint_detection_hint') }}

+

{{ + uiTheme === 'new' ? t('ui.config.nt_airprint_detection_short') : t('ui.config.airprint_detection_hint') }}

+
+

{{ t('ui.config.quad_replenish') }}

-

{{ t('ui.config.quad_replenish_hint') }}

+

{{ + uiTheme === 'new' ? t('ui.config.nt_quad_replenish_short') : t('ui.config.quad_replenish_hint') }}

-

{{ t('ui.config.stock_first_hint') }}

- +

{{ + uiTheme === 'new' ? t('ui.config.nt_stock_first_short') : t('ui.config.stock_first_hint') }}

+
+

{{ t('ui.config.pickup_cleaning') }}

-

{{ t('ui.config.pickup_cleaning_hint') }}

+

{{ + uiTheme === 'new' ? t('ui.config.nt_pickup_cleaning_short') : t('ui.config.pickup_cleaning_hint') }}

+
+

{{ t('ui.config.fluidd_panel') }}

@@ -1567,6 +1654,7 @@

{{ t('ui.config.fluidd_panel') }}

{{ t('ui.config.parameters') }}

+
+
+
+
+
+
@@ -1603,6 +1696,18 @@

{{ t('ui.config.parameters') }}

+
+ + +
+
+ + +
+
+
@@ -1615,16 +1720,9 @@

{{ t('ui.config.parameters') }}

-
- - -
-
- -
+
+
{{ t('ui.config.parameters') }}

{{ t('ui.config.purge_matrix_hint') }}

+
+
@@ -1651,6 +1751,7 @@

{{ t('ui.config.parameters') }}

+
@@ -1832,7 +1933,7 @@

ACE {{ dispIdx(i-1) }}

-
+
@@ -2017,6 +2118,10 @@

{{ t('ui.config.acefw_title') }}

}}{{ v.file ? ' (' + v.file + ')' : '' }}
+
+ + {{ t('ui.config.acefw_gen1_unknown') }} +
@@ -2154,7 +2259,7 @@

{{ t('ui.config.acefw_title') }}

-
+

@@ -2165,9 +2270,14 @@

{{ t('ui.config.acefw_title') }}

· {{ t('ui.dialog.tag_uid') }}: {{ pickerUidExtra }}

- {{ picker.head !== null && picker.head !== undefined + + {{ picker.head !== null && picker.head !== undefined ? t('ui.dialog.picker_title_head', {head: dispIdx(picker.head)}) - : t('ui.dialog.picker_title', {ace: dispIdx(picker.ace), slot: dispIdx(picker.slot)}) }} + : t('ui.dialog.picker_title', {ace: dispIdx(picker.ace), slot: dispIdx(picker.slot)}) }}{{ + t('ui.dashboard.' + pickerHeadKind) }} + {{ pickerSubline }} {{ pickerHeadTag }} +

{{ headRfidNote }}

-
-
+
-
+
-
+
-
- - - +
+ +
+
+ {{ t('ui.dialog.nt_rfid_tag') }} + + +
+
+ + + -