From 6c79f6989a86ab895ea19c2a97c2b087128bdaa6 Mon Sep 17 00:00:00 2001 From: Physics G Date: Fri, 7 Aug 2026 14:11:23 +0200 Subject: [PATCH 01/26] Add ACE_UNLOAD_ALL_CANCEL command and cancel button --- multiace/klipper/extras/ace.py | 14 +++++++++++++- multiace/web/frontend/app.js | 8 +++++++- multiace/web/frontend/index.html | 2 ++ multiace/web/frontend/style.css | 6 ++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index cc98381f..5ca95f4d 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -448,6 +448,7 @@ def _parse_idx_list(key): self._v2_active_rev_assist = False self._test_cancel = False + self._unload_all_cancel = False self._auto_feed_enabled = False self._fa_context = 'idle' @@ -658,7 +659,9 @@ def _parse_idx_list(key): 'ACE_UNLOAD_ALL_HEADS', self.cmd_ACE_UNLOAD_ALL_HEADS, desc=self.cmd_ACE_UNLOAD_ALL_HEADS_help) self.gcode.register_command( - 'ACE_TEST', self.cmd_ACE_TEST, + 'ACE_UNLOAD_ALL_CANCEL', self.cmd_ACE_UNLOAD_ALL_CANCEL, + desc='[multiACE] Cancel a running ACE_UNLOAD_ALL_HEADS after the current toolhead') + self.gcode.register_command( desc=self.cmd_ACE_TEST_help) self.gcode.register_command( 'ACE_DWELL_TEST', self.cmd_ACE_DWELL_TEST, @@ -8723,6 +8726,7 @@ def _clear_filament_display(self, head): cmd_ACE_UNLOAD_ALL_HEADS_help = '[multiACE] Unload all toolheads that have filament loaded' def cmd_ACE_UNLOAD_ALL_HEADS(self, gcmd): + self._unload_all_cancel = False if self._feed_assist_index != -1: self._disable_feed_assist() @@ -8730,6 +8734,10 @@ def cmd_ACE_UNLOAD_ALL_HEADS(self, gcmd): unloaded_any = False for head in range(4): + if self._unload_all_cancel: + self.log_always('[multiACE] ACE_UNLOAD_ALL_HEADS cancelled by user') + self._unload_all_cancel = False + break if self.head_is_manual(head): continue sensor = self.printer.lookup_object( @@ -8823,6 +8831,10 @@ def cmd_ACE_TEST_CANCEL(self, gcmd): self._test_cancel = True self.log_always(self._t('msg.test_cancel_requested')) + def cmd_ACE_UNLOAD_ALL_CANCEL(self, gcmd): + self._unload_all_cancel = True + self.log_always('[multiACE] Cancel requested - will stop after current toolhead') + cmd_ACE_DRY_help = '[multiACE] Start drying on ACE. Usage: ACE_DRY ACE=0 [TEMP=] [DURATION=]' def cmd_ACE_DRY(self, gcmd): diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index af64aa06..0ea2f401 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -570,6 +570,12 @@ createApp({ if (_blockIfPrinting()) return; run("ACE_UNLOAD_ALL_HEADS"); } + function cancelUnloadAll() { + run("ACE_UNLOAD_ALL_CANCEL"); + } + const anyUnloading = computed(() => + Object.values(toolheadOps.value).some(op => op === 'unloading') + ); async function setHeadManual(idx, enable) { try { await fetch(`${API}/head-manual`, { @@ -2902,7 +2908,7 @@ createApp({ sourceLabel, tab, version, printerName, printerFw, connClass, connText, screenAvailable, state, loadError, run, macroLog, - slotTitle, switchAce, loadSlot, loadFeederHead, slotLoadedInHead, loadAll, unloadHead, unloadAll, setHeadManual, setHeadFeeder, setHeadAce, aceOptionsForHead, headAceOf, visibleAces, openHeadPicker, isToolheadOccupied, needsReload, toolheadOps, bgEnabledFor, setBgHead, setPickupCleaning, + slotTitle, switchAce, loadSlot, loadFeederHead, slotLoadedInHead, loadAll, unloadHead, unloadAll, cancelUnloadAll, anyUnloading, setHeadManual, setHeadFeeder, setHeadAce, aceOptionsForHead, headAceOf, visibleAces, openHeadPicker, isToolheadOccupied, needsReload, toolheadOps, bgEnabledFor, setBgHead, setPickupCleaning, isPrinting, dryerCfg, dryStart, dryStop, dryOpenAce, toggleDryPanel, aceDrying, snapshots, selectedSnapshot, snapshotPreview, saveSnapshot, loadSnapshot, deleteSnapshot, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 43d13317..cb4d755c 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -154,6 +154,8 @@

{{ t('ui.dashboard.toolheads') }} +

Date: Tue, 4 Aug 2026 11:07:00 +0200 Subject: [PATCH 02/26] Undo deletion --- multiace/klipper/extras/ace.py | 1 + 1 file changed, 1 insertion(+) diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 5ca95f4d..4003d2b2 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -662,6 +662,7 @@ def _parse_idx_list(key): 'ACE_UNLOAD_ALL_CANCEL', self.cmd_ACE_UNLOAD_ALL_CANCEL, desc='[multiACE] Cancel a running ACE_UNLOAD_ALL_HEADS after the current toolhead') self.gcode.register_command( + 'ACE_TEST', self.cmd_ACE_TEST, desc=self.cmd_ACE_TEST_help) self.gcode.register_command( 'ACE_DWELL_TEST', self.cmd_ACE_DWELL_TEST, From e453878bd175094e36ab5826b01019a725fdf2b6 Mon Sep 17 00:00:00 2001 From: Physics G Date: Tue, 4 Aug 2026 18:28:36 +0200 Subject: [PATCH 03/26] Update calibration page --- CALIBRATION_TAB.md | 510 +++++++ FILAMENT_MOTION_FEATURE_REFERENCE.md | 531 ++++++++ multiace/README.md | 4 + multiace/i18n/en.json | 1 + multiace/klipper/extras/ace.py | 1287 +++++++++++++++++- multiace/klipper/extras/filament_feed_ace.py | 13 + multiace/web/backend/main.py | 128 ++ multiace/web/frontend/app.js | 499 ++++++- multiace/web/frontend/index.html | 321 +++++ multiace/web/frontend/style.css | 201 +++ multiace/web/mockups/calibration-tab.html | 1197 ++++++++++++++++ 11 files changed, 4688 insertions(+), 4 deletions(-) create mode 100644 CALIBRATION_TAB.md create mode 100644 FILAMENT_MOTION_FEATURE_REFERENCE.md create mode 100644 multiace/web/mockups/calibration-tab.html diff --git a/CALIBRATION_TAB.md b/CALIBRATION_TAB.md new file mode 100644 index 00000000..8c89f52d --- /dev/null +++ b/CALIBRATION_TAB.md @@ -0,0 +1,510 @@ +# multiACE calibration tab + +Status: Calibration and verification implementation present in the working tree +Target UI: `multiace/web/frontend` +Target firmware: `multiace/klipper/extras/ace.py` +Mockup: [`multiace/web/mockups/calibration-tab.html`](multiace/web/mockups/calibration-tab.html) +Engineering reference: [`FILAMENT_MOTION_FEATURE_REFERENCE.md`](FILAMENT_MOTION_FEATURE_REFERENCE.md) + +## Current implementation + +The first functional slice is present in the working tree: + +- the live web UI has a **Calibration** tab between Dashboard and Config; +- connected-unit, unit/slot scope, route, readiness, jog, checkpoint, review, and config-apply controls are wired to backend endpoints; +- clean-slate preparation reads the selected toolhead's actual extruder sensor, unloads only that toolhead when necessary, then requires a separate user confirmation; the unload has a direct, out-of-band cancel path rather than a queued cancel macro; +- Klipper exposes a guarded, cancellable calibration state machine with bounded feed/retract movement; +- the existing ACE → splitter → toolhead diagram resolves effective configured anchors immediately, then tracks the estimated filament tip directly on its two tube segments; +- a non-heated verification round trip supports manual pause/resume and automatically holds at the toolhead sensor and measured splitter checkpoint; +- ACE 2 Pro decoder span is sampled as supporting telemetry, while ACE v1 is explicitly labelled as a commanded-distance estimate; +- applying a completed result uses the existing conflict-aware config save/backup path. + +This slice still requires printer hardware validation of the new verification controls. Decoder-to-millimetre conversion remains future work, so displayed in-motion positions are timing/command estimates and the splitter checkpoint still requires visual confirmation. + +## Summary + +Add a **Calibration** tab between **Dashboard** and **Config**. It lists every connected ACE, lets the user calibrate one unit or one slot, measures the feed path from a fresh automatic-preload reference to the toolhead sensor, and provides one manual retract checkpoint: + +1. filament has cleared the Y/4-way splitter. + +After that checkpoint, the remaining retract is automatic: cumulative retract stops when it equals the measured outward load distance, returning to the same ACE automatic-preload reference without requiring a view inside the ACE. + +The resulting values map directly to multiACE's existing configuration hierarchy: + +| Measurement | Unit-level key | Slot-level key | Meaning | +| --- | --- | --- | --- | +| Load distance | `[ace N] load_length` | `[ace N] load_length_S` | Post-preload position to toolhead sensor | +| Swap retract | `[ace N] swap_retract_length` | `[ace N] swap_retract_length_S` | Total retract from the toolhead sensor until the tip safely clears the shared junction | +| Preload-reference return | `[ace N] retract_length` | `[ace N] retract_length_S` | Same raw distance as the measured outward trip, returning to the fresh automatic-preload reference | + +`N` and `S` are zero-based in the config, regardless of `display_index_base`. + +This is feasible on both protocol generations, but the quality of automatic measurement differs: + +- **ACE 2 Pro / protocol v2:** preferred. It supports start/stop feed commands and `GET_FEED_INFO` telemetry (`steps`, `length`, and a hardware decoder value). multiACE already reads this decoder and describes it as real filament movement. Calibration can sample it throughout a move, detect slip, and improve the measured value. +- **ACE Pro / protocol v1:** usable with incremental commanded moves and the U1 toolhead sensor. V1 exposes start/stop motion but no equivalent `feed_info` telemetry in multiACE. The result is therefore a commanded-distance estimate, not an encoder-confirmed measurement. The UI should label it **estimated** and recommend two runs. + +## Why insertion already moves filament + +Putting filament into an ACE inlet is a separate **preload** operation owned by the ACE firmware. An inlet/gate detection event starts the slot motor and the ACE moves the filament to its internal ready position. The official Anycubic manuals describe this as automatic pre-feeding after filament is detected. ACE 2 Pro reports the slot state as `preloading` over protocol v2. + +That means multiACE does not need to know the distance from the spool to the ACE's internal gate. Its configured `load_length` begins at the repeatable, post-preload position and covers only the external route to the printer/toolhead sensor. + +The automatic preload position is the calibration origin, `P0`. The absolute distance travelled inside the ACE before `P0` is deliberately unknown and cancels out: calibration measures outward from `P0` and returns by the same raw distance. `feed_length` must remain `0`, otherwise an additional multiACE feed shifts the origin after automatic preload. + +The exact ACE 2 Pro preload stop rule is not publicly documented. The available evidence shows: + +- a firmware-controlled `preloading` state; +- four independent feed motors and a buffering mechanism; +- per-slot feed telemetry including motor steps, requested length, and a decoder/filament-motion reading; +- automatic preload starting after insertion is detected. + +The safest conclusion is that the ACE's own sensors and motor/decoder feedback control the internal preload. It is not calculating the user's external Bowden length at insertion time. External travel only starts when the printer later requests a feed of a specified length. + +Sources: + +- [Anycubic ACE 2 Pro product page](https://store.anycubic.com/products/ace-2-pro) +- [Anycubic Kobra 4 Combo manual (ACE 2 Pro)](https://wiki.anycubic.com/k4/Kobra%204%20Combo%20%E7%94%A8%E6%88%B7%E6%89%8B%E5%86%8C-EN-20260407.pdf) +- [Anycubic Kobra S1 Combo manual](https://wiki.anycubic.com/kobra-s1/anycubic_kobra_s1_combo%E7%94%A8%E6%88%B7%E6%89%8B%E5%86%8C-v1.3.pdf) +- multiACE v2 protocol implementation: `multiace/klipper/extras/ace_protocol_v2.py` + +## User experience + +### Tab layout + +The Calibration tab contains: + +1. a readiness banner (printer idle, no swap/background operation, sensors reachable); +2. cards for connected ACE units only; +3. a calibration scope selector: **Entire ACE** or **Single slot**; +4. the resolved physical route, for example `ACE 1 · Slot 2 → Toolhead T2`; +5. a seven-step wizard beginning with an explicit clean-slate baseline; +6. live measured/commanded distances and sensor status; +7. a config diff and **Apply to config** action. + +### Filament-tip route indicator + +The route is a topology diagram, not a physically scaled Bowden tube. It does not add a second progress rail: the filament marker is rendered directly on the existing ACE → splitter and splitter → toolhead lines. + +On opening the tab, resolve the physical ACE-to-toolhead anchor from effective `retract_length` and the junction offset from effective `swap_retract_length`, using normal override precedence: + +1. selected per-slot `*_S` override; +2. selected `[ace N]` value; +3. global `[ace]` default. + +`load_length` is not used as the configured physical coordinate because it may include a feed allowance beyond the sensor edge. During a new measurement, the raw measured load distance and raw retract distance are equal, so that raw value becomes the live toolhead anchor. + +This means an already-calibrated route is useful immediately and does not regress to “unknown” merely because a new calibration session has not started. New measurements replace the corresponding configured anchor as soon as they become available. A question mark is reserved for a genuinely missing/invalid configured route or an indeterminate physical position. + +Once calibration has established both anchors, use the fresh ACE preload as coordinate zero: + +```text +ACE preload P0 = 0 +splitter Ps = load_length - swap_retract_length +toolhead sensor Pt = raw load distance (active measurement) + or effective retract_length (saved route) +``` + +Positions from `P0..Ps` map onto the existing ACE → splitter line; positions from `Ps..Pt` map onto the existing splitter → toolhead line. Each line uses its own local percentage. This keeps the marker physically meaningful even though the two real tube sections are not drawn to scale. + +During motion the firmware publishes `tip_position_mm`. It is derived from completed commanded movement plus elapsed movement in the active bounded move. The UI labels it **estimated** and updates the marker continuously for moving, manually paused, and endpoint-recovery states. Sensor activation is authoritative at the toolhead endpoint; user observation is authoritative when measuring the splitter checkpoint. + +Disconnected configured units can appear in a collapsed “Unavailable” area, but cannot be calibrated. + +### Scope + +**Entire ACE** uses a representative slot/path selected by the user and writes `[ace N]` defaults. This is appropriate when all four routes are physically the same. + +**Single slot** writes `*_S` overrides under `[ace N]`. This is the recommended scope when: + +- tubes have different lengths; +- a 4-way splitter has unequal branch lengths; +- routing or friction differs per spool position; +- the ACE feeds different toolheads or different splitter inputs. + +The UI should never describe slot calibration as spool-material calibration. The value follows the physical **slot/path**, even when the spool is replaced. + +### Route resolution + +- In `multi` mode, default to the normal slot-to-head route already used by multiACE. Allow the user to confirm the resolved toolhead. +- In `head` mode, use the ACE-driven head configured by `head_ace_*`; all slots may share that head through a combiner. +- When exactly one ACE is connected, select it automatically. Resolve its designated ACE-driven toolhead from `ace_heads` plus `head_ace` and select that head instead of defaulting to T0. +- Select the first slot whose live state is `ready`. Preserve an explicit user selection afterward rather than continually snapping back as status refreshes arrive. +- In `normal` mode, show the tab but require switching to `multi` or `head` before starting because there is no active ACE route. +- If a route is ambiguous, require explicit toolhead selection. Do not guess and move filament. + +## Calibration wizard + +### 1. Establish a clean slate + +Preconditions: + +- printer is idle (not printing or paused); +- no swap, background load, load, unload, or feed-assist operation is active; +- no other calibration session exists. + +The wizard must deliberately establish a known empty baseline before choosing the measured path: + +1. Inspect `filament_at_extruder` for the toolhead resolved by the selected calibration route. This is the `eN_filament` toolhead sensor used by calibration. Do not use the generic `filament_detected` field here: that field reflects the upstream feed port and may remain true while filament is preloaded in the ACE but the toolhead is empty. +2. If that toolhead is occupied, offer **Unload selected toolhead** using `ACE_UNLOAD_HEAD HEAD=N CALIBRATION_PREPARE=1 ACE=A SLOT=S`. The explicit ACE/slot route is mandatory for preparation. Firmware validates it against the configured topology, gate state, any tracked source, and other loaded routes; it must not fall back to the currently active ACE when `head_source` is unknown. +3. Wait until that workflow finishes and re-read the selected toolhead sensor; do not trust only the command return. +4. Require the selected sensor to report explicitly clear. An unavailable/unknown sensor is not equivalent to empty. +5. Ask the user to remove any loose filament left in the output tube, Y/4-way splitter, or toolhead entrance. +6. Clear a cancelled/failed calibration session and stale route selection before accepting the baseline. +7. Once the sensor is clear, present **Use clear TN as starting point**. Record the clean-baseline acknowledgement only when the user clicks it; completion of an automatic unload must not silently confirm the baseline. + +The status copy is stateful rather than a permanently positive checklist label: + +- **Selected TN: toolhead sensor is clear** when `filament_at_extruder == false`; +- **Selected TN: filament detected at toolhead sensor** when it is `true`; +- **Selected TN: toolhead sensor state unavailable** when it is absent or unknown. + +After a successful unload the wizard stays on step 1 with the sensor shown clear and the clean starting point awaiting confirmation. This keeps sensor verification (automatic evidence) distinct from the user's acknowledgement that the visible splitter/output route is actually clean. + +The selected-toolhead unload must be directly cancellable. Do not enqueue a +cancel G-code behind `ACE_UNLOAD_HEAD`: Klipper cannot execute it until the +blocking unload has already returned. Preparation uses a Klipper webhook that +sets a cooperative cancel flag out-of-band, immediately stops ACE feed/rollback +and feed assist, turns off the selected heater, and aborts at cancellation +checkpoints throughout homing, heating, tip forming, probing, and retract. +After cancellation the position is treated as unknown and the clean-slate step +must be repeated. + +Changing the ACE, slot, toolhead, or scope invalidates this acknowledgement and returns the wizard to this step. Cancelling a calibration does the same because filament may be left between checkpoints. + +This step does not eject spools from the ACE. Filament should remain captured at the ACE's normal internal ready/parked position. + +### 2. Prepare the selected route + +Preconditions: + +- selected ACE is connected and ready; +- selected slot is not empty (`gate_status == available`); +- selected toolhead sensor is present and currently clear; +- selected head is ACE-driven and not in manual/TPU or stock-feeder mode; +- the clean-slate acknowledgement from step 1 is still valid. + +Instructions: + +1. Remove and reinsert filament in the selected ACE inlet so the reference is fresh. +2. Wait for automatic ACE preload to complete and the slot to return to `ready`. +3. Confirm that only the selected slot enters the displayed physical route. +4. Manually verify that the splitter/output tube is clear and the displayed toolhead is correct. +5. Record an explicit **Use ACE preload position and confirm route** acknowledgement. + +The **Start calibration** button becomes active only after both preparation steps pass. + +### 3. Measure load distance + +Feed at a conservative calibration speed. Do not call `ACE_LOAD_HEAD` or `FEED_AUTO LOAD=1`, because those flows can select a tool, heat the nozzle, seat filament, purge, and mutate material bookkeeping. + +Recommended control loop: + +1. Disable feed assist for the selected ACE/slot. +2. Start with one bounded 500 mm move, followed by 100 mm increments until the sensor is reached or the guarded maximum is exhausted. +3. Poll the selected U1 toolhead sensor between increments and while a v2 move is active. +4. Stop the ACE immediately when the toolhead sensor becomes active. +5. Record cumulative commanded movement. +6. On v2, continuously sample `get_feed_info` during the move and store decoder min/max/span. Do not rely only on a before/after decoder delta: the current code notes that the value may reset or hold between commands. +7. Mark the result as `encoder-confirmed`, `encoder-warning`, or `estimated`. + +The feed command must still have a hard maximum (default: current effective `load_length` plus a guarded allowance, never an unlimited feed). If that ceiling is reached without the toolhead sensor, stop and fail safely. + +The raw sensor-edge measurement should be retained separately from the applied value. If a load safety allowance is desired, show it explicitly: + +```text +sensor edge 2068 mm +load allowance 20 mm +configured value 2088 mm +``` + +Do not silently add a hidden allowance. + +### 4. Mark the swap-clear position + +With filament sitting at the toolhead sensor, retract in user-selected jog steps: `−5`, `−10`, `−25`, `−50`, `−100`, `−250`, and `−500 mm`. + +After each completed step, update: + +- cumulative commanded retract; +- v2 decoder movement/span and slip warning; +- toolhead sensor state; +- ACE slot state; +- a prominent **Stop** control. + +The user visually checks the Y/4-way splitter. When the filament tip has fully passed the junction, they click **Mark splitter cleared**. Store the cumulative distance at that instant as the raw swap checkpoint. + +Offer an explicit clearance allowance field. The final value is: + +```text +swap_retract_length = raw splitter checkpoint + user-visible clearance allowance +``` + +The UI should recommend—not force—a small allowance and explain that too little can collide with the next filament, while too much slows every swap. + +### 5. Return to the automatic-preload reference + +After the splitter checkpoint, the user clicks **Return to preload reference**. The firmware automatically retracts the remaining bounded distance until cumulative retract exactly equals the raw sensor-edge load measurement. No visual inspection inside the ACE is requested or implied. + +`retract_length` is the **total cumulative retract from the toolhead sensor**, not the extra distance after the splitter checkpoint: + +```text +raw retract_length = raw load sensor-edge distance +raw retract_length > raw swap checkpoint > 0 +``` + +The load allowance is not added to `retract_length`: normal loading stops on the toolhead sensor, so the return must mirror the raw movement that actually reached that sensor. The firmware rejects manual jogs that would pass the measured preload reference. + +On ACE 2 Pro, compare accumulated outward and return decoder spans and expose their delta as diagnostics. Decoder telemetry checks symmetry/slip; it does not establish an absolute tip coordinate. ACE v1 uses commanded-distance symmetry and should be repeated to assess spread. + +### 6. Verify the calibrated route + +After calibration, offer one non-heated round trip before Save: + +1. Start at the known ACE automatic-preload coordinate (`0 mm`). +2. Issue one bounded move for the complete calibrated load distance while continuously polling the selected toolhead sensor and moving the route marker. +3. Stop immediately when the toolhead sensor activates and wait for explicit **Continue to splitter** confirmation. Do not reverse automatically. +4. After confirmation, issue one bounded retract for the complete toolhead-to-splitter distance and stop at `load_length - swap_retract_length`. +5. After the user confirms the splitter checkpoint, issue one bounded retract for the complete remaining splitter-to-preload distance. Confirm that the toolhead sensor clears and report verification complete only after the ACE reports ready at coordinate zero. +6. Permit **Pause verification** during every moving leg. Stop the ACE directly, retain the estimated partial-move coordinate, and change the control to **Resume verification**. Resuming commands only the remaining distance. +7. Keep **Stop & cancel** available independently of pause. Cancellation invalidates the clean-slate acknowledgement because the physical position may no longer match the parked reference. +8. On v2, collect decoder span separately for the verification round trip so verification telemetry does not alter the original calibration comparison. + +The normal verification path therefore contains three continuous movement commands: preload to toolhead, toolhead to splitter, and splitter to preload. Both the toolhead and splitter are explicit user-confirmed holds, but there are no automatic 500/100 mm bursts within a leg. A manual pause position is explicitly approximate because the ACE can travel slightly between the stop request and physical deceleration. + +Verification is deliberately separate from measurement so a mistaken checkpoint does not immediately become persistent configuration. Applying without verification remains possible, but a completed round trip is shown as **Verified** and can be repeated before saving. + +After a restart, a saved route may bypass the measurement acknowledgements when live state already proves the safe starting condition: the selected slot is `ready` at ACE preload, the mapped toolhead sensor is explicitly clear, the printer is idle, and no swap/background movement is active. A prominent **Verify parked route** action primes a transient verification session from effective `retract_length` and `swap_retract_length`; it does not overwrite configuration or require another measurement pass. If those live conditions are not satisfied, the button remains disabled and the normal preparation workflow remains available. + +Every verification start, including **Verify again**, submits the ACE, slot, toolhead, and scope displayed in the selector. A completed session may be reused only when all four values still match. If the displayed route changed while parked, firmware primes a new transient session from that route's effective saved values instead of silently reusing the previous slot. + +When `tip_position_mm > 0`, or verification/calibration is moving, manually paused, or held at the toolhead/splitter, lock the ACE, slot, scope, and toolhead selectors to the session route. Klipper must enforce the same rule and reject a different verification route even if requested outside the UI. This prevents a second spool entering an occupied Bowden/splitter path. Unlock only after the route returns to ACE preload or the selected route is explicitly cleared through preparation. + +Installing updated frontend files restarts the web service, but copying a new `ace.py` does not replace the Python class already loaded inside Klipper. After an upgrade that adds calibration G-code behavior, restart Klipper or reboot the printer before using the new verification action. If an old runtime responds with “complete calibration before verification,” the UI translates that into a restart instruction. + +### Verification fine positioning + +Do not fail verification immediately when the saved toolhead endpoint is reached while the toolhead sensor is still clear. Stop the bounded automatic move and enter `verify_toolhead_adjust` instead. This is a recovery hold, not unrestricted manual control. + +The verification panel then exposes two clearly labelled rows: + +- **Toward toolhead:** `+1`, `+2`, `+5`, `+10 mm`; +- **Toward ACE:** `−1`, `−2`, `−5`, `−10 mm`. + +Safety and UX rules: + +1. Use the same conservative calibration speed and allow only one bounded jog at a time. +2. Limit cumulative correction to ±50 mm around the saved endpoint. Reaching the limit without a sensor transition fails with an actionable route/friction warning. +3. Poll the selected toolhead sensor throughout positive movement and stop the ACE immediately when it activates; do not wait for the remainder of that jog. +4. Keep **Stop & cancel** directly available. Disable other jog buttons while a jog is active. +5. Update the inline route marker and show the exact signed correction beside it. The marker may remain visually held at the toolhead node when the correction extends beyond the drawn route. +6. Once the sensor trips, transition to the normal `verify_toolhead` hold and wait for explicit **Continue to splitter** confirmation. +7. Record the signed endpoint correction in the verification result. Do not silently add it to `load_length` or `retract_length`; show it during review so the user can deliberately recalibrate or apply a future correction workflow. + +The production firmware, API, and calibration tab implement this state as `verify_toolhead_adjust` with `verify_toolhead_adjusting` while a jog is active. The interactive mockup demonstrates the same flow with a saved endpoint that stops 7 mm short. + +### 7. Review and apply + +Show: + +- scope and exact config keys; +- previous effective value; +- measured raw value; +- explicit allowance; +- proposed saved value; +- measurement confidence; +- validation result. + +**Apply to config** uses the existing config read/merge/write path, including the SHA-1 conflict check and `.bak` creation. It writes either: + +```ini +[ace 0] +load_length: 2088 +swap_retract_length: 872 +retract_length: 1915 +``` + +or: + +```ini +[ace 0] +load_length_2: 2110 +swap_retract_length_2: 895 +retract_length_2: 1942 +``` + +The tab should then show the existing full-printer-restart banner. The MVP does not need to mutate the loaded Python values in place. + +## Capability and confidence matrix + +| Capability | ACE Pro / v1 | ACE 2 Pro / v2 | +| --- | --- | --- | +| Detect filament present in slot | Yes | Yes | +| Automatic inlet preload | Yes | Yes; reported as `preloading` | +| Command bounded feed/retract | Yes | Yes | +| Stop an active feed | Yes | Yes | +| Stop on U1 toolhead sensor | Yes, coordinated by multiACE | Yes, coordinated by multiACE | +| Read actual per-slot movement | Not exposed by current v1 integration | `get_feed_info` decoder telemetry | +| Automatic load measurement | Incremental commanded-distance estimate | Encoder-assisted, subject to hardware validation | +| Manual splitter checkpoint plus automatic preload-reference return | Yes | Yes | +| Slip diagnostics | Indirect/timeouts only | Decoder versus commanded movement | + +The tab should not be restricted to ACE 2 Pro. Instead, display the confidence badge and adapt the validation policy. + +## Firmware design + +### State ownership + +Keep the authoritative calibration session in `ace.py`, not only in the browser or FastAPI process. A page refresh or WebSocket reconnect must not orphan an active motor operation. + +Suggested status object: + +```python +calibration = { + "session_id": "...", + "state": "idle|prepared|feeding|at_sensor|retracting|retract_ready|swap_marked|returning|complete|failed|cancelled", + "ace": 0, + "slot": 2, + "head": 2, + "scope": "ace|slot", + "protocol": "v1|v2", + "commanded_feed_mm": 0, + "commanded_retract_mm": 0, + "decoder_span": None, + "feed_decoder_span": None, + "return_decoder_span": None, + "decoder_return_delta": None, + "park_reference": "ace_preload", + "toolhead_sensor": False, + "swap_retract_length_mm": None, + "retract_length_mm": None, + "error": None, +} +``` + +Expose it from `ace.get_status()` so the existing WebSocket state stream updates the tab. + +### Suggested G-code surface + +Use narrow commands with state validation on every transition: + +```text +ACE_CALIBRATION_START ACE=0 SLOT=2 HEAD=2 SCOPE=slot +ACE_CALIBRATION_FEED +ACE_CALIBRATION_RETRACT LENGTH=25 +ACE_CALIBRATION_MARK MARK=swap +ACE_CALIBRATION_RETURN +ACE_CALIBRATION_VERIFY ACTION=start +ACE_CALIBRATION_VERIFY ACTION=jog LENGTH=5 +ACE_CALIBRATION_VERIFY ACTION=continue +ACE_CALIBRATION_CANCEL +ACE_CALIBRATION_RESET +``` + +`ACE_CALIBRATION_CANCEL` must be accepted from every non-idle state. + +### Suggested HTTP surface + +The FastAPI layer remains a thin Moonraker adapter: + +```text +GET /api/calibration +POST /api/calibration/start +POST /api/calibration/action +POST /api/calibration/apply +``` + +Example start body: + +```json +{"ace": 0, "slot": 2, "head": 2, "scope": "slot"} +``` + +Example action body: + +```json +{"session_id": "...", "action": "retract", "length": 25} +``` + +The backend should reject stale session IDs and must never construct unrestricted G-code from arbitrary action strings. + +## Safety requirements + +- A single global calibration lock; no concurrent calibration, swap, load, unload, dryer motion, or background operation. +- Calibration disabled while printing or paused. +- Start requires a freshly confirmed baseline in which the selected calibration toolhead sensor reports clear; unknown sensor state is not clear. +- Every move is bounded by a configured maximum and a deadline. +- A visible and always-enabled **Stop & cancel** button. Preparation unload + cancellation must bypass the G-code queue through the dedicated Klipper + webhook; it may not be implemented as the next queued macro. +- Cancel stops feed/retract, disables feed assist, restores sensor enable state, restores the previously active ACE, and leaves the filament captured. +- Toolhead sensor edge is debounced before stopping/recording. +- Reject starting when the selected toolhead sensor is already active. +- Reject a slot that becomes empty during the operation. +- Stop on ACE disconnect, protocol error, stalled decoder, or page-independent watchdog timeout. +- Never heat or extrude during calibration; the filament should only reach the toolhead sensor. +- Do not update `head_source`, RFID/material identity, `print_task_config`, or snapshot state. +- Do not save automatically. Persist only after review and successful validation (or an explicit expert override with a warning). + +## Config precedence and form integration + +The existing lookup order is already correct: + +1. `[ace N] key_S`; +2. `[ace N] key`; +3. global `[ace] key` (with existing head/slot fallbacks for load); +4. built-in default. + +After Apply, update the in-browser `configForm.perAce[N]` values too, so switching to Config immediately shows the same numbers. A later config reload remains the source of truth. + +Unit-level calibration should not delete existing per-slot overrides. Show a warning that those slots will continue to override the new unit value. Slot-level Apply changes only the selected slot. + +## Implementation phases + +### Phase 1: safe MVP + +- Calibration tab and connected-unit cards. +- Clean-slate preparation action, sensor recheck, and explicit empty-baseline/route confirmations. +- Explicit ACE/slot/head route selection. +- Incremental feed to toolhead sensor for both protocols. +- Manual splitter checkpoint followed by an automatic return to the measured preload reference. +- Commanded-distance results and v1/v2 confidence badges. +- Config diff, backup, conflict-aware write, restart banner. +- Cancel/watchdog and all precondition checks. + +### Phase 2: v2 telemetry + +- Continuous `get_feed_info` sampling during every move. +- Decoder span capture that accounts for reset/hold behavior. +- Slip ratio/warnings and encoder-assisted result. +- Hardware test matrix across ACE 2 Pro firmware versions. + +### Phase 3: repeatability tools + +- Two/three-run calibration and median/spread display. +- Per-slot “calibrate all” queue with explicit user checkpoints. +- Calibration history and last validated date/firmware version. + +## Acceptance criteria + +- Calibration appears between Dashboard and Config. +- Only connected ACEs can start a session. +- Calibration cannot start until the clean-slate baseline and physical route are explicitly confirmed; changing route or scope invalidates both. +- Unit and slot scopes write the correct zero-based config keys. +- Feed always stops at or before the guarded maximum and stops promptly on toolhead sensor activation. +- The splitter checkpoint records its cumulative total; the final retract automatically stops at the raw load distance. +- `retract_length == raw load_length > raw swap checkpoint` is enforced, and manual retract cannot pass the preload reference. +- Cancel works in every active step without requiring the browser to remain connected. +- Normal load/unload material bookkeeping remains unchanged. +- Applying results creates a backup and does not overwrite a concurrently edited config. +- v1 results are visibly labeled estimated; v2 results show decoder health/confidence. +- The user can complete a validation round trip before saving. + +## Open hardware questions to validate + +1. Confirm the unit and scaling of v2 `decoder` across current ACE 2 Pro firmware versions. +2. Confirm whether v2 `length` is the active command target, the completed distance, or a lifetime/session counter. +3. Measure how quickly STOP takes effect at 20, 50, and 80 mm/s. +4. Determine the smallest reliable incremental command on v1 and v2. +5. Measure automatic-preload repeatability per slot and material; no visual checkpoint inside the ACE is assumed. +6. Decide default load and splitter-clear allowances from repeated hardware tests; do not guess them in production. diff --git a/FILAMENT_MOTION_FEATURE_REFERENCE.md b/FILAMENT_MOTION_FEATURE_REFERENCE.md new file mode 100644 index 00000000..5c62c526 --- /dev/null +++ b/FILAMENT_MOTION_FEATURE_REFERENCE.md @@ -0,0 +1,531 @@ +# multiACE filament-motion feature engineering reference + +This document captures reusable lessons from building Bowden-path calibration and verification. It is intended as a starting point for future features that select a spool, move filament, interpret sensors, display a route, or persist path-specific configuration. + +The detailed calibration UX and state machine remain in [CALIBRATION_TAB.md](CALIBRATION_TAB.md). This reference focuses on architectural rules and failure modes that apply beyond calibration. + +## 1. The core mental model + +Keep four kinds of state separate: + +| State | What it answers | Examples | +|---|---|---| +| Inventory | What is inserted in an ACE? | Slot ready or empty, RFID, material, colour | +| Topology | Which ACE and slot can reach which toolhead? | Mode, `ace_heads`, `head_ace`, manual or feeder heads | +| Route/session | Which path did this operation select? | ACE, slot, head, scope, session ID | +| Physical | Where is the filament tip now? | Parked, between ACE and splitter, at splitter, at toolhead sensor | + +These states are related but are not interchangeable. + +- A ready slot contains a preloaded spool; it is not proof that its filament occupies the external Bowden path. +- A clear toolhead sensor does not prove the splitter and Bowden path are empty. +- A selected card in the browser is user intent; it does not prove that Klipper selected the same route. +- `head_source` records the known source of a loaded head; it is not a continuously measured tip coordinate. +- Material metadata describes a spool; it does not establish that this spool is the one currently moving. + +Every motion feature should build one explicit route descriptor and use it for validation, commands, status, and visualization: + +```text +route = ACE index + slot index + toolhead index + operating mode/scope +``` + +Never reconstruct different versions of the route independently in the UI, web backend, and Klipper. + +## 2. Authoritative sources and where to look + +### Runtime layers + +| Question | Authoritative source | Useful implementation | +|---|---|---| +| Is an ACE connected? | Klipper `ace` object | `MultiAce.get_status` in `multiace/klipper/extras/ace.py` | +| Which protocol is it using? | Per-ACE protocol instance exposed by Klipper | `aces[].protocol` in the same status payload | +| Which slots are present or moving? | ACE gate and slot status | `_gate_status_per_ace`, `_info_per_ace`, normalized as `aces[].slots[]` | +| Which head is ACE-driven? | Klipper topology configuration | `ace_heads`, `head_ace`, `head_manual`, `head_feeder` | +| Which spool loaded a head? | Klipper route history | `head_source` | +| Is filament at the toolhead? | U1 `eN_filament` motion sensor | `_calibration_sensor` and normalized `filament_at_extruder` | +| Is a print or swap active? | Klipper and related components | `print_stats`, `swap_in_progress`, `ace_bg_swap.busy` | +| Where is a calibration tip estimated? | Calibration session | `calibration.tip_position_mm` | +| What values apply to one path? | Klipper effective config lookup | `get_load_length`, `get_retract_length`, `get_swap_retract_length` | +| What metadata should be displayed? | Override, then RFID, then derived print-task data | `_parse_state` in `multiace/web/backend/main.py` | +| Is v2 filament movement observable? | ACE 2 Pro feed telemetry | `get_feed_info` decoder sampling | + +The browser should normally consume the normalized `/api/state` result. The web backend obtains this from Moonraker with `/printer/objects/query` for the objects listed in `ACE_OBJECTS` in `multiace/web/backend/main.py`. + +When debugging disagreement between layers, inspect them in this order: + +1. Klipper `ace` object status: route, mapping, session, active device and raw ACE state. +2. `filament_feed left` and `filament_feed right`: individual toolhead sensor fields. +3. Normalized `/api/state`: what the web backend decided to expose. +4. Browser state and selected controls: what the UI rendered. +5. The exact G-code or API payload sent when motion started. + +### Important source files + +- `multiace/klipper/extras/ace.py`: hardware commands, topology, effective configuration, collision guards, calibration state and status. +- `multiace/klipper/extras/filament_feed_ace.py`: toolhead loading and unloading mechanics and sensor interaction. +- `multiace/web/backend/main.py`: Moonraker queries, normalization, validation, config persistence and web API dispatch. +- `multiace/web/frontend/app.js`: UI-derived state, default selection, route locking and API payload construction. +- `multiace/web/frontend/index.html`: user-visible states and available controls. +- `multiace/web/mockups/calibration-tab.html`: proposed UX that may be ahead of production behavior. +- `multiace/README.md`: installation, modes, hardware topology and operational prerequisites. + +Do not treat the mockup or browser state as proof of firmware behavior. Confirm production behavior in `ace.py` and the API payload. + +## 3. Indexing and route identity + +Internal ACE, slot and head indices are zero-based. The UI displays them as one-based: + +```text +internal ace=0 slot=0 head=3 +display ACE 1 Slot 1 T4 +``` + +Keep conversion at presentation boundaries. Logs should state whether values are internal or display indices. + +A session must store at least: + +```text +session_id, ace, slot, head, scope, state, tip_position_mm +``` + +Every action after start must include the session ID. Every new or repeated start must include the displayed route. Klipper must compare the submitted route with the stored session before deciding to reuse it. + +### Lesson: Verify again must not mean reuse blindly + +A completed Slot 4 verification was once reused after the user selected Slot 1. The graph showed Slot 1 while Klipper still commanded the Slot 4 session. The general rule is: + +- If submitted route and stored route match, a completed measured session may be reused. +- If they differ and the old route is parked, create a new transient session from the selected route's effective saved configuration. +- If they differ and the old route is not parked, reject the request. + +The route shown in the graph must be the same route serialized into the command. + +## 4. Topology and toolhead selection + +multiACE supports different physical topologies. + +### Multi mode + +The slot maps directly to the corresponding toolhead. Calibration requires `slot == head`. + +### Head mode + +All slots in an ACE can share one ACE-driven head through a combiner. Resolve the toolhead using `ace_heads` and `head_ace`; do not assume T1 or derive the head from the slot number. + +When exactly one ACE is connected, selecting it automatically is reasonable. The initial toolhead must still be resolved from topology. A single ACE does not imply the first toolhead. + +Manual and stock-feeder heads are not ACE routes. Reject them in Klipper even if the browser accidentally offers them. + +### Selection defaults + +- Auto-select the only connected ACE when the user has not made a choice. +- Auto-select the first ready slot only before the user has deliberately selected a slot. +- Auto-select the mapped ACE-driven toolhead from live topology. +- Never overwrite an explicit user choice during a harmless state refresh. +- Once filament leaves preload, force the UI back to the authoritative session route. + +## 5. Spool presence and ACE preload + +Inserting filament starts an ACE-owned internal preload. The ACE advances filament to its own ready position before multiACE commands any external Bowden movement. + +Use that automatic-preload position as coordinate zero. The distance travelled inside the ACE before this point is deliberately unknown and does not need to be measured. + +### Slot availability + +Klipper gate values are: + +```text +-1 unknown + 0 empty + 1 available +``` + +The web backend additionally normalizes motion/error states such as loading, unloading, feeding, assist and error. + +Protocol strings differ. ACE Pro v1 can report `empty1`, while v2 reports `empty`. Empty detection must use the shared prefix rule rather than equality with only `empty`. + +Require all of the following before starting a route from preload: + +- selected ACE connected; +- selected gate available; +- slot returned to ready after preload; +- selected toolhead is ACE-driven; +- selected toolhead sensor explicitly clear; +- printer idle; +- no print, swap, background load/unload, or calibration movement. + +Multiple slots may legitimately be ready at the same time. Do not infer the intended motion slot from the first ready spool after the user has selected another one. Helpers such as `_first_loaded_slot_for_ace` are appropriate only for documented fallback/display behavior, not for a safety-critical explicit move. + +### Metadata is not placement evidence + +The display metadata priority is: + +1. user slot override; +2. ACE RFID data; +3. derived print-task data; +4. empty/unknown. + +This priority is useful for labels and colours. It must never decide which motor to move. + +## 6. Filament detection semantics + +Use `filament_at_extruder` for the selected toolhead when deciding whether the tip reached or cleared the toolhead. It corresponds to the U1 `filament_motion_sensor eN_filament` object used by calibration. + +Do not substitute the generic `filament_detected` field. That field represents an upstream feed/channel condition and may remain true while filament is merely preloaded in the ACE. + +Treat sensor values as three-state data: + +```text +true filament detected at the selected toolhead sensor +false selected toolhead sensor explicitly clear +null sensor unavailable or state unknown +``` + +Unknown is not clear. A missing sensor must block automatic motion that depends on it. + +A clear toolhead sensor also does not mean the entire route is clear. The tip can be between the ACE, splitter and toolhead. Combine the sensor with the session coordinate and state. + +The useful hierarchy is: + +- Sensor transition: authoritative at the physical toolhead endpoint. +- Explicit visual confirmation: authoritative at a visible splitter checkpoint. +- Commanded distance and elapsed time: an estimate between anchors. +- v2 decoder movement: supporting evidence for motion/slip, not an absolute coordinate. + +## 7. Coordinate model + +Use the ACE preload position as `P0`: + +```text +P0 = 0 +Ps = load_length - swap_retract_length +Pt = raw sensor-edge load distance or effective saved retract_length +``` + +The marker maps `P0..Ps` to the ACE-to-splitter segment and `Ps..Pt` to the splitter-to-toolhead segment. Each drawn segment has its own percentage because the diagram is not physically to scale. + +Saved valid anchors are sufficient to draw a parked tip at P0 after restart. Show a question mark only when anchors or physical position are genuinely unknown. + +Keep raw sensor-edge measurements separate from allowances. For example, a load allowance may affect a saved load command but must not silently change the raw round-trip return coordinate. + +## 8. Collision prevention invariants + +Collision prevention must exist in both the UI and Klipper. UI disabling is guidance; firmware validation is authority. + +### Route lock + +Lock ACE, slot, scope and toolhead selection whenever: + +- `tip_position_mm > 0`; +- calibration or verification is moving; +- verification is manually paused; +- verification is held at the toolhead or splitter; +- a cancelled or failed session leaves physical position uncertain. + +While locked, pin the displayed route to the session route. Reject a different route even if it arrives through a direct API call or stale browser. + +Unlock only when the route is known parked at ACE preload or has been explicitly cleared through a recovery/preparation operation. + +### Shared-path rules + +- Never feed a second spool into an occupied combiner, splitter or shared Bowden path. +- Do not use another ready slot as evidence that switching is safe. +- Do not clear route ownership merely because the toolhead sensor changed to clear. +- Do not allow selector changes during a checkpoint hold or pause. +- On disconnect, timeout, cancellation or protocol failure, preserve enough session identity to recover the same route. +- Prefer rejecting an uncertain move over guessing which spool is physically active. + +### Preparation scope + +Preparation should unload only the toolhead connected to the ACE route being calibrated. Unload-all is broader than necessary and can disturb unrelated paths. + +When the toolhead sensor is occupied but `head_source` is unknown, preparation must submit the ACE and slot selected in the calibration UI. Firmware may accept that route only after validating topology, selected-slot gate state, current calibration occupancy, and conflicts with other loaded routes. The normal dashboard command historically tolerated a missing source by using the active ACE; calibration must never use that fallback. Once accepted, keep the recovered route in memory so all nested unload stages target the same spool and a failed or cancelled unload can be retried safely. + +An automatic unload can prove that the selected toolhead sensor became clear. It cannot prove that loose filament was removed from a visible splitter/output path. Keep automatic sensor evidence separate from the user's clean-path acknowledgement. + +## 9. Motion and state-machine design + +### Measurement versus verification + +Measurement and verification have different goals: + +- Measurement uses controlled increments near an unknown endpoint. The current load procedure begins with 500 mm and then uses 100 mm increments while polling the sensor. +- Verification already knows the anchors. Each leg should be one bounded command rather than repeated 500/100 mm bursts. + +The verification workflow is: + +1. preload to toolhead in one continuous command; +2. stop at the toolhead sensor and wait for manual continuation; +3. toolhead to splitter in one continuous command; +4. stop at the splitter and wait for visual confirmation; +5. splitter to preload in one continuous command. + +Manual pause/resume can divide a leg, but automatic chunking must not. + +If the configured toolhead endpoint is reached while its sensor remains clear, enter the bounded `verify_toolhead_adjust` recovery hold rather than failing immediately. Accept only signed 1, 2, 5, or 10 mm jogs, cap cumulative correction at +/-50 mm, poll the toolhead sensor during movement, and transition to the normal toolhead hold as soon as it activates. Keep the signed correction diagnostic; never silently rewrite calibration values from a verification jog. + +### Command acknowledgement is not motion completion + +An ACE command callback confirms acceptance or reports an error. Completion is determined from ACE ready state plus elapsed-time safeguards. Cached ready state may still be visible immediately after sending a command, so do not mark a move complete too early. + +The calibration timer currently waits until elapsed time reaches a substantial fraction of the expected move duration before accepting ready as completion, and applies an additional timeout margin. + +### Sensor-driven stop + +Poll the selected toolhead sensor throughout positive movement. Stop the ACE immediately on activation. Do not wait for the remainder of a long requested feed. + +Record the estimated trigger coordinate separately from the calibrated target so timing and stop-latency differences remain diagnostic rather than silently altering configuration. + +### Pause and resume + +On pause: + +1. estimate and commit the partial coordinate; +2. send stop directly; +3. remember the previous moving phase; +4. stop the timer; +5. resume by commanding only the remaining distance. + +The displayed paused position is approximate because motion can continue briefly during deceleration. + +### Cancellation must bypass blocked queues + +A cancellation queued behind a blocking unload runs too late. Preparation unload therefore uses the direct Klipper webhook `multiace/calibration_unload_cancel`, exposed by the web backend as `/api/calibration/unload-cancel`. + +Use this pattern for future long-running synchronous operations: + +- register an out-of-band cancellation endpoint; +- set a cancellation flag; +- stop hardware immediately where supported; +- check the flag at safe points inside the operation; +- preserve an unknown/non-parked state if exact position cannot be proven. + +Keep Stop and cancel available while moving, manually paused, and waiting at checkpoints. + +### Session concurrency + +- Increment the session ID for each new route session. +- Include it in every action request. +- Reject actions for a stale session rather than applying them to the new route. +- Treat page reload as a projection refresh, not as a reason to reset physical state. +- Never infer that an HTTP request finishing means the physical move finished; poll the authoritative state. + +## 10. ACE protocol differences + +### ACE Pro v1 + +- No equivalent per-slot `get_feed_info` telemetry is exposed by the current integration. +- Distance is a commanded-distance estimate. +- Repeatability runs are important. +- Empty state strings can differ from v2, notably `empty1`. + +### ACE 2 Pro v2 + +- Provides per-slot feed information including steps, requested length and decoder value. +- Reports firmware motion states such as preloading. +- Supports continuous decoder sampling during movement. + +Decoder guidance: + +- Sample repeatedly during the move and use min/max span. +- Do not rely only on a before/after delta because the value may reset or hold between commands. +- Normalize the unsigned 64-bit value when required. +- Use span and outward/return symmetry as diagnostics for movement and slip. +- Do not claim that decoder count is an absolute filament-tip coordinate until its unit and firmware behavior are verified. +- Do not let decoder diagnostics alter the commanded coordinate model silently. + +## 11. Effective configuration and persistence + +Klipper lookup priority is: + +```text +per-slot value under the selected ACE +then per-ACE value +then global value +``` + +Use the existing lookup methods in `ace.py` rather than duplicating precedence: + +- `get_load_length` +- `get_retract_length` +- `get_swap_retract_length` + +The frontend must calculate preview anchors with the same precedence. It must use measured session values only when the session route matches the currently selected route. Otherwise, showing old measured anchors beside a new slot creates the same class of UI/runtime disagreement as the Slot 4 verification bug. + +Entire-ACE calibration writes shared ACE defaults. Single-slot calibration writes the path-specific override. Do not remove unrelated slot overrides when applying an entire-ACE value. + +Config writes should use the existing conflict-aware read/merge/write path: + +- retain a content hash to detect concurrent changes; +- create a backup; +- show the exact diff; +- do not save automatically; +- persist only after explicit review; +- show the required restart state. + +## 12. UI rules learned from the feature + +- Initialize selectors only after live state and topology are available. +- Distinguish a visually selected control from an acknowledged backend route. +- Disable controls from physical/session state, not only from a local HTTP busy flag. +- Pin selectors to the session while the route is locked. +- Show the tip marker in the existing ACE-splitter-toolhead graphic instead of duplicating position in an unrelated line. +- Use saved anchors immediately after restart when the route is known parked. +- State which sensor is clear; avoid broad claims such as all ACE-driven sensors are clear when only one selected sensor was evaluated. +- Use plain action language such as Use clear T4 as starting point instead of opaque state language such as Empty baseline explicitly confirmed. +- Separate automatic evidence from user confirmation. +- Show why an action is disabled, especially route locks and unavailable sensors. +- Keep expert corrections bounded and contextual rather than exposing permanent unrestricted motor controls. +- Never silently write a manual verification correction into configuration. + +The graph, labels and controls should all derive from the same route/session object. If the graph says Slot 1, the next command must contain Slot 1. + +## 13. Deployment and runtime version lessons + +The feature spans two independently loaded runtimes: + +- frontend/backend files served by the standalone multiACE web service; +- `ace.py` loaded into the running Klipper process. + +Updating web files does not replace the Python class already loaded in Klipper. A web restart can therefore show a new button that calls an old G-code implementation. + +After changing `ace.py`, restart Klipper or reboot the printer. For changes involving boot scripts, USB/serial discovery or the full installed package, use a full printer reboot as directed by the installer. + +Useful deployment checks: + +- inspect the JavaScript served by the printer for a newly added symbol; +- inspect `/api/state` for newly added status fields; +- invoke no motion until the web and Klipper versions agree; +- hard-refresh the browser if the served bundle is correct but the page is stale; +- remember that `/etc/init.d/S98multiace-web` owns the standalone web service; +- do not assume reinstall succeeded merely because the page loads. + +Convert recognizable old-runtime errors into actionable restart guidance, but do not hide the underlying error in logs. + +## 14. Diagnostics and observability + +Log route identity at the start of every physical operation: + +```text +operation, session ID, ACE, slot, head, phase, requested distance +``` + +For movement, also retain: + +```text +start coordinate, direction, speed, start time, move ID, +sensor transitions, ACE response errors, completion reason, +decoder min/max/span when available +``` + +The calibration implementation uses a move sequence ID and session ID so delayed callbacks cannot mutate a newer move. + +When reproducing a route bug, capture: + +1. `/api/state` before the click; +2. selected values rendered by the page; +3. request payload sent to `/api/calibration/action`; +4. Klipper calibration status immediately after start; +5. the ACE and slot index in the hardware request; +6. sensor state and final session state. + +The most useful invariant to log is: + +```text +displayed route == submitted route == session route == hardware request route +``` + +## 15. Common failed assumptions + +| Failed assumption | Correct rule | +|---|---| +| One connected ACE means T1 | Resolve the mapped ACE-driven head from topology | +| First ready spool is always intended | Use it only as an untouched default; explicit selection wins | +| A ready spool occupies the Bowden path | Ready normally means parked at the ACE preload position | +| Clear toolhead sensor means clear route | The tip may be elsewhere in the Bowden/splitter path | +| `filament_detected` is the toolhead endpoint | Use `filament_at_extruder` for the selected head | +| Unknown sensor means clear | Unknown must block sensor-dependent automation | +| The selector controls the active backend slot | Submit and verify the full route on every start | +| Verify again can reuse any completed session | Reuse only an exact route match | +| UI disabling prevents collisions | Enforce the same invariant in Klipper | +| A queued cancel stops a blocking unload | Use an out-of-band stop/cancel path | +| Decoder count is absolute distance | Treat it as movement/slip telemetry until proven otherwise | +| Verification should use measurement bursts | Known verification legs can be one bounded command each | +| The splitter can be skipped | It is a user-confirmed physical checkpoint | +| Reaching a checkpoint should auto-continue | Wait for explicit intervention at toolhead and splitter | +| Web reinstall means Python is live | Restart Klipper or reboot after `ace.py` changes | +| Unload all is safest preparation | Clear only the selected route and avoid disturbing unrelated paths | + +## 16. Regression scenarios for future features + +Every filament-motion feature should test at least these cases: + +### Route selection + +- One connected ACE mapped to T4: initial route shows T4, not T1. +- Slots 1 and 4 both ready: selecting Slot 1 moves only Slot 1. +- Complete a Slot 4 operation, select Slot 1, then repeat: the new session and hardware request use Slot 1. +- Switch scope between entire ACE and one slot while parked: effective values follow the selected scope. + +### Collision prevention + +- Pause with the tip between ACE and splitter: every route selector remains locked. +- Hold at the toolhead: another slot cannot be selected through UI or direct API. +- Hold at the splitter: another slot cannot be selected through UI or direct API. +- Cancel mid-route: route identity remains available for recovery and no different spool can start. +- Return to preload: selectors unlock only after position zero is authoritative. + +### Sensors and unknown state + +- Toolhead sensor false: start is allowed only if all other guards pass. +- Toolhead sensor true: start from preload is rejected. +- Toolhead sensor unavailable: start is rejected as unknown, not treated as clear. +- Sensor trips during a long outbound command: ACE receives stop immediately. +- Sensor remains triggered after full return: verification fails rather than reporting parked. + +### Motion control + +- Each verification leg produces one full remaining-distance request. +- Manual pause stops the current leg and resume sends only the remaining distance. +- Toolhead and splitter holds require explicit continuation. +- Stop and cancel is available while moving, paused and held. +- ACE disconnect, rejected command and timeout all end in a recoverable failed/cancelled state. + +### Protocol and deployment + +- Run the same workflow on v1 with estimated labels and on v2 with decoder diagnostics. +- Reinstall web files without restarting Klipper: the UI reports an actionable runtime mismatch rather than proceeding unsafely. +- Reload the page during movement or a checkpoint hold: the UI reconstructs and locks to the live session. +- Browser cache contains the old bundle: version/symbol checks distinguish cache from old installation. + +## 17. Template for a new filament-motion feature + +Before implementation, write down: + +1. Route identity: ACE, slot, head and topology constraints. +2. Physical origin: how parked is proven. +3. Endpoints: which are sensor-driven, distance-driven or visually confirmed. +4. Collision domain: which other routes share hardware with this route. +5. State machine: every moving, held, paused, complete, failed and cancelled state. +6. Guards: printer, swap, connection, gate, sensor, mode and route-lock checks. +7. Motion bounds: maximum distance, speed and timeout. +8. Stop behavior: direct hardware stop and out-of-band cancellation if needed. +9. Telemetry: authoritative signals versus diagnostics. +10. UI projection: how selectors, graph and actions derive from session state. +11. Persistence: effective precedence, review, backup and restart behavior. +12. Recovery: what the user does after interruption or unknown position. +13. Deployment: which processes must restart. +14. Regression tests: especially two ready spools and stale-session reuse. + +Do not start motor-control implementation until the physical origin, collision domain and recovery path are explicit. + +## 18. Known limitations and follow-up work + +- Verification fine-position controls are implemented in firmware, API, and the production UI; their stop latency and +/-50 mm recovery bound still require validation on each supported ACE generation. +- The real unit/scaling and cross-firmware stability of the ACE 2 Pro decoder should be measured before using it for anything beyond diagnostics. +- Stop latency and physical coast introduce uncertainty into elapsed-time position estimates. +- Backlash, splitter friction, material stiffness and spool drag can make outward and return paths asymmetric. +- Automatic-preload repeatability should be measured per ACE generation, slot and material. +- Recovery after power loss cannot assume the last persisted coordinate is still physically correct; require a clean-route procedure. + +These limitations should appear as explicit confidence or recovery states, not be hidden behind a precise-looking number. diff --git a/multiace/README.md b/multiace/README.md index ee2967df..f341e747 100644 --- a/multiace/README.md +++ b/multiace/README.md @@ -205,6 +205,10 @@ Before installing multiACE, ensure the following: ``` bash /tmp/multiace/install_multiace.sh ``` + or for web + ``` + bash /tmp/multiace/install_multiace.sh --install-web --keep-config + ``` 4. Reboot the printer 5. multiACE starts in **Multi mode** - all connected ACE units are detected automatically diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 5cff87eb..ef828c82 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -13,6 +13,7 @@ }, "tabs": { "dashboard": "Dashboard", + "calibration": "Calibration", "dry": "Dry", "config": "Config", "display": "Display" diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 4003d2b2..10521f43 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -446,6 +446,22 @@ def _parse_idx_list(key): self._last_swap_result = None self._event_seq = 0 + # Bowden-path calibration is deliberately separate from FEED_AUTO: + # it never heats, extrudes, purges, or mutates head_source/material + # bookkeeping. The reactor timer keeps every move cancellable. + self._calibration = self._calibration_idle_state() + self._calibration_timer = None + self._calibration_move = None + self._calibration_prev_ace = None + self._calibration_seq = 0 + self._calibration_unload = { + 'active': False, 'head': None, 'ace': None, 'slot': None, + 'cancel_requested': False, + } + self.printer.lookup_object('webhooks').register_endpoint( + 'multiace/calibration_unload_cancel', + self._handle_calibration_unload_cancel) + self._v2_active_rev_assist = False self._test_cancel = False self._unload_all_cancel = False @@ -620,6 +636,30 @@ def _parse_idx_list(key): self.gcode.register_command( 'ACE_RETRACT', self.cmd_ACE_RETRACT, desc=self.cmd_ACE_RETRACT_help) + self.gcode.register_command( + 'ACE_CALIBRATION_START', self.cmd_ACE_CALIBRATION_START, + desc=self.cmd_ACE_CALIBRATION_START_help) + self.gcode.register_command( + 'ACE_CALIBRATION_FEED', self.cmd_ACE_CALIBRATION_FEED, + desc=self.cmd_ACE_CALIBRATION_FEED_help) + self.gcode.register_command( + 'ACE_CALIBRATION_RETRACT', self.cmd_ACE_CALIBRATION_RETRACT, + desc=self.cmd_ACE_CALIBRATION_RETRACT_help) + self.gcode.register_command( + 'ACE_CALIBRATION_MARK', self.cmd_ACE_CALIBRATION_MARK, + desc=self.cmd_ACE_CALIBRATION_MARK_help) + self.gcode.register_command( + 'ACE_CALIBRATION_RETURN', self.cmd_ACE_CALIBRATION_RETURN, + desc=self.cmd_ACE_CALIBRATION_RETURN_help) + self.gcode.register_command( + 'ACE_CALIBRATION_VERIFY', self.cmd_ACE_CALIBRATION_VERIFY, + desc=self.cmd_ACE_CALIBRATION_VERIFY_help) + self.gcode.register_command( + 'ACE_CALIBRATION_CANCEL', self.cmd_ACE_CALIBRATION_CANCEL, + desc=self.cmd_ACE_CALIBRATION_CANCEL_help) + self.gcode.register_command( + 'ACE_CALIBRATION_RESET', self.cmd_ACE_CALIBRATION_RESET, + desc=self.cmd_ACE_CALIBRATION_RESET_help) self.gcode.register_command( 'ACE_SWITCH', self.cmd_ACE_SWITCH, @@ -1530,6 +1570,7 @@ def _hotplug_monitor(self, eventtime): return eventtime + 2.0 def _handle_disconnect(self): + self._calibration_abort('klippy disconnected', restore=False) logging.info('[multiACE] Closing all ACE connections') for idx in list(self._serials.keys()): try: @@ -1968,6 +2009,7 @@ def _sniff_print_gcode_loads(self): return False def _on_print_start(self, *args): + self._calibration_abort('print started') self._print_has_gcode_loads = self._sniff_print_gcode_loads() logging.info('[multiACE] print gcode carries multiACE loads: %s' % self._print_has_gcode_loads) @@ -4853,6 +4895,1172 @@ def callback(self, response): request={"method": "stop_feed_filament", "params": {"index": index}}, callback=callback) + # ------------------------------------------------------------------ + # Bowden path calibration + + def _calibration_idle_state(self): + return { + 'state': 'idle', + 'session_id': 0, + 'ace': None, + 'slot': None, + 'head': None, + 'scope': 'ace', + 'protocol': '', + 'commanded_feed_mm': 0, + 'commanded_retract_mm': 0, + 'load_length_mm': None, + 'swap_retract_length_mm': None, + 'retract_length_mm': None, + 'decoder_span': None, + 'feed_decoder_span': None, + 'return_decoder_span': None, + 'decoder_return_delta': None, + 'park_reference': 'ace_preload', + 'tip_position_mm': None, + 'verify_phase': 'unavailable', + 'verify_position_mm': None, + 'verify_paused_from': None, + 'verify_feed_actual_mm': None, + 'verify_feed_delta_mm': None, + 'verify_fine_offset_mm': 0, + 'verify_fine_limit_mm': 50, + 'verify_decoder_span': None, + 'verify_move_seq': 0, + 'toolhead_sensor': None, + 'max_feed_mm': 0, + 'speed_mm_s': 0, + 'previous_load_length': None, + 'previous_swap_retract_length': None, + 'previous_retract_length': None, + 'error': None, + } + + def _calibration_sensor(self, head): + sensor = self.printer.lookup_object( + 'filament_motion_sensor e%d_filament' % int(head), None) + if sensor is None: + return None + try: + return bool(sensor.get_status(0).get('filament_detected')) + except Exception: + return None + + def _calibration_prepare_unload_source(self, gcmd, head, ace, slot): + if ace is None or slot is None: + raise gcmd.error( + '[multiACE] calibration preparation requires the selected ' + 'ACE and SLOT') + if not (0 <= ace < len(self._ace_devices)): + raise gcmd.error( + '[multiACE] calibration preparation ACE out of range') + if slot < 0 or slot > 3: + raise gcmd.error( + '[multiACE] calibration preparation SLOT must be 0-3') + if getattr(self, '_ace_mode', 'normal') == 'normal': + raise gcmd.error( + '[multiACE] switch to multi or head mode first') + if self.head_is_manual(head) or not self.head_uses_ace(head): + raise gcmd.error( + '[multiACE] selected head is not ACE-driven') + mode = getattr(self, '_ace_mode', 'multi') + if mode == 'multi' and slot != head: + raise gcmd.error( + '[multiACE] multi mode preparation requires SLOT=HEAD') + if mode == 'head' and self.head_ace_for(head) != ace: + raise gcmd.error( + '[multiACE] selected head is wired to a different ACE') + + ps = self.printer.lookup_object('print_stats', None) + ps_state = (getattr(ps, 'state', '') or '').lower() + if ps_state in ('printing', 'paused'): + raise gcmd.error( + '[multiACE] calibration preparation requires an idle printer') + if self._swap_in_progress: + raise gcmd.error( + '[multiACE] cannot prepare calibration during a swap') + bg = self.printer.lookup_object('ace_bg_swap', None) + if bg is not None and getattr(bg, '_busy', None): + raise gcmd.error( + '[multiACE] background load/unload is active') + if self._calibration_unload.get('active'): + raise gcmd.error( + '[multiACE] calibration preparation unload already active') + if not self._ensure_ace_available(ace): + raise gcmd.error( + '[multiACE] selected ACE is not connected') + + gates = self._gate_status_per_ace.get(ace) or [] + if slot >= len(gates) or gates[slot] != GATE_AVAILABLE: + raise gcmd.error( + '[multiACE] selected slot is not at its ACE preload ' + 'reference') + + c = self._calibration + if c.get('state') in ( + 'feeding', 'retracting', 'returning', 'verifying_feed', + 'verifying_splitter', 'verifying_return', + 'verify_toolhead_adjusting'): + raise gcmd.error( + '[multiACE] calibration movement is still active') + tip = c.get('tip_position_mm') + route_occupied = tip is not None and float(tip) > 0.0 + if route_occupied and ( + c.get('ace') != ace or c.get('slot') != slot + or c.get('head') != head): + raise gcmd.error( + '[multiACE] selected preparation route does not match the ' + 'occupied calibration route') + + for other_head, other in self._head_source.items(): + if other_head == head or not other: + continue + if (other.get('ace_index') == ace + and other.get('slot') == slot): + raise gcmd.error( + '[multiACE] selected ACE slot is already assigned to ' + 'another toolhead') + if mode == 'head' and other.get('ace_index') == ace: + raise gcmd.error( + '[multiACE] selected ACE already has another loaded ' + 'toolhead route') + + return {'ace_index': int(ace), 'slot': int(slot)} + + def _calibration_unload_begin(self, head, ace, slot): + self._calibration_unload = { + 'active': True, 'head': int(head), 'ace': int(ace), + 'slot': int(slot), 'cancel_requested': False, + } + + def _calibration_unload_finish(self): + self._calibration_unload = { + 'active': False, 'head': None, 'ace': None, 'slot': None, + 'cancel_requested': False, + } + + def _calibration_unload_stop_hardware(self): + state = self._calibration_unload + ace, slot, head = state.get('ace'), state.get('slot'), state.get('head') + if isinstance(ace, int) and isinstance(slot, int): + for method in ('stop_feed_filament', 'stop_feed_assist'): + try: + self.send_request_to(ace, { + 'method': method, 'params': {'index': slot}}, + lambda self, response: None) + except Exception: + pass + try: + extruder = self.printer.lookup_object( + 'extruder' if head == 0 else 'extruder%d' % head, None) + pheaters = self.printer.lookup_object('heaters', None) + if extruder is not None and pheaters is not None: + pheaters.set_temperature(extruder.get_heater(), 0.) + except Exception: + logging.exception( + '[multiACE] failed to turn heater off during calibration ' + 'unload cancellation') + + def _handle_calibration_unload_cancel(self, web_request): + state = self._calibration_unload + if not state.get('active'): + web_request.send({'state': 'idle', 'cancelled': False}) + return + state['cancel_requested'] = True + self._calibration_unload_stop_hardware() + logging.info('[multiACE] direct calibration unload cancel requested ' + 'for head %s', state.get('head')) + web_request.send({'state': 'cancelling', 'cancelled': True}) + + def _check_calibration_unload_cancel(self): + if (self._calibration_unload.get('active') + and self._calibration_unload.get('cancel_requested')): + self._calibration_unload_stop_hardware() + raise self.printer.command_error( + '[multiACE] calibration preparation unload cancelled') + + def _calibration_stop_timer(self): + timer = self._calibration_timer + self._calibration_timer = None + if timer is not None: + try: + self.reactor.unregister_timer(timer) + except Exception: + pass + + def _calibration_send_stop(self): + c = self._calibration + idx, slot = c.get('ace'), c.get('slot') + if not isinstance(idx, int) or not isinstance(slot, int): + return + try: + self.send_request_to(idx, { + 'method': 'stop_feed_filament', + 'params': {'index': slot}}, lambda self, response: None) + except Exception: + pass + + def _calibration_restore_active(self): + prev = self._calibration_prev_ace + self._calibration_prev_ace = None + if (isinstance(prev, int) and prev != self._active_device_index + and self._connected_per_ace.get(prev, False)): + try: + self._set_active_idx(prev) + except Exception: + pass + + def _calibration_abort(self, reason, restore=True): + if not hasattr(self, '_calibration'): + return + c = self._calibration + if c.get('state') in ( + 'idle', 'cancelled', 'complete', 'verified', 'failed'): + return + self._calibration_send_stop() + self._calibration_stop_timer() + self._calibration_move = None + c['state'] = 'cancelled' + c['error'] = str(reason) + if c.get('head') is not None: + c['toolhead_sensor'] = self._calibration_sensor(c['head']) + if restore: + self._calibration_restore_active() + logging.info('[multiACE] calibration cancelled: %s', reason) + + def _calibration_fail(self, message): + c = self._calibration + self._calibration_send_stop() + self._calibration_move = None + c['state'] = 'failed' + c['error'] = str(message) + if c.get('head') is not None: + c['toolhead_sensor'] = self._calibration_sensor(c['head']) + self._calibration_restore_active() + self.log_error('[multiACE] Calibration failed: %s' % message) + + def _calibration_move_callback(self, response, session_id, move_id): + c = self._calibration + move = self._calibration_move + if (c.get('session_id') != session_id or move is None + or move.get('id') != move_id): + return + if response and response.get('code', 0) != 0: + move['error'] = response.get('msg') or 'ACE rejected movement' + + def _calibration_sample_decoder(self, idx, slot, session_id, move_id): + def _cb(self, response, _sid=session_id, _mid=move_id, + _slot=slot): + c = self._calibration + move = self._calibration_move + if (c.get('session_id') != _sid or move is None + or move.get('id') != _mid): + return + try: + rows = ((response or {}).get('result') or {}).get( + 'feed_info') or [] + for row in rows: + if int(row.get('index', -1)) != _slot: + continue + value = int(row.get('decoder', 0)) + if value >= (1 << 63): + value -= (1 << 64) + cur_min = move.get('decoder_min') + cur_max = move.get('decoder_max') + move['decoder_min'] = value if cur_min is None else min( + cur_min, value) + move['decoder_max'] = value if cur_max is None else max( + cur_max, value) + break + except Exception: + pass + try: + self.send_request_to(idx, {'method': 'get_feed_info'}, _cb) + except Exception: + pass + + def _calibration_accumulate_decoder(self, move): + dmin, dmax = move.get('decoder_min'), move.get('decoder_max') + if dmin is None or dmax is None: + return + span = max(0, int(dmax) - int(dmin)) + kind = str(move.get('kind') or '') + if kind.startswith('verify_'): + current = self._calibration.get('verify_decoder_span') + self._calibration['verify_decoder_span'] = span \ + if current is None else int(current) + span + return + current = self._calibration.get('decoder_span') + self._calibration['decoder_span'] = span if current is None \ + else int(current) + span + key = ('feed_decoder_span' if kind == 'feed' + else 'return_decoder_span') + current = self._calibration.get(key) + self._calibration[key] = span if current is None \ + else int(current) + span + + def _calibration_finish_return(self): + c = self._calibration + target = int(c.get('load_length_mm') or 0) + value = int(c.get('commanded_retract_mm') or 0) + if target <= 0 or value != target: + self._calibration_fail( + 'return to ACE preload reference ended at the wrong distance') + return + c['retract_length_mm'] = target + feed_span = c.get('feed_decoder_span') + return_span = c.get('return_decoder_span') + if feed_span is not None and return_span is not None: + c['decoder_return_delta'] = abs( + int(feed_span) - int(return_span)) + c['state'] = 'complete' + c['verify_phase'] = 'ready' + c['verify_position_mm'] = 0 + c['tip_position_mm'] = 0 + c['toolhead_sensor'] = self._calibration_sensor(c['head']) + c['error'] = None + self._calibration_move = None + self._calibration_restore_active() + self.log_always( + '[multiACE] Calibration returned to ACE preload reference: ' + 'load=%d swap=%d retract=%d' + % (target, int(c.get('swap_retract_length_mm') or 0), target)) + + def _calibration_verify_position(self, eventtime, commit=False): + c = self._calibration + move = self._calibration_move + if move is None or not str(move.get('kind', '')).startswith('verify_'): + return int(c.get('verify_position_mm') or 0) + elapsed = max(0.0, eventtime - float(move['started'])) + travelled = min(float(move['length']), + elapsed * float(move['speed'])) + position = (float(move.get('position_start', 0)) + + float(move.get('direction', 1)) * travelled) + load = int(c.get('load_length_mm') or 0) + if str(move.get('kind', '')).startswith('verify_adjust'): + fine_limit = int(c.get('verify_fine_limit_mm') or 50) + lower = max(0, load - fine_limit) + upper = load + fine_limit + else: + lower, upper = 0, load + position = max(float(lower), min(float(upper), position)) + c['verify_position_mm'] = int(round(position)) + c['tip_position_mm'] = c['verify_position_mm'] + if commit: + self._calibration_accumulate_decoder(move) + self._calibration_move = None + return c['verify_position_mm'] + + def _calibration_verify_hold_toolhead(self, eventtime): + c = self._calibration + actual = self._calibration_verify_position(eventtime, commit=True) + target = int(c.get('load_length_mm') or 0) + c['verify_feed_actual_mm'] = actual + c['verify_feed_delta_mm'] = actual - target + c['verify_fine_offset_mm'] = actual - target + c['verify_position_mm'] = target + c['tip_position_mm'] = target + c['verify_phase'] = 'toolhead' + c['state'] = 'verify_toolhead' + c['toolhead_sensor'] = True + self._calibration_send_stop() + self._calibration_timer = None + self.log_always( + '[multiACE] Verification paused at toolhead sensor ' + '(actual~%dmm, calibrated=%dmm)' % (actual, target)) + + def _calibration_verify_enter_adjust(self): + c = self._calibration + target = int(c.get('load_length_mm') or 0) + c['verify_position_mm'] = target + c['tip_position_mm'] = target + c['verify_feed_actual_mm'] = target + c['verify_feed_delta_mm'] = 0 + c['verify_fine_offset_mm'] = 0 + c['verify_phase'] = 'toolhead_adjust' + c['state'] = 'verify_toolhead_adjust' + c['toolhead_sensor'] = False + c['error'] = None + self._calibration_move = None + self._calibration_timer = None + self.log_always( + '[multiACE] Verification reached the calibrated toolhead ' + 'endpoint with the sensor clear; bounded fine positioning ' + 'is available') + + def _calibration_verify_hold_after_adjust(self, eventtime): + c = self._calibration + actual = self._calibration_verify_position(eventtime, commit=True) + target = int(c.get('load_length_mm') or 0) + offset = actual - target + c['verify_feed_actual_mm'] = actual + c['verify_feed_delta_mm'] = offset + c['verify_fine_offset_mm'] = offset + c['verify_position_mm'] = actual + c['tip_position_mm'] = actual + c['verify_phase'] = 'toolhead' + c['state'] = 'verify_toolhead' + c['toolhead_sensor'] = True + self._calibration_send_stop() + self._calibration_timer = None + self.log_always( + '[multiACE] Verification fine positioning reached the ' + 'toolhead sensor at %+dmm' % offset) + + def _calibration_verify_dispatch(self, eventtime, state): + c = self._calibration + position = int(c.get('verify_position_mm') or 0) + load = int(c.get('load_length_mm') or 0) + if state == 'verifying_feed': + remaining = load - position + if remaining <= 0: + if self._calibration_sensor(c['head']): + self._calibration_verify_hold_toolhead(eventtime) + else: + self._calibration_verify_enter_adjust() + return False + # Verification is a continuous traversal, unlike measurement: + # command the complete remaining route and keep polling the + # sensor so the move can still be stopped immediately. + length = remaining + kind, method, direction = 'verify_feed', 'feed_filament', 1 + speed = c['speed_mm_s'] + elif state == 'verifying_splitter': + target = load - int(c.get('swap_retract_length_mm') or 0) + remaining = position - target + if remaining <= 0: + c['verify_position_mm'] = target + c['tip_position_mm'] = target + c['verify_phase'] = 'splitter' + c['state'] = 'verify_splitter' + self._calibration_timer = None + return False + length = remaining + kind = 'verify_splitter' + method, direction = 'unwind_filament', -1 + speed = min(30, self.get_retract_speed(c['ace'])) + else: + remaining = position + if remaining <= 0: + if self._calibration_sensor(c['head']) is True: + self._calibration_fail( + 'toolhead sensor remained triggered after the ' + 'verification return') + self._calibration_timer = None + return False + c['verify_position_mm'] = 0 + c['tip_position_mm'] = 0 + c['verify_phase'] = 'verified' + c['state'] = 'verified' + c['error'] = None + self._calibration_timer = None + self._calibration_restore_active() + self.log_always( + '[multiACE] Verification round trip complete') + return False + length = remaining + kind, method, direction = 'verify_return', 'unwind_filament', -1 + speed = min(30, self.get_retract_speed(c['ace'])) + c['verify_move_seq'] = int(c.get('verify_move_seq') or 0) + 1 + move_id = c['verify_move_seq'] + move = { + 'id': move_id, 'kind': kind, 'length': int(length), + 'speed': speed, 'started': eventtime, + 'position_start': position, 'direction': direction, + 'decoder_min': None, 'decoder_max': None, + 'next_decoder_sample': eventtime, 'error': None, + } + self._calibration_move = move + try: + self.send_request_to(c['ace'], { + 'method': method, + 'params': {'index': c['slot'], 'length': int(length), + 'speed': speed}}, + lambda self, response, _sid=c['session_id'], _mid=move_id: + self._calibration_move_callback(response, _sid, _mid)) + except Exception as e: + self._calibration_fail( + 'failed to start verification movement: %s' % e) + return False + return True + + def _calibration_verify_timer_tick(self, eventtime): + c = self._calibration + state = c.get('state') + idx, head = c['ace'], c['head'] + move = self._calibration_move + c['toolhead_sensor'] = self._calibration_sensor(head) + if state == 'verifying_feed' and c['toolhead_sensor'] is True: + self._calibration_verify_hold_toolhead(eventtime) + return self.reactor.NEVER + if (state == 'verify_toolhead_adjusting' + and c['toolhead_sensor'] is True): + self._calibration_verify_hold_after_adjust(eventtime) + return self.reactor.NEVER + if move is not None: + self._calibration_verify_position(eventtime) + if move.get('error'): + self._calibration_fail(move['error']) + self._calibration_timer = None + return self.reactor.NEVER + if (c.get('protocol') == 'v2' + and eventtime >= move.get('next_decoder_sample', 0)): + move['next_decoder_sample'] = eventtime + 0.25 + self._calibration_sample_decoder( + idx, c['slot'], c['session_id'], move['id']) + elapsed = eventtime - move['started'] + expected = float(move['length']) / max(float(move['speed']), 1.) + ready = (self._info_per_ace.get(idx, {}) or {}).get( + 'status') == 'ready' + if ready and elapsed >= max(0.15, expected * 0.80): + c['verify_position_mm'] = int( + move.get('position_start', 0) + + move.get('direction', 1) * move['length']) + c['tip_position_mm'] = c['verify_position_mm'] + self._calibration_accumulate_decoder(move) + self._calibration_move = None + if state == 'verify_toolhead_adjusting': + offset = (int(c['verify_position_mm']) + - int(c.get('load_length_mm') or 0)) + c['verify_fine_offset_mm'] = offset + c['verify_feed_actual_mm'] = c['verify_position_mm'] + c['verify_feed_delta_mm'] = offset + limit = int(c.get('verify_fine_limit_mm') or 50) + if abs(offset) >= limit: + self._calibration_fail( + 'toolhead sensor was not reached within the ' + 'verification fine-position limit') + self._calibration_timer = None + return self.reactor.NEVER + c['state'] = 'verify_toolhead_adjust' + c['verify_phase'] = 'toolhead_adjust' + self._calibration_timer = None + return self.reactor.NEVER + return eventtime + 0.05 + if elapsed > expected + 8.0: + self._calibration_fail('verification movement timed out') + self._calibration_timer = None + return self.reactor.NEVER + return eventtime + 0.05 + if (self._info_per_ace.get(idx, {}) or {}).get('status') != 'ready': + return eventtime + 0.10 + if not self._calibration_verify_dispatch(eventtime, state): + return self.reactor.NEVER + return eventtime + 0.05 + + def _calibration_finish_feed(self, eventtime): + c = self._calibration + move = self._calibration_move + partial = 0.0 + if move is not None: + elapsed = max(0.0, eventtime - move['started']) + partial = min(float(move['length']), + elapsed * float(move['speed'])) + self._calibration_accumulate_decoder(move) + c['commanded_feed_mm'] = int(round( + float(c.get('commanded_feed_mm', 0)) + partial)) + c['load_length_mm'] = c['commanded_feed_mm'] + c['tip_position_mm'] = c['load_length_mm'] + c['toolhead_sensor'] = True + c['state'] = 'at_sensor' + c['error'] = None + self._calibration_move = None + self._calibration_send_stop() + self.log_always( + '[multiACE] Calibration load sensor reached at ~%dmm ' + '(ACE %d slot %d -> head %d)' + % (c['load_length_mm'], c['ace'], c['slot'], c['head'])) + + def _calibration_timer_tick(self, eventtime): + c = self._calibration + state = c.get('state') + verify_moving = ( + 'verifying_feed', 'verifying_splitter', 'verifying_return', + 'verify_toolhead_adjusting') + if state in verify_moving: + idx = c.get('ace') + if not self._connected_per_ace.get(idx, False): + self._calibration_fail('selected ACE disconnected') + self._calibration_timer = None + return self.reactor.NEVER + return self._calibration_verify_timer_tick(eventtime) + if state not in ('feeding', 'retracting', 'returning'): + self._calibration_timer = None + return self.reactor.NEVER + idx, slot, head = c['ace'], c['slot'], c['head'] + if not self._connected_per_ace.get(idx, False): + self._calibration_fail('selected ACE disconnected') + self._calibration_timer = None + return self.reactor.NEVER + + detected = self._calibration_sensor(head) + c['toolhead_sensor'] = detected + move = self._calibration_move + + if state == 'feeding' and detected is True: + self._calibration_finish_feed(eventtime) + self._calibration_timer = None + return self.reactor.NEVER + + if move is not None: + if state == 'feeding': + elapsed = max(0.0, eventtime - float(move['started'])) + partial = min(float(move['length']), + elapsed * float(move['speed'])) + c['tip_position_mm'] = max(0, int(round( + int(c.get('commanded_feed_mm') or 0) + partial))) + elif state in ('retracting', 'returning'): + elapsed = max(0.0, eventtime - float(move['started'])) + partial = min(float(move['length']), + elapsed * float(move['speed'])) + c['tip_position_mm'] = max(0, int(round( + int(c.get('load_length_mm') or 0) + - int(c.get('commanded_retract_mm') or 0) + - partial))) + if move.get('error'): + self._calibration_fail(move['error']) + self._calibration_timer = None + return self.reactor.NEVER + if (c.get('protocol') == 'v2' + and eventtime >= move.get('next_decoder_sample', 0)): + move['next_decoder_sample'] = eventtime + 0.25 + self._calibration_sample_decoder( + idx, slot, c['session_id'], move['id']) + elapsed = eventtime - move['started'] + info = self._info_per_ace.get(idx, {}) or {} + ready = info.get('status') == 'ready' + expected = float(move['length']) / max(float(move['speed']), 1.) + # The cached status can still read ready immediately after a + # command is queued. Never complete a chunk until enough motion + # time has elapsed for the heartbeat to observe busy -> ready. + if ready and elapsed >= max(0.15, expected * 0.80): + self._calibration_accumulate_decoder(move) + if state == 'feeding': + c['commanded_feed_mm'] = int( + c.get('commanded_feed_mm', 0)) + int(move['length']) + self._calibration_move = None + if c['commanded_feed_mm'] >= c['max_feed_mm']: + self._calibration_fail( + 'toolhead sensor not reached before feed limit') + self._calibration_timer = None + return self.reactor.NEVER + return eventtime + 0.05 + c['commanded_retract_mm'] = int( + c.get('commanded_retract_mm', 0)) + int(move['length']) + c['tip_position_mm'] = max( + 0, int(c.get('load_length_mm') or 0) + - int(c['commanded_retract_mm'])) + self._calibration_move = None + if state == 'returning': + if (int(c['commanded_retract_mm']) >= + int(c.get('load_length_mm') or 0)): + self._calibration_finish_return() + self._calibration_timer = None + return self.reactor.NEVER + return eventtime + 0.05 + c['state'] = ('swap_marked' + if c.get('swap_retract_length_mm') is not None + else 'retract_ready') + c['toolhead_sensor'] = self._calibration_sensor(head) + self._calibration_timer = None + return self.reactor.NEVER + if elapsed > expected + 8.0: + self._calibration_fail('ACE movement timed out') + self._calibration_timer = None + return self.reactor.NEVER + return eventtime + 0.05 + + info = self._info_per_ace.get(idx, {}) or {} + if info.get('status') != 'ready': + return eventtime + 0.10 + if state == 'returning': + remaining = (int(c.get('load_length_mm') or 0) + - int(c.get('commanded_retract_mm') or 0)) + if remaining <= 0: + self._calibration_finish_return() + self._calibration_timer = None + return self.reactor.NEVER + length = min(500, remaining) + kind = 'return' + method = 'unwind_filament' + speed = min(30, self.get_retract_speed(idx)) + move_id = int(c.get('commanded_retract_mm', 0)) + 1 + else: + remaining = int(c['max_feed_mm']) - int(c['commanded_feed_mm']) + length = min(500 if not c.get('commanded_feed_mm') else 100, + remaining) + kind = 'feed' + method = 'feed_filament' + speed = c['speed_mm_s'] + move_id = int(c.get('commanded_feed_mm', 0)) + 1 + if remaining <= 0: + self._calibration_fail( + 'toolhead sensor not reached before feed limit') + self._calibration_timer = None + return self.reactor.NEVER + move = { + 'id': move_id, 'kind': kind, 'length': length, + 'speed': speed, 'started': eventtime, + 'decoder_min': None, 'decoder_max': None, + 'next_decoder_sample': eventtime, 'error': None, + } + self._calibration_move = move + try: + self.send_request_to(idx, { + 'method': method, + 'params': {'index': slot, 'length': length, + 'speed': speed}}, + lambda self, response, _sid=c['session_id'], _mid=move_id: + self._calibration_move_callback(response, _sid, _mid)) + except Exception as e: + self._calibration_fail('failed to start feed: %s' % e) + self._calibration_timer = None + return self.reactor.NEVER + return eventtime + 0.05 + + def _calibration_arm_timer(self): + self._calibration_stop_timer() + self._calibration_timer = self.reactor.register_timer( + self._calibration_timer_tick, self.reactor.NOW) + + cmd_ACE_CALIBRATION_START_help = ( + '[multiACE] Start Bowden calibration. ' + 'ACE_CALIBRATION_START ACE=0 SLOT=0 HEAD=0 SCOPE=ace|slot') + def cmd_ACE_CALIBRATION_START(self, gcmd): + if self._calibration.get('state') in ( + 'feeding', 'retracting', 'returning', + 'verifying_feed', 'verifying_splitter', + 'verifying_return', 'verify_toolhead_adjusting'): + raise gcmd.error('[multiACE] calibration movement already active') + ace = gcmd.get_int('ACE') + slot = gcmd.get_int('SLOT') + head = gcmd.get_int('HEAD') + scope = (gcmd.get('SCOPE', 'ace') or 'ace').strip().lower() + if not (0 <= ace < len(self._ace_devices)): + raise gcmd.error('[multiACE] calibration ACE out of range') + if slot < 0 or slot > 3 or head < 0 or head > 3: + raise gcmd.error('[multiACE] calibration SLOT/HEAD must be 0-3') + if scope not in ('ace', 'slot'): + raise gcmd.error('[multiACE] calibration SCOPE must be ace or slot') + ps = self.printer.lookup_object('print_stats', None) + ps_state = (getattr(ps, 'state', '') or '').lower() + if ps_state in ('printing', 'paused'): + raise gcmd.error('[multiACE] calibration requires an idle printer') + if getattr(self, '_ace_mode', 'normal') == 'normal': + raise gcmd.error('[multiACE] switch to multi or head mode first') + if self._swap_in_progress: + raise gcmd.error('[multiACE] cannot calibrate during a swap') + bg = self.printer.lookup_object('ace_bg_swap', None) + if bg is not None and getattr(bg, '_busy', None): + raise gcmd.error('[multiACE] background load/unload is active') + if self.head_is_manual(head) or not self.head_uses_ace(head): + raise gcmd.error('[multiACE] selected head is not ACE-driven') + if getattr(self, '_ace_mode', 'multi') == 'multi' and slot != head: + raise gcmd.error( + '[multiACE] multi mode calibration requires SLOT=HEAD') + if (getattr(self, '_ace_mode', 'multi') == 'head' + and self.head_ace_for(head) != ace): + raise gcmd.error( + '[multiACE] selected head is wired to a different ACE') + if not self._ensure_ace_available(ace): + raise gcmd.error('[multiACE] selected ACE is not connected') + gates = self._gate_status_per_ace.get(ace) or [] + if slot >= len(gates) or gates[slot] != GATE_AVAILABLE: + raise gcmd.error( + '[multiACE] insert filament and wait for ACE preload first') + detected = self._calibration_sensor(head) + if detected is None: + raise gcmd.error('[multiACE] selected toolhead sensor unavailable') + if detected: + raise gcmd.error( + '[multiACE] selected toolhead sensor already has filament') + + self._calibration_abort('new calibration session') + self._calibration_prev_ace = self._active_device_index + if not self._switch_ace_for_head_target(ace): + raise gcmd.error('[multiACE] failed to activate selected ACE') + self._disable_feed_assist_all() + self.wait_ace_ready_on(ace) + proto = self._protocols.get(ace) + protocol = getattr(proto, 'NAME', '') if proto else '' + self._calibration_seq += 1 + effective_load = self.get_load_length(ace, slot) + self._calibration = self._calibration_idle_state() + self._calibration.update({ + 'state': 'prepared', + 'session_id': self._calibration_seq, + 'ace': ace, 'slot': slot, 'head': head, 'scope': scope, + 'protocol': protocol, + 'toolhead_sensor': False, + 'tip_position_mm': 0, + 'max_feed_mm': min(5000, max(100, effective_load + 300)), + 'speed_mm_s': min(30, self.get_feed_speed(ace)), + 'previous_load_length': effective_load, + 'previous_swap_retract_length': + self.get_swap_retract_length(ace, slot), + 'previous_retract_length': self.get_retract_length(ace, slot), + }) + self._calibration_move = None + self.log_always( + '[multiACE] Calibration prepared: ACE %d slot %d -> head %d ' + '(%s scope, %s)' + % (ace, slot, head, scope, protocol or 'unknown protocol')) + + cmd_ACE_CALIBRATION_FEED_help = ( + '[multiACE] Feed a prepared calibration path to its toolhead sensor') + def cmd_ACE_CALIBRATION_FEED(self, gcmd): + c = self._calibration + if c.get('state') != 'prepared': + raise gcmd.error('[multiACE] calibration is not prepared') + if self._calibration_sensor(c['head']): + raise gcmd.error('[multiACE] toolhead sensor is already active') + c['state'] = 'feeding' + c['error'] = None + self._calibration_move = None + self._calibration_arm_timer() + + cmd_ACE_CALIBRATION_RETRACT_help = ( + '[multiACE] Calibration retract jog. LENGTH=5..500') + def cmd_ACE_CALIBRATION_RETRACT(self, gcmd): + c = self._calibration + if c.get('state') not in ('at_sensor', 'retract_ready', 'swap_marked'): + raise gcmd.error('[multiACE] calibration is not ready to retract') + length = gcmd.get_int('LENGTH') + if length < 5 or length > 500: + raise gcmd.error('[multiACE] calibration retract LENGTH must be 5-500') + if int(c.get('commanded_retract_mm', 0)) + length \ + > int(c.get('load_length_mm') or 0): + raise gcmd.error( + '[multiACE] calibration retract would pass the ACE preload ' + 'reference') + self.wait_ace_ready_on(c['ace']) + move_id = int(c.get('commanded_retract_mm', 0)) + 1 + now = self.reactor.monotonic() + move = { + 'id': move_id, 'kind': 'retract', 'length': length, + 'speed': min(30, self.get_retract_speed(c['ace'])), + 'started': now, + 'decoder_min': None, 'decoder_max': None, + 'next_decoder_sample': now, 'error': None, + } + self._calibration_move = move + c['state'] = 'retracting' + try: + self.send_request_to(c['ace'], { + 'method': 'unwind_filament', + 'params': {'index': c['slot'], 'length': length, + 'speed': move['speed']}}, + lambda self, response, _sid=c['session_id'], _mid=move_id: + self._calibration_move_callback(response, _sid, _mid)) + except Exception as e: + self._calibration_fail('failed to start retract: %s' % e) + raise gcmd.error('[multiACE] failed to start calibration retract') + self._calibration_arm_timer() + + cmd_ACE_CALIBRATION_MARK_help = ( + '[multiACE] Mark calibration checkpoint. MARK=swap|full') + def cmd_ACE_CALIBRATION_MARK(self, gcmd): + c = self._calibration + mark = (gcmd.get('MARK') or '').strip().lower() + if mark == 'swap': + if c.get('state') != 'retract_ready': + raise gcmd.error( + '[multiACE] retract at least one step before marking swap') + value = int(c.get('commanded_retract_mm', 0)) + if value <= 0: + raise gcmd.error('[multiACE] swap checkpoint must be positive') + if value >= int(c.get('load_length_mm') or 0): + raise gcmd.error( + '[multiACE] splitter checkpoint must be before the ACE ' + 'preload reference') + c['swap_retract_length_mm'] = value + c['state'] = 'swap_marked' + return + if mark == 'full': + if c.get('state') != 'swap_marked': + raise gcmd.error('[multiACE] mark swap checkpoint first') + value = int(c.get('commanded_retract_mm', 0)) + swap = int(c.get('swap_retract_length_mm') or 0) + target = int(c.get('load_length_mm') or 0) + if value <= swap or value != target: + raise gcmd.error( + '[multiACE] full retract must match the measured ACE ' + 'preload reference') + self._calibration_finish_return() + return + raise gcmd.error('[multiACE] MARK must be swap or full') + + cmd_ACE_CALIBRATION_RETURN_help = ( + '[multiACE] Return from the splitter checkpoint to the ACE preload ' + 'reference') + def cmd_ACE_CALIBRATION_RETURN(self, gcmd): + c = self._calibration + if c.get('state') != 'swap_marked': + raise gcmd.error('[multiACE] mark the splitter checkpoint first') + target = int(c.get('load_length_mm') or 0) + current = int(c.get('commanded_retract_mm') or 0) + if target <= 0 or current >= target: + raise gcmd.error( + '[multiACE] no safe distance remains to the preload reference') + c['state'] = 'returning' + c['error'] = None + self._calibration_move = None + self._calibration_arm_timer() + + cmd_ACE_CALIBRATION_VERIFY_help = ( + '[multiACE] Verify calibrated route. ' + 'ACTION=start|continue|pause|resume|jog [LENGTH=-10..10]') + def cmd_ACE_CALIBRATION_VERIFY(self, gcmd): + c = self._calibration + action = (gcmd.get('ACTION') or '').strip().lower() + moving = ( + 'verifying_feed', 'verifying_splitter', 'verifying_return') + if action == 'start': + configured_start = False + requested_ace = gcmd.get_int('ACE', None) + requested_slot = gcmd.get_int('SLOT', None) + requested_head = gcmd.get_int('HEAD', None) + route_values = ( + requested_ace, requested_slot, requested_head) + route_supplied = any(value is not None for value in route_values) + if route_supplied and not all( + value is not None for value in route_values): + raise gcmd.error( + '[multiACE] verification route requires ACE, SLOT and ' + 'HEAD') + requested_scope = ( + (gcmd.get('SCOPE', 'ace') or 'ace').strip().lower()) + route_changed = route_supplied and ( + int(requested_ace) != c.get('ace') + or int(requested_slot) != c.get('slot') + or int(requested_head) != c.get('head') + or requested_scope != c.get('scope')) + current_position = c.get('tip_position_mm') + tip_away_from_park = ( + current_position is not None + and float(current_position) > 0.0) + non_parked_state = c.get('state') in ( + 'feeding', 'at_sensor', 'retract_ready', 'retracting', + 'swap_marked', 'returning', 'verifying_feed', + 'verify_toolhead', 'verify_toolhead_adjust', + 'verify_toolhead_adjusting', 'verifying_splitter', + 'verify_splitter', 'verifying_return', 'verify_paused', + 'cancelled', 'failed') + if route_changed and (tip_away_from_park or non_parked_state): + raise gcmd.error( + '[multiACE] cannot change verification slot while the ' + 'previous filament route is not parked; return or clear ' + 'that route first') + if c.get('state') not in ('complete', 'verified') or route_changed: + if c.get('state') not in ('idle', 'cancelled', 'failed'): + if not (route_changed + and c.get('state') in ('complete', 'verified')): + raise gcmd.error( + '[multiACE] finish or cancel the current ' + 'calibration before verification') + if not route_supplied: + raise gcmd.error( + '[multiACE] verification route requires ACE, SLOT ' + 'and HEAD') + ace = int(requested_ace) + slot = int(requested_slot) + if not (0 <= ace < len(self._ace_devices)): + raise gcmd.error('[multiACE] verification ACE out of range') + if slot < 0 or slot > 3: + raise gcmd.error('[multiACE] verification SLOT must be 0-3') + configured_retract = int( + self.get_retract_length(ace, slot) or 0) + configured_swap = int( + self.get_swap_retract_length(ace, slot) or 0) + if (configured_retract <= 0 or configured_swap <= 0 + or configured_swap >= configured_retract): + raise gcmd.error( + '[multiACE] saved retract/splitter calibration is ' + 'missing or invalid for this route') + # Reuse the full start guard (idle printer, selected route, + # fresh preload, clear toolhead sensor) before priming a + # transient session from the effective saved values. + self.cmd_ACE_CALIBRATION_START(gcmd) + c = self._calibration + c.update({ + 'state': 'complete', + 'load_length_mm': configured_retract, + 'swap_retract_length_mm': configured_swap, + 'retract_length_mm': configured_retract, + 'verify_phase': 'ready', + 'verify_position_mm': 0, + 'tip_position_mm': 0, + }) + configured_start = True + if c.get('state') not in ('complete', 'verified'): + raise gcmd.error( + '[multiACE] complete calibration before verification') + load = int(c.get('load_length_mm') or 0) + swap = int(c.get('swap_retract_length_mm') or 0) + if load <= 0 or swap <= 0 or swap >= load: + raise gcmd.error( + '[multiACE] calibrated route anchors are invalid') + detected = self._calibration_sensor(c['head']) + if detected is None: + raise gcmd.error( + '[multiACE] selected toolhead sensor unavailable') + if detected: + raise gcmd.error( + '[multiACE] toolhead sensor must be clear before verify') + if not self._ensure_ace_available(c['ace']): + raise gcmd.error('[multiACE] selected ACE is not connected') + if not configured_start: + self._calibration_prev_ace = self._active_device_index + if not self._switch_ace_for_head_target(c['ace']): + raise gcmd.error( + '[multiACE] failed to activate selected ACE') + self._disable_feed_assist_all() + self.wait_ace_ready_on(c['ace']) + c.update({ + 'state': 'verifying_feed', + 'verify_phase': 'feed', + 'verify_position_mm': 0, + 'tip_position_mm': 0, + 'verify_paused_from': None, + 'verify_feed_actual_mm': None, + 'verify_feed_delta_mm': None, + 'verify_fine_offset_mm': 0, + 'verify_fine_limit_mm': 50, + 'verify_decoder_span': None, + 'verify_move_seq': 0, + 'toolhead_sensor': False, + 'error': None, + }) + self.log_always( + '[multiACE] Verification starting on ACE %d slot %d -> ' + 'head %d' % (c['ace'], c['slot'], c['head'])) + self._calibration_move = None + self._calibration_arm_timer() + return + if action == 'jog': + if c.get('state') != 'verify_toolhead_adjust': + raise gcmd.error( + '[multiACE] verification is not waiting for toolhead ' + 'fine positioning') + delta = gcmd.get_int('LENGTH') + if abs(delta) not in (1, 2, 5, 10): + raise gcmd.error( + '[multiACE] verification jog LENGTH must be ' + '+/-1, 2, 5, or 10 mm') + detected = self._calibration_sensor(c['head']) + if detected is None: + raise gcmd.error( + '[multiACE] selected toolhead sensor unavailable') + if detected: + self._calibration_verify_hold_after_adjust( + self.reactor.monotonic()) + return + load = int(c.get('load_length_mm') or 0) + position = int(c.get('verify_position_mm') or load) + current_offset = position - load + target_offset = current_offset + delta + limit = int(c.get('verify_fine_limit_mm') or 50) + if abs(target_offset) > limit: + raise gcmd.error( + '[multiACE] verification jog would exceed the ' + '+/-50 mm fine-position limit') + self.wait_ace_ready_on(c['ace']) + c['verify_move_seq'] = int(c.get('verify_move_seq') or 0) + 1 + move_id = c['verify_move_seq'] + direction = 1 if delta > 0 else -1 + method = 'feed_filament' if delta > 0 else 'unwind_filament' + speed = min(30, (c.get('speed_mm_s') or 30)) + now = self.reactor.monotonic() + move = { + 'id': move_id, + 'kind': ('verify_adjust_feed' if delta > 0 + else 'verify_adjust_retract'), + 'length': abs(delta), 'speed': speed, 'started': now, + 'position_start': position, 'direction': direction, + 'decoder_min': None, 'decoder_max': None, + 'next_decoder_sample': now, 'error': None, + } + self._calibration_move = move + c['state'] = 'verify_toolhead_adjusting' + c['verify_phase'] = 'toolhead_adjusting' + c['error'] = None + try: + self.send_request_to(c['ace'], { + 'method': method, + 'params': {'index': c['slot'], 'length': abs(delta), + 'speed': speed}}, + lambda self, response, _sid=c['session_id'], + _mid=move_id: self._calibration_move_callback( + response, _sid, _mid)) + except Exception as e: + self._calibration_fail( + 'failed to start verification fine positioning: %s' % e) + raise gcmd.error( + '[multiACE] failed to start verification jog') + self._calibration_arm_timer() + return + if action == 'continue': + if c.get('state') == 'verify_toolhead': + c['state'] = 'verifying_splitter' + c['verify_phase'] = 'splitter_move' + elif c.get('state') == 'verify_splitter': + c['state'] = 'verifying_return' + c['verify_phase'] = 'return' + else: + raise gcmd.error( + '[multiACE] verification is not waiting at a checkpoint') + c['error'] = None + self._calibration_move = None + self._calibration_arm_timer() + return + if action == 'pause': + if c.get('state') not in moving: + raise gcmd.error('[multiACE] verification is not moving') + now = self.reactor.monotonic() + c['verify_position_mm'] = self._calibration_verify_position( + now, commit=True) + c['verify_paused_from'] = c['state'] + c['state'] = 'verify_paused' + c['verify_phase'] = 'paused' + self._calibration_send_stop() + self._calibration_stop_timer() + return + if action == 'resume': + previous = c.get('verify_paused_from') + if c.get('state') != 'verify_paused' or previous not in moving: + raise gcmd.error('[multiACE] verification is not paused') + c['state'] = previous + c['verify_phase'] = { + 'verifying_feed': 'feed', + 'verifying_splitter': 'splitter_move', + 'verifying_return': 'return', + }[previous] + c['verify_paused_from'] = None + self._calibration_move = None + self._calibration_arm_timer() + return + raise gcmd.error( + '[multiACE] verification ACTION must be ' + 'start, continue, pause, resume, or jog') + + cmd_ACE_CALIBRATION_CANCEL_help = ( + '[multiACE] Stop and cancel the active calibration') + def cmd_ACE_CALIBRATION_CANCEL(self, gcmd): + self._calibration_abort('cancelled by user') + + cmd_ACE_CALIBRATION_RESET_help = ( + '[multiACE] Clear a completed/failed/cancelled calibration session') + def cmd_ACE_CALIBRATION_RESET(self, gcmd): + if self._calibration.get('state') in ( + 'feeding', 'retracting', 'returning', + 'verifying_feed', 'verifying_splitter', + 'verifying_return', 'verify_toolhead_adjusting'): + raise gcmd.error('[multiACE] cancel active movement before reset') + self._calibration_restore_active() + self._calibration_stop_timer() + self._calibration_move = None + self._calibration = self._calibration_idle_state() + cmd_ACE_SWITCH_help = 'Switch active ACE unit. Usage: ACE_SWITCH TARGET=0 [AUTOLOAD=1]' EXTRUDER_MAP = { @@ -6700,6 +7908,7 @@ def _tipform_rejected(self, resp): return str(resp.get('msg', '')).strip().upper() == 'FORBIDDEN' def _run_tipform(self, head, temp, soft, nozzle_diameter): + self._check_calibration_unload_cancel() material = self._tipform_material_for(head) vendor = self._tipform_vendor_for(head) table = self.tipform_table_for(material, vendor=vendor, soft=bool(soft)) @@ -6717,6 +7926,7 @@ def _run_tipform(self, head, temp, soft, nozzle_diameter): self.gcode.run_script_from_command( "INNER_FILAMENT_UNLOAD TEMP=%d SOFT=%d NOZZLE_DIAMETER=%f\r\n" % (temp, soft, nozzle_diameter)) + self._check_calibration_unload_cancel() return _tf_desc = (('%s %s' % (vendor, material)) if vendor and material else (material.lower() if material else 'default')) @@ -6748,6 +7958,7 @@ def _run_tipform(self, head, temp, soft, nozzle_diameter): fwd_armed = False def _tf_fa_start(): + self._check_calibration_unload_cancel() try: if self._v2_get_slot_status(ace_idx, slot) \ in V2_FA_RUNNING_STATES: @@ -6756,6 +7967,7 @@ def _tf_fa_start(): except Exception: pass for _a in range(3): + self._check_calibration_unload_cancel() resp = self._tipform_send(ace_idx, { 'method': 'start_feed_assist', 'params': {'index': slot}}) if not self._tipform_rejected(resp): @@ -6766,6 +7978,7 @@ def _tf_fa_start(): def _tf_unwind(ln): for _a in range(3): + self._check_calibration_unload_cancel() self._tipform_send(ace_idx, { 'method': 'stop_feed_assist', 'params': {'index': slot}}) resp = self._tipform_send(ace_idx, { @@ -6781,8 +7994,10 @@ def _tf_unwind(ln): try: run('MOVE_TO_DISCARD_FILAMENT_POSITION') run('M109 S%d' % int(temp)) + self._check_calibration_unload_cancel() run('M83') for tok in table: + self._check_calibration_unload_cancel() kind = tok[0] if kind == 'move': mm, feed = float(tok[1]), int(tok[2]) @@ -6836,6 +8051,7 @@ def _tf_unwind(ln): break self.reactor.pause( self.reactor.monotonic() + 0.5) + self._check_calibration_unload_cancel() elif kind == 'fan': run('M106 S%d' % int(tok[1])) finally: @@ -7124,13 +8340,17 @@ def cmd_ACE_LOAD_HEAD(self, gcmd): cmd_ACE_UNLOAD_HEAD_help = ( '[multiACE] Unload a toolhead back to its ACE. ' - 'Usage: ACE_UNLOAD_HEAD HEAD=0 [RETRACT_LENGTH=] [KEEP_HEAT=]') + 'Usage: ACE_UNLOAD_HEAD HEAD=0 [RETRACT_LENGTH=] ' + '[KEEP_HEAT=] [CALIBRATION_PREPARE=1 ACE=0 SLOT=0]') def cmd_ACE_UNLOAD_HEAD(self, gcmd): head = gcmd.get_int('HEAD') retract_override = gcmd.get_int('RETRACT_LENGTH', 0) keep_heat = gcmd.get_int('KEEP_HEAT', 0) + calibration_prepare = bool(gcmd.get_int('CALIBRATION_PREPARE', 0)) + requested_ace = gcmd.get_int('ACE', None) + requested_slot = gcmd.get_int('SLOT', None) self._last_unload_ok = True @@ -7141,6 +8361,10 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): '[multiACE] head %d is manual - ACE_UNLOAD_HEAD ignored, ' 'unload it by hand' % head) return + selected_source = None + if calibration_prepare: + selected_source = self._calibration_prepare_unload_source( + gcmd, head, requested_ace, requested_slot) self._wait_bg_op(head, gcmd) if not self._head_is_loaded(head): self.log_always(self._t('msg.unload_head_already_empty', @@ -7161,6 +8385,17 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): logging.info('[multiACE] unload head %d: using bg-staged ' 'ACE %d slot %d as the source' % (head, staged[0], staged[1])) + recovery_source = False + if calibration_prepare: + if source is not None and ( + source.get('ace_index') != selected_source['ace_index'] + or source.get('slot') != selected_source['slot']): + raise gcmd.error( + '[multiACE] selected calibration route does not match ' + 'the tracked toolhead source') + if source is None: + source = selected_source + recovery_source = True if source: ace_index = source['ace_index'] slot = source['slot'] @@ -7171,6 +8406,19 @@ def cmd_ACE_UNLOAD_HEAD(self, gcmd): if not self._switch_ace_for_head_target(ace_index): raise gcmd.error( '[multiACE] Failed to connect to ACE %d for unload!' % ace_index) + if recovery_source: + # FEED_AUTO resolves its retract slot through head_source. + # Record the user's validated route in memory so every + # downstream stage targets the selected spool rather than a + # first-ready or active-ACE fallback. A verified unload clears + # it normally; a failed/cancelled unload retains it for a safe + # retry during this Klipper session. + self._head_source[head] = dict(source) + self.log_always( + '[multiACE] calibration preparation recovered unknown ' + 'head %d source as ACE %d slot %d' + % (self._disp(head), self._disp(ace_index), + self._disp(slot))) else: logging.info(self._t('msg.unload_head_no_mapping', head=self._disp(head))) @@ -7217,20 +8465,53 @@ def _noop_cb(self, response): module, channel = self.EXTRUDER_MAP[head] + if calibration_prepare: + self._calibration_unload_begin( + head, active_idx, int(source.get('slot', head))) + self._retract_length_override = retract_override if retract_override > 0 else None + cancelled = False try: + self._check_calibration_unload_cancel() self.gcode.run_script_from_command( "FEED_AUTO MODULE=%s CHANNEL=%d EXTRUDER=%d UNLOAD=1 STAGE=prepare" % (module, channel, head)) + self._check_calibration_unload_cancel() self.gcode.run_script_from_command( "FEED_AUTO MODULE=%s CHANNEL=%d EXTRUDER=%d UNLOAD=1 STAGE=doing" % (module, channel, head)) + self._check_calibration_unload_cancel() except Exception as e: self._audit_state('UNLOAD_HEAD_FAILED', {'head': head, 'reason': 'feed_auto_error', 'error': str(e), 'active_device': self._active_device_index}) - raise + cancelled = bool( + calibration_prepare + and self._calibration_unload.get('cancel_requested')) + if not cancelled: + if calibration_prepare: + self._calibration_unload_finish() + raise finally: self._retract_length_override = None + if cancelled: + self._last_unload_ok = False + try: + self.gcode.run_script_from_command('M104 S0') + self.gcode.run_script_from_command( + 'SET_FILAMENT_SENSOR SENSOR=e%d_filament ENABLE=1' % head) + except Exception: + pass + self._calibration_unload_finish() + self.log_always( + '[multiACE] Calibration preparation unload cancelled for ' + 'head %d; verify filament position before retrying' + % self._disp(head)) + self._audit_state('CALIBRATION_UNLOAD_CANCELLED', {'head': head}) + return + + if calibration_prepare: + self._calibration_unload_finish() + if keep_heat > 0: self.gcode.run_script_from_command('M104 S%d' % keep_heat) @@ -9624,6 +10905,8 @@ def get_status(self, eventtime=None): 'head_ace': {str(h): int(self.head_ace.get(h, h)) for h in range(4)}, 'swap_in_progress': self._swap_in_progress, + 'calibration': dict(self._calibration), + 'calibration_unload': dict(self._calibration_unload), 'aces': aces, } diff --git a/multiace/klipper/extras/filament_feed_ace.py b/multiace/klipper/extras/filament_feed_ace.py index 528330c5..de49b1a4 100644 --- a/multiace/klipper/extras/filament_feed_ace.py +++ b/multiace/klipper/extras/filament_feed_ace.py @@ -1809,6 +1809,7 @@ def _phase3_a_cb(self, response): if self.ace is not None: self.ace._ensure_active_ace_for_head(self.filament_ch[ch]) + self.ace._check_calibration_unload_cancel() if self.ace is not None \ and self.ace.head_uses_ace(self.filament_ch[ch]): @@ -1881,6 +1882,8 @@ def _phase3_a_cb(self, response): "for tip-form", _precool) self.gcode.run_script_from_command("M109 S%d\r\n" % (filament_unload_temp)) self.toolhead.wait_moves() + if self.ace is not None: + self.ace._check_calibration_unload_cancel() except: self.channel_error[ch] = FEED_ERR_HEAT raise @@ -1898,6 +1901,8 @@ def _phase3_a_cb(self, response): elif stage == FEED_UNLOAD_STAGE_DOING: try: + if self.ace is not None: + self.ace._check_calibration_unload_cancel() _stable_states = [ FEED_STA_UNLOAD_HEAT_FINISH, @@ -1930,6 +1935,7 @@ def _phase3_a_cb(self, response): int(filament_soft), self.toolhead.get_extruder().nozzle_diameter) self.toolhead.wait_moves() + self.ace._check_calibration_unload_cancel() except: self.channel_error[ch] = FEED_ERR_CUSTOM_GCODE raise ValueError('custom gcode error!') @@ -1992,6 +1998,7 @@ def _phase3_a_cb(self, response): ace=self.ace._disp(_gate_ace), slot=self.ace._disp(_ace_slot))) for unload_attempt in range(unload_max): + self.ace._check_calibration_unload_cancel() self.ace.wait_ace_ready() _usp = self.ace._retract_with_decoder_span( self.ace._active_device_index, _ace_slot, @@ -1999,6 +2006,7 @@ def _phase3_a_cb(self, response): _ace_slot, _short_retract, _retract_speed, head=head_idx)) self.ace.wait_ace_ready() + self.ace._check_calibration_unload_cancel() self._unload_dec_log( head_idx, _ace_slot, 'short', _short_retract, _usp, unload_attempt + 1) @@ -2047,6 +2055,7 @@ def _phase3_a_cb(self, response): except: logging.info("[feed][unload] forward probe failed") self.reactor.pause(self.reactor.monotonic() + FEED_UNLOAD_TRIGGER_SETTLE) + self.ace._check_calibration_unload_cancel() try: _pin = getattr(self.runout_sensor[ch], 'runout_buttun_state', None) @@ -2087,6 +2096,7 @@ def _phase3_a_cb(self, response): except: logging.info("[feed][unload] probe pull-back failed") try: + self.ace._check_calibration_unload_cancel() if precool_temp > 0: self.gcode.run_script_from_command("M104 S%d\r\n" % precool_temp) self.gcode.run_script_from_command( @@ -2103,12 +2113,14 @@ def _phase3_a_cb(self, response): int(filament_soft), self.toolhead.get_extruder().nozzle_diameter) self.toolhead.wait_moves() + self.ace._check_calibration_unload_cancel() self.gcode.run_script_from_command("M104 S%d\r\n" % probe_temp) except: logging.info("[feed][unload] toolhead unload retry failed") if unload_ok: _rest = _full_retract - _short_retract if _rest > 0: + self.ace._check_calibration_unload_cancel() self.ace.wait_ace_ready() _rsp = self.ace._retract_with_decoder_span( self.ace._active_device_index, _ace_slot, @@ -2116,6 +2128,7 @@ def _phase3_a_cb(self, response): _ace_slot, _rest, _retract_speed, head=head_idx)) self.ace.wait_ace_ready() + self.ace._check_calibration_unload_cancel() self._unload_dec_log( head_idx, _ace_slot, 'rest', _rest, _rsp, '-') diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index 54c0b98f..677e9b17 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -513,6 +513,7 @@ def _head_in_op(t: int) -> bool: language = sv_vars.get("ace__language", os.environ.get("MULTIACE_LANGUAGE", "en")) idx_base = _read_display_index_base() return { + 'calibration': ace.get('calibration') or {'state': 'idle'}, "ace_status": ace.get("status"), "ace_temp": ace.get("temp"), "printer_state": printer_state, @@ -569,6 +570,21 @@ class TipformUpdate(BaseModel): tables: dict[str, str] restart_klipper: bool = False +class CalibrationStart(BaseModel): + ace: int + slot: int + head: int + scope: str = 'ace' + +class CalibrationAction(BaseModel): + action: str + length: int | None = None + session_id: int | None = None + ace: int | None = None + slot: int | None = None + head: int | None = None + scope: str | None = None + class SnapshotSave(BaseModel): name: str description: str | None = None @@ -1416,6 +1432,118 @@ async def run_macro(req: MacroRequest) -> dict: detail='moonraker: %s' % (str(e) or type(e).__name__)) return {"script": script, "result": result} +def _raw_calibration(status: dict) -> dict: + ace = status.get('ace', {}) or {} + return ace.get('calibration') or {'state': 'idle', 'session_id': 0} + +async def _dispatch_calibration(script: str) -> dict: + try: + return await _mr_post('/printer/gcode/script', {'script': script}, + timeout=30.0) + except httpx.HTTPStatusError as e: + detail = e.response.text if e.response is not None else str(e) + raise HTTPException( + status_code=e.response.status_code if e.response is not None else 502, + detail=detail) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail='moonraker: %s' % e) + +@app.get('/api/calibration') +async def get_calibration() -> dict: + try: + status = await _query_state_gated() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail='moonraker: %s' % e) + return _raw_calibration(status) + +@app.post('/api/calibration/start') +async def start_calibration(payload: CalibrationStart) -> dict: + if not (0 <= payload.ace <= 3 and 0 <= payload.slot <= 3 + and 0 <= payload.head <= 3): + raise HTTPException(400, 'ace, slot and head must be 0-3') + scope = (payload.scope or 'ace').strip().lower() + if scope not in ('ace', 'slot'): + raise HTTPException(400, 'scope must be ace or slot') + script = ('ACE_CALIBRATION_START ACE=%d SLOT=%d HEAD=%d SCOPE=%s' + % (payload.ace, payload.slot, payload.head, scope)) + result = await _dispatch_calibration(script) + return {'ok': True, 'script': script, 'result': result} + +@app.post('/api/calibration/action') +async def calibration_action(payload: CalibrationAction) -> dict: + action = (payload.action or '').strip().lower() + try: + status = await _query_state_gated() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail='moonraker: %s' % e) + current = _raw_calibration(status) + if (payload.session_id is not None + and int(current.get('session_id', 0)) != payload.session_id): + raise HTTPException(409, 'calibration session changed; reload state') + if action == 'feed': + script = 'ACE_CALIBRATION_FEED' + elif action == 'retract': + length = int(payload.length or 0) + if length < 5 or length > 500: + raise HTTPException(400, 'retract length must be 5-500 mm') + script = 'ACE_CALIBRATION_RETRACT LENGTH=%d' % length + elif action == 'mark_swap': + script = 'ACE_CALIBRATION_MARK MARK=swap' + elif action == 'return_park': + script = 'ACE_CALIBRATION_RETURN' + elif action == 'verify_start': + script = 'ACE_CALIBRATION_VERIFY ACTION=start' + route = (payload.ace, payload.slot, payload.head) + if any(value is not None for value in route): + if not all(value is not None for value in route): + raise HTTPException(400, 'verify route requires ace, slot and head') + if not all(0 <= int(value) <= 3 for value in route): + raise HTTPException(400, 'ace, slot and head must be 0-3') + scope = (payload.scope or 'ace').strip().lower() + if scope not in ('ace', 'slot'): + raise HTTPException(400, 'scope must be ace or slot') + script += ' ACE=%d SLOT=%d HEAD=%d SCOPE=%s' % ( + payload.ace, payload.slot, payload.head, scope) + elif action == 'verify_continue': + script = 'ACE_CALIBRATION_VERIFY ACTION=continue' + elif action == 'verify_pause': + script = 'ACE_CALIBRATION_VERIFY ACTION=pause' + elif action == 'verify_resume': + script = 'ACE_CALIBRATION_VERIFY ACTION=resume' + elif action == 'verify_jog': + length = int(payload.length or 0) + if abs(length) not in (1, 2, 5, 10): + raise HTTPException( + 400, 'verification jog must be +/-1, 2, 5, or 10 mm') + script = 'ACE_CALIBRATION_VERIFY ACTION=jog LENGTH=%d' % length + elif action == 'cancel': + script = 'ACE_CALIBRATION_CANCEL' + elif action == 'reset': + script = 'ACE_CALIBRATION_RESET' + else: + raise HTTPException(400, 'unknown calibration action') + result = await _dispatch_calibration(script) + return {'ok': True, 'script': script, 'result': result} + +@app.post('/api/calibration/unload-cancel') +async def cancel_calibration_unload() -> dict: + """Cancel preparation out-of-band through a Klipper webhook. + + This deliberately does not dispatch G-code: a cancellation submitted to + the G-code queue cannot run until the blocking unload has already ended. + """ + try: + result = await _mr_post( + '/printer/multiace/calibration_unload_cancel', {}, timeout=5.0) + except httpx.HTTPStatusError as e: + detail = e.response.text if e.response is not None else str(e) + raise HTTPException( + status_code=e.response.status_code if e.response is not None else 502, + detail=detail) + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail='moonraker: %s' % e) + return {'ok': True, 'result': result} + def _extract_params(text: str) -> tuple[dict[str, str], dict[int, dict[str, str]]]: """Pull `key: value` pairs out of [ace] and per-ACE [ace N] sections. Returns (main_params, per_ace_params) where per_ace_params is a dict diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 0ea2f401..3ac0f92d 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -5,7 +5,7 @@ const WS_URL = (location.protocol === "https:" ? "wss://" : "ws://") const SCREEN = "/screen"; createApp({ setup() { - const _validTabs = new Set(["dashboard", "config"]); + const _validTabs = new Set(["dashboard", "calibration", "config"]); const _storedTab = localStorage.getItem("multiace.tab"); const _isPluginTab = (s) => typeof s === "string" && s.startsWith("plugin:"); const tab = ref( @@ -144,6 +144,7 @@ createApp({ bg_swap: {available: false, enabled_heads: [], busy: [], version: null}, pickup_cleaning: false, tipform: {available: false, mode: null, tables: []}, + calibration: {state: "idle", session_id: 0}, }); const loadError = ref(""); const notifications = ref([]); @@ -211,8 +212,11 @@ createApp({ state.head_ace = (s.head_ace && typeof s.head_ace === "object") ? s.head_ace : {}; state.dryer = s.dryer ?? null; state.swap_in_progress = !!s.swap_in_progress; + state.calibration = (s.calibration && typeof s.calibration === "object") + ? s.calibration : {state: "idle", session_id: 0}; state.aces = Array.isArray(s.aces) ? s.aces : []; state.toolheads = Array.isArray(s.toolheads) ? s.toolheads : []; + syncCalibrationDefaults(); state.wiring = Array.isArray(s.wiring) ? s.wiring : []; state.save_variables = s.save_variables || {}; state.bg_swap = (s.bg_swap && typeof s.bg_swap === "object") @@ -361,6 +365,481 @@ createApp({ _scheduleAdvance(); } function run(name, args) { return enqueue(name, args); } + + const calibrationUi = reactive({ + ace: 0, slot: 0, head: 0, scope: 'ace', + loadAllowance: 20, swapAllowance: 20, + baselinePrepared: false, routeConfirmed: false, + busy: '', cancelRequested: false, error: '', saved: false, + }); + const calibrationSelectionTouched = reactive({ + ace: false, slot: false, head: false, + }); + const connectedCalibrationAces = computed(() => + state.aces.filter(a => a && a.connected)); + const calibrationSelectedAce = computed(() => + state.aces.find(a => Number(a.idx) === Number(calibrationUi.ace)) || null); + const calibrationSelectedToolhead = computed(() => + state.toolheads.find( + h => Number(h.idx) === Number(calibrationUi.head)) || null); + const calibrationVerifyMoving = computed(() => + ['verifying_feed', 'verifying_splitter', 'verifying_return'] + .includes(state.calibration?.state)); + const calibrationMoving = computed(() => + ['feeding', 'retracting', 'returning'].includes(state.calibration?.state) + || calibrationVerifyMoving.value + || state.calibration?.state === 'verify_toolhead_adjusting'); + const calibrationRouteLocked = computed(() => { + const c = state.calibration || {}; + const position = Number(c.tip_position_mm); + const tipAwayFromPark = Number.isFinite(position) && position > 0; + const nonParkedState = [ + 'feeding', 'at_sensor', 'retract_ready', 'retracting', + 'swap_marked', 'returning', 'verifying_feed', 'verify_toolhead', + 'verify_toolhead_adjust', 'verify_toolhead_adjusting', + 'verifying_splitter', 'verify_splitter', 'verifying_return', + 'verify_paused', 'cancelled', 'failed', + ].includes(c.state); + return c.ace != null && c.slot != null && c.head != null + && (tipAwayFromPark || nonParkedState); + }); + const calibrationSelectedHeadSensorKnown = computed(() => + typeof calibrationSelectedToolhead.value?.filament_at_extruder === 'boolean'); + const calibrationSelectedHeadClear = computed(() => + calibrationSelectedToolhead.value?.filament_at_extruder === false); + const calibrationCanPrepare = computed(() => { + const ace = calibrationSelectedAce.value; + const toolhead = calibrationSelectedToolhead.value; + return !!ace && ace.connected + && !!toolhead && !toolhead.manual && !toolhead.feeder + && calibrationSelectedHeadSensorKnown.value + && state.mode !== 'normal' + && !['printing', 'paused'].includes(state.printer_state) + && !state.swap_in_progress + && !(state.bg_swap?.busy || []).length + && !calibrationMoving.value; + }); + const calibrationCanStart = computed(() => { + const ace = calibrationSelectedAce.value; + const toolhead = calibrationSelectedToolhead.value; + const slot = ace?.slots?.find( + s => Number(s.idx) === Number(calibrationUi.slot)); + return !!ace && ace.connected + && calibrationUi.baselinePrepared + && calibrationUi.routeConfirmed + && calibrationSelectedHeadClear.value + && state.mode !== 'normal' + && !['printing', 'paused'].includes(state.printer_state) + && !state.swap_in_progress + && !(state.bg_swap?.busy || []).length + && !!slot && slot.state === 'ready' + && toolhead?.filament_at_extruder === false + && !toolhead?.manual && !toolhead?.feeder + && !calibrationMoving.value; + }); + const calibrationCanVerifyConfigured = computed(() => { + const ace = calibrationSelectedAce.value; + const toolhead = calibrationSelectedToolhead.value; + const slot = ace?.slots?.find( + item => Number(item.idx) === Number(calibrationUi.slot)); + return ['idle', 'complete', 'verified'].includes(state.calibration?.state) + && calibrationRouteKnown.value + && !!ace && ace.connected + && !!slot && slot.state === 'ready' + && calibrationSelectedHeadClear.value + && !!toolhead && !toolhead.manual && !toolhead.feeder + && state.mode !== 'normal' + && !['printing', 'paused'].includes(state.printer_state) + && !state.swap_in_progress + && !(state.bg_swap?.busy || []).length + && !calibrationMoving.value; + }); + const calibrationProposed = computed(() => { + const c = state.calibration || {}; + return { + load: c.load_length_mm == null ? null + : Number(c.load_length_mm) + Math.max(0, Number(calibrationUi.loadAllowance) || 0), + swap: c.swap_retract_length_mm == null ? null + : Number(c.swap_retract_length_mm) + Math.max(0, Number(calibrationUi.swapAllowance) || 0), + retract: c.retract_length_mm == null ? null : Number(c.retract_length_mm), + }; + }); + const calibrationConfiguredRoute = computed(() => { + const ace = Number(calibrationUi.ace); + const slot = Number(calibrationUi.slot); + const aceCfg = configForm.perAce?.[ace] || {}; + const slotCfg = aceCfg.perSlot?.[slot] || {}; + const effective = (key) => { + for (const value of [slotCfg[key], aceCfg[key], configForm[key]]) { + if (value !== '' && value != null && Number.isFinite(Number(value)) + && Number(value) > 0) return Number(value); + } + return null; + }; + return { + load: effective('retract_length'), + feedLimit: effective('load_length'), + swap: effective('swap_retract_length'), + }; + }); + const calibrationSessionMatchesSelection = computed(() => { + const c = state.calibration || {}; + return Number(c.ace) === Number(calibrationUi.ace) + && Number(c.slot) === Number(calibrationUi.slot) + && Number(c.head) === Number(calibrationUi.head) + && String(c.scope || 'ace') === String(calibrationUi.scope || 'ace'); + }); + const calibrationRouteAnchors = computed(() => { + const c = state.calibration || {}; + const measuredLoad = !calibrationSessionMatchesSelection.value + || c.load_length_mm == null ? null : Number(c.load_length_mm); + const measuredSwap = !calibrationSessionMatchesSelection.value + || c.swap_retract_length_mm == null + ? null : Number(c.swap_retract_length_mm); + const load = Number.isFinite(measuredLoad) && measuredLoad > 0 + ? measuredLoad : calibrationConfiguredRoute.value.load; + const swap = Number.isFinite(measuredSwap) && measuredSwap > 0 + ? measuredSwap : calibrationConfiguredRoute.value.swap; + return {load, swap, source: measuredLoad > 0 && measuredSwap > 0 + ? 'measured' : 'configured'}; + }); + const calibrationRouteKnown = computed(() => { + const {load, swap} = calibrationRouteAnchors.value; + return Number.isFinite(load) && Number.isFinite(swap) + && load > 0 && swap > 0 && swap < load; + }); + const calibrationTipPositionMm = computed(() => { + if (!calibrationRouteKnown.value) return null; + const c = state.calibration || {}; + const load = calibrationRouteAnchors.value.load; + if (c.tip_position_mm != null && Number.isFinite(Number(c.tip_position_mm))) { + const fineLimit = Math.max(0, Number(c.verify_fine_limit_mm) || 50); + return Math.max(0, Math.min(load + fineLimit, Number(c.tip_position_mm))); + } + if (calibrationSelectedToolhead.value?.filament_at_extruder === true) return load; + if (['complete', 'verified'].includes(c.state)) return 0; + const slot = calibrationSelectedAce.value?.slots?.find( + item => Number(item.idx) === Number(calibrationUi.slot)); + if (slot?.state === 'ready' + && calibrationSelectedToolhead.value?.filament_at_extruder === false) return 0; + return null; + }); + const calibrationTipSegment = computed(() => { + const position = calibrationTipPositionMm.value; + if (position == null) return 'ace'; + const {load, swap} = calibrationRouteAnchors.value; + const splitter = load - swap; + return position <= splitter ? 'ace' : 'toolhead'; + }); + const calibrationTipSegmentPercent = computed(() => { + const position = calibrationTipPositionMm.value; + if (position == null) return 50; + const {load, swap} = calibrationRouteAnchors.value; + const splitter = load - swap; + const percent = position <= splitter + ? (splitter > 0 ? 100 * position / splitter : 0) + : (swap > 0 ? 100 * (position - splitter) / swap : 0); + return Math.max(0, Math.min(100, percent)); + }); + const calibrationTipLabel = computed(() => { + const c = state.calibration || {}; + const position = calibrationTipPositionMm.value; + if (!calibrationRouteKnown.value) return 'No configured calibration anchors for this route'; + if (position == null) return 'Configured route known · current tip position unavailable'; + if (c.state === 'verify_paused') { + return `Paused · estimated ${Math.round(position)} mm from ACE preload`; + } + if (c.state === 'verify_toolhead_adjust') { + const offset = Number(c.verify_fine_offset_mm) || 0; + return `Toolhead endpoint adjustment · ${offset >= 0 ? '+' : ''}${offset} mm`; + } + if (c.state === 'verify_toolhead_adjusting') { + return `Fine positioning · estimated ${Math.round(position)} mm from ACE preload`; + } + if (c.state === 'verify_toolhead') return 'Held at the toolhead sensor'; + if (c.state === 'verify_splitter') return 'Held at the calibrated splitter checkpoint'; + if (c.state === 'verified') return 'Verification complete · parked at ACE preload'; + if (c.state === 'complete') return 'Parked at the ACE preload reference'; + if (calibrationMoving.value) { + return `Moving · estimated ${Math.round(position)} mm from ACE preload`; + } + const source = calibrationRouteAnchors.value.source === 'configured' + ? 'Configured route' : 'Measured route'; + return `${source} · estimated tip ${Math.round(position)} mm from ACE preload`; + }); + + function calibrationResetPreparation() { + calibrationUi.baselinePrepared = false; + calibrationUi.routeConfirmed = false; + } + function calibrationSetScope(scope) { + if (calibrationRouteLocked.value || calibrationUi.busy + || !['ace', 'slot'].includes(scope)) return; + calibrationUi.scope = scope; + calibrationResetPreparation(); + } + function calibrationRouteChanged() { + if (!calibrationRouteLocked.value) calibrationResetPreparation(); + } + + function syncCalibrationDefaults() { + if (calibrationRouteLocked.value) { + const c = state.calibration || {}; + calibrationUi.ace = Number(c.ace); + calibrationUi.slot = Number(c.slot); + calibrationUi.head = Number(c.head); + calibrationUi.scope = c.scope === 'slot' ? 'slot' : 'ace'; + return; + } + const connected = state.aces.filter(ace => ace?.connected); + if (!connected.length) return; + let selected = connected.find( + ace => Number(ace.idx) === Number(calibrationUi.ace)); + if (!selected || (connected.length === 1 && !calibrationSelectionTouched.ace)) { + selected = connected[0]; + calibrationUi.ace = Number(selected.idx); + } + if (!calibrationSelectionTouched.slot) { + const ready = (selected.slots || []).find(slot => slot.state === 'ready'); + if (ready) calibrationUi.slot = Number(ready.idx); + } + if (state.mode === 'multi') { + calibrationUi.head = Number(calibrationUi.slot); + return; + } + if (connected.length !== 1) return; + const mappedHeads = (state.ace_heads || []).filter(head => + Number(state.head_ace?.[String(head)] ?? state.head_ace?.[head] ?? head) + === Number(selected.idx)); + const mapped = mappedHeads.find(head => { + const toolhead = state.toolheads.find( + item => Number(item.idx) === Number(head)); + return toolhead && !toolhead.manual && !toolhead.feeder; + }) ?? mappedHeads[0]; + if (mapped != null && (!calibrationSelectionTouched.head + || !mappedHeads.some(head => Number(head) === Number(calibrationUi.head)))) { + calibrationUi.head = Number(mapped); + } + } + async function calibrationPrepare() { + calibrationUi.busy = 'prepare'; + calibrationUi.cancelRequested = false; + calibrationUi.error = ''; + calibrationResetPreparation(); + try { + if (['cancelled', 'failed'].includes(state.calibration?.state)) { + const resetOk = await calibrationRequest('action', { + action: 'reset', + session_id: Number(state.calibration?.session_id || 0), + }, 'prepare'); + calibrationUi.busy = 'prepare'; + if (!resetOk) return; + } + if (calibrationSelectedHeadClear.value) return; + const ok = await run('ACE_UNLOAD_HEAD', { + HEAD: Number(calibrationUi.head), + ACE: Number(calibrationUi.ace), + SLOT: Number(calibrationUi.slot), + CALIBRATION_PREPARE: 1, + }); + await reloadState(); + if (calibrationUi.cancelRequested) { + throw new Error( + 'Preparation unload cancelled. Verify the filament position ' + + 'before establishing a new baseline.'); + } + if (!ok || !calibrationSelectedHeadClear.value) { + throw new Error( + 'Preparation did not produce a clear selected-toolhead baseline. ' + + 'Resolve the loaded or unknown sensor, then try again.'); + } + } catch (e) { + calibrationUi.error = String(e?.message || e); + } finally { + calibrationUi.busy = ''; + calibrationUi.cancelRequested = false; + } + } + async function calibrationConfirmBaseline() { + if (!calibrationCanPrepare.value || !calibrationSelectedHeadClear.value + || calibrationUi.busy) return; + calibrationUi.busy = 'confirm-baseline'; + calibrationUi.error = ''; + calibrationResetPreparation(); + try { + if (['cancelled', 'failed'].includes(state.calibration?.state)) { + const resetOk = await calibrationRequest('action', { + action: 'reset', + session_id: Number(state.calibration?.session_id || 0), + }, 'confirm-baseline'); + calibrationUi.busy = 'confirm-baseline'; + if (!resetOk) return; + } + await reloadState(); + if (!calibrationSelectedHeadClear.value) { + throw new Error( + 'The selected toolhead sensor is no longer clear. Unload the ' + + 'selected toolhead before confirming the starting point.'); + } + calibrationUi.baselinePrepared = true; + } catch (e) { + calibrationUi.error = String(e?.message || e); + } finally { + calibrationUi.busy = ''; + } + } + async function calibrationCancelPrepare() { + if (calibrationUi.busy !== 'prepare' || calibrationUi.cancelRequested) return; + calibrationUi.cancelRequested = true; + calibrationUi.error = ''; + try { + const r = await fetch(`${API}/calibration/unload-cancel`, {method: 'POST'}); + let j = null; + try { j = await r.json(); } catch (_) {} + if (!r.ok) throw new Error(j?.detail || `HTTP ${r.status}`); + } catch (e) { + calibrationUi.cancelRequested = false; + calibrationUi.error = String(e?.message || e); + } + } + function calibrationConfirmRoute() { + if (!calibrationUi.baselinePrepared || calibrationMoving.value) return; + calibrationUi.routeConfirmed = !calibrationUi.routeConfirmed; + calibrationUi.error = ''; + } + + function calibrationSelectAce(idx) { + if (calibrationRouteLocked.value || calibrationUi.busy) return; + calibrationSelectionTouched.ace = true; + calibrationUi.ace = Number(idx); + calibrationResetPreparation(); + if (state.mode === 'head') { + const h = (state.ace_heads || []).find( + head => Number(state.head_ace?.[String(head)] ?? state.head_ace?.[head] ?? head) + === Number(idx)); + if (h != null) calibrationUi.head = Number(h); + } + calibrationUi.error = ''; + calibrationUi.saved = false; + } + function calibrationSelectSlot(idx) { + if (calibrationRouteLocked.value || calibrationUi.busy) return; + calibrationSelectionTouched.slot = true; + calibrationUi.slot = Number(idx); + calibrationResetPreparation(); + if (state.mode === 'multi') calibrationUi.head = Number(idx); + calibrationUi.error = ''; + calibrationUi.saved = false; + } + function calibrationSelectHead() { + if (calibrationRouteLocked.value || calibrationUi.busy) return; + calibrationSelectionTouched.head = true; + calibrationRouteChanged(); + calibrationUi.error = ''; + calibrationUi.saved = false; + } + async function calibrationRequest(path, body, busy) { + calibrationUi.busy = busy; + calibrationUi.error = ''; + try { + const r = await fetch(`${API}/calibration/${path}`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body || {}), + }); + let j = null; + try { j = await r.json(); } catch (_) {} + if (!r.ok) throw new Error(j?.detail || `HTTP ${r.status}`); + await reloadState(); + return true; + } catch (e) { + let message = String(e?.message || e); + if (body?.action === 'verify_start' + && /complete calibration before verification/i.test(message)) { + message = 'Klipper is still running the previous calibration code. ' + + 'Restart Klipper or reboot the printer, then retry verification.'; + } + calibrationUi.error = message; + return false; + } finally { + calibrationUi.busy = ''; + } + } + async function calibrationStart() { + calibrationUi.saved = false; + const ok = await calibrationRequest('start', { + ace: Number(calibrationUi.ace), + slot: Number(calibrationUi.slot), + head: Number(calibrationUi.head), + scope: calibrationUi.scope, + }, 'start'); + if (ok) await calibrationAction('feed'); + } + async function calibrationVerifyConfigured() { + if (!calibrationCanVerifyConfigured.value) return false; + return calibrationRequest('action', { + action: 'verify_start', + session_id: Number(state.calibration?.session_id || 0), + ace: Number(calibrationUi.ace), + slot: Number(calibrationUi.slot), + head: Number(calibrationUi.head), + scope: calibrationUi.scope, + }, 'verify_start'); + } + async function calibrationAction(action, length=null) { + const ok = await calibrationRequest('action', { + action, length, + session_id: Number(state.calibration?.session_id || 0), + }, action); + if (ok && ['cancel', 'reset'].includes(action)) { + calibrationResetPreparation(); + } + return ok; + } + async function calibrationApply() { + const c = state.calibration || {}; + const v = calibrationProposed.value; + calibrationUi.error = ''; + if (!['complete', 'verified'].includes(c.state) + || v.load == null || v.swap == null + || v.retract == null) { + calibrationUi.error = 'Complete all calibration checkpoints first.'; + return; + } + if (v.retract < v.swap) { + calibrationUi.error = + 'Full retract must be at least the swap checkpoint plus its allowance.'; + return; + } + const ace = Number(c.ace); + const slot = Number(c.slot); + if (configForm.ace_device_count <= ace) { + configForm.ace_device_count = ace + 1; + } + _ensurePerAceLength(); + const target = configForm.perAce[ace]; + if (!target) { + calibrationUi.error = 'Config form has no matching ACE section.'; + return; + } + if (c.scope === 'slot') { + target.perSlot[slot].load_length = v.load; + target.perSlot[slot].swap_retract_length = v.swap; + target.perSlot[slot].retract_length = v.retract; + } else { + target.load_length = v.load; + target.swap_retract_length = v.swap; + target.retract_length = v.retract; + } + calibrationUi.busy = 'apply'; + await saveConfigForm(); + calibrationUi.busy = ''; + calibrationUi.saved = rebootNeeded.value; + if (!calibrationUi.saved) { + calibrationUi.error = configLog.value || 'Config save failed.'; + } + } + function clearAllErrors() { cmdQueue.value = cmdQueue.value.filter(it => it.status !== 'error'); cmdPaused.value = false; @@ -2876,7 +3355,9 @@ createApp({ await loadNotifications(); await refreshDebugState(); await refreshPlugins(); - if (state.mode === "normal" && tab.value === "dashboard") tab.value = "config"; + if (state.mode === "normal" && ["dashboard", "calibration"].includes(tab.value)) { + tab.value = "config"; + } wsConnect(); if (window.ResizeObserver && wiringContainerEl.value) { resizeObserver = new ResizeObserver(() => recomputeWiring()); @@ -2908,6 +3389,20 @@ createApp({ sourceLabel, tab, version, printerName, printerFw, connClass, connText, screenAvailable, state, loadError, run, macroLog, + calibrationUi, connectedCalibrationAces, calibrationSelectedAce, + calibrationSelectedToolhead, calibrationMoving, calibrationRouteLocked, + calibrationVerifyMoving, calibrationRouteKnown, calibrationRouteAnchors, + calibrationTipPositionMm, calibrationTipSegment, + calibrationTipSegmentPercent, calibrationTipLabel, + calibrationSelectedHeadSensorKnown, calibrationSelectedHeadClear, + calibrationCanPrepare, + calibrationCanStart, calibrationCanVerifyConfigured, calibrationProposed, + calibrationSetScope, calibrationRouteChanged, calibrationPrepare, + calibrationConfirmBaseline, + calibrationCancelPrepare, calibrationConfirmRoute, + calibrationSelectAce, calibrationSelectSlot, calibrationSelectHead, + calibrationStart, + calibrationVerifyConfigured, calibrationAction, calibrationApply, slotTitle, switchAce, loadSlot, loadFeederHead, slotLoadedInHead, loadAll, unloadHead, unloadAll, cancelUnloadAll, anyUnloading, setHeadManual, setHeadFeeder, setHeadAce, aceOptionsForHead, headAceOf, visibleAces, openHeadPicker, isToolheadOccupied, needsReload, toolheadOps, bgEnabledFor, setBgHead, setPickupCleaning, isPrinting, dryerCfg, dryStart, dryStop, dryOpenAce, toggleDryPanel, aceDrying, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index cb4d755c..dcd2bdca 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -20,6 +20,8 @@

mUlt1ACE

{{ macroLog }}
+
+

{{ calibrationUi.error }}

+
+
+

Bowden path calibration

+

Measure from a fresh ACE automatic-preload reference to the toolhead sensor, mark the splitter-clear point, then return by the same measured distance.

+
+ Session {{ state.calibration.state || 'idle' }} +
+ +
No connected ACE units are available.
+
+ + +
+
+ ACE {{ dispIdx(calibrationUi.ace) }}Slot {{ dispIdx(calibrationUi.slot) }} +
+ + {{ calibrationTipPositionMm == null ? '?' : '●' }} + +
+ Splittershared junction +
+ ● +
+ Toolhead T{{ dispIdx(calibrationUi.head) }} + {{ calibrationSelectedHeadClear ? 'sensor clear' + : calibrationSelectedToolhead?.filament_at_extruder === true + ? 'filament detected' : 'sensor unavailable' }} +
+ + + + + + + + + + + + + + +
+
+
+

{{ configLoadError }}

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

diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index 9bfe25fd..b00cc9ba 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -771,3 +771,204 @@ body { padding-bottom: 64px; } align-self: flex-start; padding: .2em .6em; font-size: .82em; } .tipform-pending { color: #e0a020; } + +/* Bowden calibration tab */ +.calibration-page h2 { margin: .2rem 0 .3rem; font-size: 1.35rem; } +.calibration-heading { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 1rem; margin-bottom: 1rem; +} +.calibration-heading p { margin: 0; } +.calibration-layout { + display: grid; grid-template-columns: 310px minmax(0, 1fr); + gap: 1rem; align-items: start; +} +.calibration-sidebar, .calibration-workspace { + background: var(--card); border: 1px solid var(--border); + border-radius: 6px; padding: .9rem; +} +.calibration-sidebar h3:first-child { margin-top: 0; } +.calibration-ace-card { + width: 100%; display: flex; justify-content: space-between; + gap: .6rem; align-items: flex-start; text-align: left; + background: #1b1b1b; color: var(--fg); border: 1px solid var(--border); + border-radius: 5px; padding: .65rem; margin-bottom: .45rem; + cursor: pointer; +} +.calibration-ace-card:hover:not(:disabled), +.calibration-ace-card.selected { border-color: var(--accent); } +.calibration-ace-card.selected { background: rgba(21,189,198,.10); } +.calibration-ace-card span:first-child { display: flex; flex-direction: column; gap: .12rem; } +.calibration-ace-card small { color: var(--muted); font-size: .7rem; } +.calibration-ready { color: var(--ok); font-size: .72rem; white-space: nowrap; } +.calibration-scope { display: flex; margin-bottom: .8rem; } +.calibration-scope > button { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: .12rem; padding: .5rem .35rem; font-size: .78rem; +} +.calibration-scope small { color: inherit; opacity: .72; font-size: .62rem; } +.calibration-slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: .3rem; } +.calibration-slots button { + display: flex; flex-direction: column; gap: .1rem; align-items: center; + background: #191919; color: var(--muted); border: 1px solid var(--border); + border-radius: 4px; padding: .4rem .15rem; cursor: pointer; +} +.calibration-slots button.active { color: var(--accent); border-color: var(--accent); } +.calibration-slots button:disabled { opacity: .35; cursor: not-allowed; } +.calibration-slots small { font-size: .62rem; } +.calibration-head-select { + display: flex; flex-direction: column; gap: .3rem; + color: var(--muted); font-size: .75rem; margin-top: .8rem; +} +.calibration-head-select select { + background: #111; color: var(--fg); border: 1px solid var(--border); + border-radius: 4px; padding: .4rem; +} +.calibration-confidence { + display: flex; flex-direction: column; gap: .2rem; margin-top: .8rem; + padding: .6rem; border-radius: 5px; border: 1px solid #2b666a; + background: #163033; color: #a9e8eb; font-size: .72rem; line-height: 1.4; +} +.calibration-confidence.estimate { + border-color: #735429; background: #342815; color: #ffd59a; +} +.calibration-workspace { position: relative; min-height: 480px; } +.calibration-route { + display: grid; grid-template-columns: auto 1fr auto 1fr auto; + gap: .55rem; align-items: center; padding: .7rem; + background: #191919; border: 1px solid var(--border); + border-radius: 5px; margin-bottom: 1rem; +} +.calibration-route span { + display: flex; flex-direction: column; gap: .1rem; min-width: 92px; + text-align: center; padding: .4rem; border: 1px solid var(--border); + border-radius: 4px; color: var(--muted); font-size: .68rem; +} +.calibration-route strong { color: var(--accent); font-size: .77rem; } +.calibration-route-tube { + position: relative; height: 3px; background: #484848; + border-radius: 999px; +} +.calibration-tip-marker { + position: absolute; left: var(--tip, 50%); top: 50%; z-index: 1; + display: grid; place-items: center; + width: 24px; height: 24px; border-radius: 50%; + color: #101010; background: var(--accent); border: 2px solid #111; + transform: translate(-50%, -50%); transition: left .12s linear, top .12s linear; + font-size: .8rem; font-weight: 700; +} +.calibration-route.moving .calibration-tip-marker { + animation: calibration-tip-pulse 1s ease-in-out infinite; +} +.calibration-route.paused .calibration-tip-marker { + background: var(--warn); animation: none; +} +.calibration-route.unknown .calibration-tip-marker { + color: #ffe0a8; background: #3b3326; border-color: #735429; + animation: none; +} +@keyframes calibration-tip-pulse { + 50% { box-shadow: 0 0 0 6px rgba(60, 201, 206, .15); } +} +.calibration-checks { display: grid; grid-template-columns: 1fr 1fr; gap: .45rem; } +.calibration-checks div { + padding: .55rem; border: 1px solid var(--border); border-radius: 4px; + color: var(--muted); font-size: .78rem; +} +.calibration-checks div::before { content: '○'; margin-right: .4rem; } +.calibration-checks div.ok { color: #b6f0bd; border-color: #2d7137; background: #17331c; } +.calibration-checks div.ok::before { content: '✓'; color: var(--ok); } +.calibration-quick-verify { + display: flex; align-items: center; justify-content: space-between; gap: 1rem; + margin-bottom: 1rem; padding: .75rem; + border: 1px solid #735429; border-radius: 5px; + background: #342815; +} +.calibration-quick-verify.ready { border-color: #2d7137; background: #17331c; } +.calibration-quick-verify h3 { margin: 0 0 .25rem; } +.calibration-quick-verify p { margin: 0; color: var(--muted); font-size: .76rem; line-height: 1.4; } +.calibration-quick-verify button { flex: 0 0 auto; } +.calibration-prep-next { + margin-top: 1rem; padding-top: .8rem; border-top: 1px solid var(--border); + transition: opacity .15s ease; +} +.calibration-prep-next.locked { opacity: .5; } +.calibration-confirm-row { margin: .7rem 0 0; } +.calibration-confirm-row > button { flex: 1; } +.calibration-warning { + padding: .65rem; color: #ffd6a0; background: var(--warn-bg); + border: 1px solid #765521; border-radius: 4px; font-size: .78rem; + line-height: 1.45; +} +.calibration-metrics { + display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; + margin: .8rem 0; +} +.calibration-metrics div { + display: flex; flex-direction: column; gap: .2rem; + background: #191919; border: 1px solid var(--border); + border-radius: 5px; padding: .65rem; +} +.calibration-metrics small { color: var(--muted); text-transform: uppercase; font-size: .65rem; } +.calibration-metrics strong { font-size: 1.05rem; } +.calibration-jogs { display: flex; flex-wrap: wrap; gap: .4rem; margin: .8rem 0; } +.calibration-fine-position { + display: grid; gap: .65rem; margin: .8rem 0; padding: .75rem; + border: 1px solid #735429; border-radius: 5px; + background: #342815; +} +.calibration-fine-head, .calibration-fine-row { + display: flex; align-items: center; justify-content: space-between; + gap: .75rem; +} +.calibration-fine-head span, .calibration-fine-row > span { + color: var(--muted); font-size: .72rem; +} +.calibration-fine-position .calibration-jogs { margin: 0; } +.calibration-jogs button, .calibration-page .row button { + background: #2a2a2a; color: var(--fg); border: 1px solid var(--border); + border-radius: 4px; padding: .45rem .75rem; cursor: pointer; +} +.calibration-page .row button.primary { + background: var(--accent); color: var(--accent-fg); border-color: var(--accent); + font-weight: 600; +} +.calibration-page button:disabled { opacity: .4; cursor: not-allowed; } +.calibration-stop { + position: absolute; right: .9rem; bottom: .9rem; + background: var(--error-bg); color: var(--error); border: 1px solid var(--error); + border-radius: 4px; padding: .5rem .8rem; cursor: pointer; +} +.calibration-allowances { display: flex; flex-wrap: wrap; gap: .7rem; } +.calibration-allowances label { color: var(--muted); font-size: .76rem; } +.calibration-allowances input { + width: 5rem; margin-left: .3rem; background: #111; color: var(--fg); + border: 1px solid var(--border); border-radius: 4px; padding: .3rem; +} +.calibration-review { width: 100%; border-collapse: collapse; margin: .8rem 0; font-size: .78rem; } +.calibration-review th, .calibration-review td { + text-align: left; padding: .45rem; border-bottom: 1px solid var(--border); +} +.calibration-review th { color: var(--muted); font-weight: 500; } +.calibration-success { + color: #b6f0bd; background: #17331c; border: 1px solid #2d7137; + border-radius: 4px; padding: .6rem; +} +@media (max-width: 820px) { + .calibration-layout { grid-template-columns: 1fr; } + .calibration-workspace { min-height: 0; } +} +@media (max-width: 560px) { + .calibration-heading { display: block; } + .calibration-heading .pill { margin-top: .5rem; } + .calibration-route { grid-template-columns: 1fr; } + .calibration-route-tube { width: 3px; height: 24px; margin: 0 auto; } + .calibration-tip-marker { + left: 50%; top: var(--tip, 50%); + } + .calibration-checks, .calibration-metrics { grid-template-columns: 1fr; } + .calibration-fine-head, .calibration-fine-row { + align-items: flex-start; flex-direction: column; + } + .calibration-quick-verify { align-items: stretch; flex-direction: column; } +} diff --git a/multiace/web/mockups/calibration-tab.html b/multiace/web/mockups/calibration-tab.html new file mode 100644 index 00000000..4f2c55f2 --- /dev/null +++ b/multiace/web/mockups/calibration-tab.html @@ -0,0 +1,1197 @@ + + + + + + + mUlt1ACE · Calibration mockup + + + +
+

mUlt1ACE v0.96 · U1 Workshop

+ + + ● Connected +
+ +
Interactive concept mockup — values are simulated and no printer commands are sent.
+
Calibration values saved to ace.cfg. Restart the printer to apply them.
+ +
+
+
+

Bowden path calibration

+

Measure from a fresh ACE automatic-preload reference to the toolhead sensor, mark the splitter-clear point, then return by the same measured distance.

+
+
+ Printer idle + Operations clear + Mode head +
+
+ +
+ + +
+
+
1Clean slate
+
2Prepare route
+
3Measure load
+
4Clear splitter
+
5Return to preload
+
6Validate
+
7Apply
+
+ +
+
+
+

Ready to calibrate

+

Confirm the path and insert filament into the selected ACE slot. The ACE will perform its own short internal preload.

+
+ READY +
+ +
+
ACE 1 · S4preloaded
+
●
+
4 → 1splitter
+
+
Toolhead T4sensor clear
+
+ +
+
+
Saved calibration is ready to verifyThe selected slot is parked at ACE preload and its mapped toolhead sensor is clear.
+ +
+
+
✓ Printer is idle required
+
✓ No load, unload, swap, or background operation required
+
✓ Selected T4 toolhead sensor sensor clear
+
○ Clean starting point awaits confirmation
+
+
Clean starting point: unload only the selected toolhead and remove loose filament from its output tube or splitter path. Other loaded toolheads are left untouched.
+
+ + + +
+
+ + + + + + + + + + +
+
+
+
+ + + + From 21ed27c88624974c9d7f00307a0644bd5db88f29 Mon Sep 17 00:00:00 2001 From: Physics G Date: Wed, 5 Aug 2026 10:13:45 +0200 Subject: [PATCH 04/26] Improve UX --- CALIBRATION_TAB.md | 1 + multiace/web/frontend/app.js | 110 ++++- multiace/web/frontend/index.html | 428 ++++++++++++++---- multiace/web/frontend/style.css | 143 ++++++ .../web/mockups/calibration-guided-tabs.html | 413 +++++++++++++++++ 5 files changed, 1009 insertions(+), 86 deletions(-) create mode 100644 multiace/web/mockups/calibration-guided-tabs.html diff --git a/CALIBRATION_TAB.md b/CALIBRATION_TAB.md index 8c89f52d..9ba86b3c 100644 --- a/CALIBRATION_TAB.md +++ b/CALIBRATION_TAB.md @@ -4,6 +4,7 @@ Status: Calibration and verification implementation present in the working tree Target UI: `multiace/web/frontend` Target firmware: `multiace/klipper/extras/ace.py` Mockup: [`multiace/web/mockups/calibration-tab.html`](multiace/web/mockups/calibration-tab.html) +Guided sub-tab UX mockup: [`multiace/web/mockups/calibration-guided-tabs.html`](multiace/web/mockups/calibration-guided-tabs.html) Engineering reference: [`FILAMENT_MOTION_FEATURE_REFERENCE.md`](FILAMENT_MOTION_FEATURE_REFERENCE.md) ## Current implementation diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 3ac0f92d..d483e1f8 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -370,6 +370,7 @@ createApp({ ace: 0, slot: 0, head: 0, scope: 'ace', loadAllowance: 20, swapAllowance: 20, baselinePrepared: false, routeConfirmed: false, + panel: 'setup', panelTouched: false, recalibrate: false, busy: '', cancelRequested: false, error: '', saved: false, }); const calibrationSelectionTouched = reactive({ @@ -382,6 +383,9 @@ createApp({ const calibrationSelectedToolhead = computed(() => state.toolheads.find( h => Number(h.idx) === Number(calibrationUi.head)) || null); + const calibrationSelectedSlot = computed(() => + calibrationSelectedAce.value?.slots?.find( + slot => Number(slot.idx) === Number(calibrationUi.slot)) || null); const calibrationVerifyMoving = computed(() => ['verifying_feed', 'verifying_splitter', 'verifying_return'] .includes(state.calibration?.state)); @@ -440,9 +444,9 @@ createApp({ const calibrationCanVerifyConfigured = computed(() => { const ace = calibrationSelectedAce.value; const toolhead = calibrationSelectedToolhead.value; - const slot = ace?.slots?.find( - item => Number(item.idx) === Number(calibrationUi.slot)); - return ['idle', 'complete', 'verified'].includes(state.calibration?.state) + const slot = calibrationSelectedSlot.value; + return ['idle', 'cancelled', 'failed', 'complete', 'verified'] + .includes(state.calibration?.state) && calibrationRouteKnown.value && !!ace && ace.connected && !!slot && slot.state === 'ready' @@ -508,6 +512,52 @@ createApp({ return Number.isFinite(load) && Number.isFinite(swap) && load > 0 && swap > 0 && swap < load; }); + const calibrationVerifyBlockedReason = computed(() => { + if (!calibrationRouteKnown.value) return 'Save a calibration for this route first.'; + if (!calibrationSelectedAce.value?.connected) return 'The selected ACE is not connected.'; + if (calibrationSelectedSlot.value?.state !== 'ready') { + return 'Return the selected slot to its automatic-preload position.'; + } + if (!calibrationSelectedHeadSensorKnown.value) { + return 'The mapped toolhead sensor is unavailable.'; + } + if (!calibrationSelectedHeadClear.value) return 'Unload the mapped toolhead first.'; + if (calibrationSelectedToolhead.value?.manual + || calibrationSelectedToolhead.value?.feeder) { + return 'Select an ACE-driven toolhead.'; + } + if (state.mode === 'normal') return 'Switch multiACE out of normal mode.'; + if (['printing', 'paused'].includes(state.printer_state)) { + return 'Wait until the printer is idle.'; + } + if (state.swap_in_progress || (state.bg_swap?.busy || []).length) { + return 'Wait for the active filament operation to finish.'; + } + if (!['idle', 'cancelled', 'failed', 'complete', 'verified'] + .includes(state.calibration?.state)) { + return 'Finish or cancel the current calibration first.'; + } + return ''; + }); + const calibrationTabStatus = computed(() => { + const c = state.calibration || {}; + const measuring = ['prepared', 'feeding', 'at_sensor', 'retract_ready', + 'retracting', 'swap_marked', 'returning'].includes(c.state); + const verifying = ['verifying_feed', 'verify_toolhead', + 'verify_toolhead_adjust', 'verify_toolhead_adjusting', + 'verifying_splitter', 'verify_splitter', 'verifying_return', + 'verify_paused'].includes(c.state); + return { + setup: calibrationRouteKnown.value ? 'Route ready' : 'Choose route', + calibrate: measuring ? 'In progress' + : calibrationRouteKnown.value ? 'Values available' : 'Required', + verify: verifying ? 'In progress' + : c.state === 'verified' ? 'Passed' + : calibrationRouteKnown.value ? 'Available' : 'Needs values', + results: c.state === 'verified' ? 'Passed' + : c.state === 'complete' ? 'Review values' : 'No new result', + }; + }); const calibrationTipPositionMm = computed(() => { if (!calibrationRouteKnown.value) return null; const c = state.calibration || {}; @@ -568,18 +618,57 @@ createApp({ return `${source} · estimated tip ${Math.round(position)} mm from ACE preload`; }); + watch(() => state.calibration?.state, (next, previous) => { + const verifying = ['verifying_feed', 'verify_toolhead', + 'verify_toolhead_adjust', 'verify_toolhead_adjusting', + 'verifying_splitter', 'verify_splitter', 'verifying_return', + 'verify_paused']; + const measuring = ['prepared', 'feeding', 'at_sensor', 'retract_ready', + 'retracting', 'swap_marked', 'returning']; + if (verifying.includes(next)) { + calibrationSetPanel('verify', false); + } else if (measuring.includes(next)) { + calibrationSetPanel('calibrate', false); + } else if (next === 'verified' && previous && previous !== 'verified') { + calibrationSetPanel('results', false); + } else if (next === 'complete' && previous === 'returning') { + calibrationSetPanel('results', false); + } + }); + function calibrationResetPreparation() { calibrationUi.baselinePrepared = false; calibrationUi.routeConfirmed = false; } + function calibrationSetPanel(panel, touched=true) { + if (!['setup', 'calibrate', 'verify', 'results'].includes(panel)) return; + calibrationUi.panel = panel; + if (touched) calibrationUi.panelTouched = true; + } + function calibrationContinueRoute() { + calibrationSetPanel(calibrationRouteKnown.value ? 'verify' : 'calibrate'); + } + function calibrationBeginRecalibration() { + calibrationUi.recalibrate = true; + calibrationUi.error = ''; + calibrationSetPanel('calibrate'); + } + function calibrationKeepSaved() { + calibrationUi.recalibrate = false; + calibrationUi.error = ''; + } function calibrationSetScope(scope) { if (calibrationRouteLocked.value || calibrationUi.busy || !['ace', 'slot'].includes(scope)) return; calibrationUi.scope = scope; calibrationResetPreparation(); + calibrationUi.recalibrate = false; } function calibrationRouteChanged() { - if (!calibrationRouteLocked.value) calibrationResetPreparation(); + if (!calibrationRouteLocked.value) { + calibrationResetPreparation(); + calibrationUi.recalibrate = false; + } } function syncCalibrationDefaults() { @@ -721,6 +810,7 @@ createApp({ } calibrationUi.error = ''; calibrationUi.saved = false; + calibrationUi.recalibrate = false; } function calibrationSelectSlot(idx) { if (calibrationRouteLocked.value || calibrationUi.busy) return; @@ -730,6 +820,7 @@ createApp({ if (state.mode === 'multi') calibrationUi.head = Number(idx); calibrationUi.error = ''; calibrationUi.saved = false; + calibrationUi.recalibrate = false; } function calibrationSelectHead() { if (calibrationRouteLocked.value || calibrationUi.busy) return; @@ -767,6 +858,7 @@ createApp({ } async function calibrationStart() { calibrationUi.saved = false; + calibrationSetPanel('calibrate', false); const ok = await calibrationRequest('start', { ace: Number(calibrationUi.ace), slot: Number(calibrationUi.slot), @@ -777,6 +869,7 @@ createApp({ } async function calibrationVerifyConfigured() { if (!calibrationCanVerifyConfigured.value) return false; + calibrationSetPanel('verify', false); return calibrationRequest('action', { action: 'verify_start', session_id: Number(state.calibration?.session_id || 0), @@ -1901,6 +1994,11 @@ createApp({ } } watch(() => configForm.ace_device_count, _ensurePerAceLength, {immediate: true}); + watch([() => tab.value, calibrationRouteKnown], ([activePage, routeKnown]) => { + if (activePage !== 'calibration' || calibrationUi.panelTouched + || state.calibration?.state !== 'idle') return; + calibrationSetPanel(routeKnown ? 'verify' : 'setup', false); + }, {immediate: true}); // True after a config save, which needs a full printer restart to take // effect (a bare Klipper restart misses USB/serial + PAXX boot-script // changes). Drives the prominent top reboot banner; cleared once a restart @@ -3390,13 +3488,17 @@ createApp({ tab, version, printerName, printerFw, connClass, connText, screenAvailable, state, loadError, run, macroLog, calibrationUi, connectedCalibrationAces, calibrationSelectedAce, + calibrationSelectedSlot, calibrationSelectedToolhead, calibrationMoving, calibrationRouteLocked, calibrationVerifyMoving, calibrationRouteKnown, calibrationRouteAnchors, + calibrationVerifyBlockedReason, calibrationTabStatus, calibrationTipPositionMm, calibrationTipSegment, calibrationTipSegmentPercent, calibrationTipLabel, calibrationSelectedHeadSensorKnown, calibrationSelectedHeadClear, calibrationCanPrepare, calibrationCanStart, calibrationCanVerifyConfigured, calibrationProposed, + calibrationSetPanel, calibrationContinueRoute, + calibrationBeginRecalibration, calibrationKeepSaved, calibrationSetScope, calibrationRouteChanged, calibrationPrepare, calibrationConfirmBaseline, calibrationCancelPrepare, calibrationConfirmRoute, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index dcd2bdca..f0b85f33 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -291,75 +291,27 @@

{{ t('ui.dashboard.snapshots') }}

{{ calibrationUi.error }}

-

Bowden path calibration

-

Measure from a fresh ACE automatic-preload reference to the toolhead sensor, mark the splitter-clear point, then return by the same measured distance.

+

Filament path

+

Set up, calibrate, and verify one ACE route at a time.

Session {{ state.calibration.state || 'idle' }}
No connected ACE units are available.
-
- - -
+
+ + Splitter anchor {{ calibrationRouteAnchors.load - calibrationRouteAnchors.swap }} mm + · Toolhead {{ calibrationRouteAnchors.load }} mm + + Route anchors are not calibrated yet. + {{ calibrationUi.scope === 'slot' + ? 'Slot ' + dispIdx(calibrationUi.slot) + ' override' + : 'Entire ACE defaults' }} +
+ +
+
+ +
+
+
+
+
+ Setup +

Choose one physical route

+

The selected ACE, Bowden path, and mapped toolhead stay visible throughout calibration and verification.

+
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ {{ calibrationUi.scope === 'ace' + ? 'Shared ACE values measured through' : 'Per-slot override' }} + ACE {{ dispIdx(calibrationUi.ace) }} · Slot + {{ dispIdx(calibrationUi.slot) }} → T{{ dispIdx(calibrationUi.head) }} +
+
+ +

+ Route locked to ACE {{ dispIdx(state.calibration.ace) }} · Slot + {{ dispIdx(state.calibration.slot) }} while the tip is away from + preload. Return or clear this route before selecting another spool. +

+ +
+
Printer is idle
+
No other route is moving
+
+ T{{ dispIdx(calibrationUi.head) }} sensor + {{ calibrationSelectedHeadClear ? 'is clear' : 'needs attention' }} +
+
+ Slot {{ dispIdx(calibrationUi.slot) }} + {{ calibrationSelectedSlot?.state === 'ready' + ? 'is parked at preload' : 'is not ready' }} +
+
+
+ {{ calibrationSelectedAce?.protocol === 'v2' + ? 'Encoder observed' : 'Estimated distance' }} + + Decoder movement is diagnostic; configured millimetres use the bounded commanded distance. + + ACE v1 has no decoder telemetry. Repeat and visually verify before applying. +
+ +
+ +
+
+ +
- @@ -1487,6 +1572,11 @@

{{ calibrationResultCase +

+

{{ calibrationClearBlocked.why }}

@@ -3115,6 +3212,47 @@

{{ confirmDialog.title }}

+ +
+ +

diff --git a/multiace/web/frontend/style-new.css b/multiace/web/frontend/style-new.css index 8e3045f6..03e216fa 100644 --- a/multiace/web/frontend/style-new.css +++ b/multiace/web/frontend/style-new.css @@ -1620,6 +1620,38 @@ html[data-theme="new"] .calibration-review input { height: 30px; border-radius: 0; color: var(--fg); background: var(--nt-input); border: 1px solid var(--nt-ctl); } html[data-theme="new"] .calibration-review input:focus { outline: none; border-color: var(--accent); } + +/* saved values, clear, undo bar and the clear dialog */ +html[data-theme="new"] .calibration-saved-values { + padding: 14px 16px; border-radius: 0; background: var(--nt-bg2); border-color: var(--border); +} +html[data-theme="new"] .calibration-saved-head { font-size: 12px; } +html[data-theme="new"] .calibration-badge { border-radius: 0; font-size: 11.5px; border-color: var(--nt-ctl); } +html[data-theme="new"] .calibration-badge.own { color: var(--accent); border-color: var(--accent); } +html[data-theme="new"] .calibration-badge.shared { color: var(--nt-ok); border-color: var(--nt-ok); } +html[data-theme="new"] .calibration-badge.none { color: var(--nt-warn-fg); border-color: var(--nt-warn); } +html[data-theme="new"] .calibration-clear-table { font-size: 13px; } +html[data-theme="new"] .calibration-clear-table th { font-size: 12px; font-weight: 400; color: var(--nt-label); } +html[data-theme="new"] .calibration-clear-table th, +html[data-theme="new"] .calibration-clear-table td { padding: 8px 10px; border-bottom-color: var(--nt-line); } +html[data-theme="new"] .calibration-clear-table td small { font-size: 11px; color: var(--nt-label); } +html[data-theme="new"] .calibration-clear-table tr.unset td { color: var(--nt-disabled); } +html[data-theme="new"] .calibration-clear-table td.changed { color: var(--nt-warn-fg); } +html[data-theme="new"] .calibration-clear-reason { font-size: 12px; } +html[data-theme="new"] .calibration-clear-reason.blocked { color: var(--nt-warn-fg); } +html[data-theme="new"] .calibration-undo { + border-radius: 0; font-size: 12.5px; background: var(--nt-bg2); + border-color: var(--border); border-left: 1px solid var(--border); + box-shadow: inset 2px 0 0 var(--nt-warn-hi); +} +html[data-theme="new"] .calibration-undo.done { box-shadow: inset 2px 0 0 var(--nt-ok); } +html[data-theme="new"] .nt-confirm.calibration-clear-dialog { width: 500px; } +html[data-theme="new"] .nt-confirm > .calibration-clear-table { margin: 14px 18px 0; width: calc(100% - 36px); } +html[data-theme="new"] .nt-confirm > .calibration-warning, +html[data-theme="new"] .nt-confirm > .calibration-notice { margin: 12px 18px 0; } +html[data-theme="new"] .nt-confirm .nt-dlg-foot .nt-main.calibration-clear-confirm { + color: var(--nt-on-color); background: var(--nt-danger); border-color: var(--nt-danger); +} @media (max-width: 760px) { html[data-theme="new"] .calibration-workflow-body { padding: 14px 12px; } html[data-theme="new"] .calibration-steps { gap: 8px; } @@ -1634,6 +1666,8 @@ html[data-theme="new"] .calibration-review input:focus { outline: none; border-c html[data-theme="new"] .calibration-review th, html[data-theme="new"] .calibration-review td { padding: 8px 6px; } html[data-theme="new"] .calibration-page .row button.primary { flex: 1 1 100%; } + html[data-theme="new"] .calibration-clear-table th, + html[data-theme="new"] .calibration-clear-table td { padding: 7px 5px; } } /* ---- Light shade: surfaces the new design inherits from style.css ---- diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index 256d7ffe..4ae4ed71 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -2057,6 +2057,88 @@ body:has(.panel) .overlay .dialog { } .calibration-success strong { display: block; } .calibration-panel p.muted { margin: .6rem 0; font-size: .78rem; line-height: 1.45; } +/* Saved values of the route on Setup, with the scope and Clear. */ +.calibration-saved-values { + margin-top: 1rem; padding: .75rem; + background: var(--bg); border: 1px solid var(--border); border-radius: 5px; +} +.calibration-saved-head { + display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; + margin-bottom: .5rem; color: var(--muted); font-size: .72rem; +} +.calibration-badge { + padding: .18rem .5rem; border: 1px solid var(--border); border-radius: 999px; + color: var(--muted); font-size: .68rem; white-space: nowrap; +} +.calibration-badge.own { color: var(--accent); border-color: var(--accent); } +.calibration-badge.shared { color: var(--ok); border-color: var(--ok); } +.calibration-badge.none { color: var(--warn); border-color: var(--warn); } +.calibration-clear-table { + width: 100%; border-collapse: collapse; font-size: .76rem; + font-variant-numeric: tabular-nums; +} +.calibration-clear-table th, .calibration-clear-table td { + padding: .38rem .4rem; text-align: right; + border-bottom: 1px solid var(--border); +} +.calibration-clear-table th:first-child, +.calibration-clear-table td:first-child { text-align: left; } +.calibration-clear-table th { color: var(--muted); font-weight: 500; } +.calibration-clear-table tr:last-child td { border-bottom: 0; } +.calibration-clear-table td small { + display: block; color: var(--muted); font-size: .66rem; +} +.calibration-clear-table tr.unset td { color: var(--muted); } +.calibration-clear-table tr.in-use td:first-child { color: var(--fg); font-weight: 600; } +.calibration-clear-table .in-use-tag { + margin-left: .3rem; color: var(--accent); font-size: .64rem; font-weight: 500; +} +.calibration-clear-table td.changed { color: var(--warn); font-weight: 600; } +.calibration-saved-values .calibration-selector-block { margin-top: .7rem; } +.calibration-clear-row { + display: flex; flex-wrap: wrap; align-items: stretch; gap: .5rem; + margin-top: .7rem; +} +.calibration-saved-values .calibration-selector-block + .calibration-clear-row { margin-top: 0; } +.calibration-clear-row .calibration-option-grid { flex: 1 1 260px; } +.calibration-clear-row .calibration-route-option { justify-content: center; } +.calibration-page .calibration-clear-action { flex: 0 0 auto; margin: 0; } +.calibration-page .calibration-clear-action button { height: 100%; } +.calibration-clear-reason { margin: .45rem 0 0; color: var(--muted); font-size: .72rem; } +.calibration-clear-reason::before { content: '\24D8\00A0'; } +.calibration-clear-reason.blocked { color: var(--warn); } +/* Undo bar after a clear. */ +.calibration-undo { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: .5rem .8rem; margin: 0 0 1rem; padding: .65rem .8rem; + color: var(--muted); background: var(--card); + border: 1px solid var(--border); border-left: 3px solid var(--warn); + border-radius: 5px; font-size: .78rem; line-height: 1.45; +} +.calibration-undo.done { border-left-color: var(--ok); } +.calibration-undo strong { display: block; color: var(--fg); } +.calibration-undo > div { min-width: 0; flex: 1 1 220px; } +.calibration-page .calibration-undo.row { margin: 0 0 1rem; } +/* Clear confirm dialog. */ +.dialog.calibration-clear-dialog { width: min(500px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; } +.calibration-clear-dialog .calibration-clear-table { margin: .8rem 0; } +.calibration-clear-dialog .calibration-warning, +.calibration-clear-dialog .calibration-notice { margin: .6rem 0 0; font-size: .78rem; } +.calibration-clear-dialog .calibration-notice strong { display: inline; } +/* Destructive confirm: the existing danger colour, filled. */ +.calibration-clear-dialog button.calibration-clear-confirm { + color: #fff; background: var(--danger); border-color: var(--danger); font-weight: 600; +} +.calibration-clear-dialog button.calibration-clear-confirm:hover:not(:disabled) { filter: brightness(1.12); } +.calibration-clear-dialog button.calibration-clear-confirm:disabled { opacity: .5; filter: none; } +@media (max-width: 560px) { + .calibration-clear-row { flex-direction: column; } + .calibration-clear-row .calibration-option-grid { flex: none; } + .calibration-page .calibration-clear-action, + .calibration-page .calibration-clear-action button { width: 100%; } + .calibration-clear-table th, .calibration-clear-table td { padding: .32rem .25rem; } + .calibration-undo > button { width: 100%; } +} @media (max-width: 760px) { .calibration-steps div { font-size: .68rem; overflow-wrap: anywhere; } .calibration-glossary { text-align: left; } From 246fd1075efe827bbfe4970c970c97acb9624652 Mon Sep 17 00:00:00 2001 From: Physics G Date: Tue, 27 Oct 2026 18:10:23 +0100 Subject: [PATCH 23/26] Calibration: show the move a drag started, hub mark only when saved After release the track keeps the target and says how far the tip is moving and how far it still has to go, with Stop beside it. The step buttons take the same path. A forward correction now reports the tip while it moves. The hub mark is drawn only from a hub clearance saved for this route, not from the global default. --- multiace/i18n/de.json | 1 + multiace/i18n/en.json | 1 + multiace/klipper/extras/ace.py | 6 +++++ multiace/web/frontend/app.js | 43 +++++++++++++++++++++++++++++--- multiace/web/frontend/index.html | 20 +++++++++------ multiace/web/frontend/style.css | 3 ++- tests/unit/test_calibration.py | 11 ++++++++ 7 files changed, 72 insertions(+), 13 deletions(-) diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index a1e9c82a..a73a154c 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -730,6 +730,7 @@ "done_ready": "Messung abgeschlossen", "done_ready_hint": "Sieh dir die Werte unter Ergebnis an oder prüfe den Weg.", "drag_hint": "Zieh den Griff dorthin, wo die Spitze stehen soll, und lass los. Beim Ziehen bewegt sich nichts.", + "drag_moving": "Fährt {mm} mm Richtung {dir} · noch {left} mm", "drag_release": "Loslassen fährt {mm} mm Richtung {dir}", "drag_small": "Zu klein für eine Bewegung", "err_incomplete": "Zuerst alle Messpunkte der Kalibrierung abschließen.", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index dfe630b3..033f720b 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -730,6 +730,7 @@ "done_ready": "Measurement finished", "done_ready_hint": "Review the values in Results, or verify the route.", "drag_hint": "Drag the handle to where the tip should be, then let go. Nothing moves while you drag.", + "drag_moving": "Moving {mm} mm toward the {dir} · {left} mm to go", "drag_release": "Release to move {mm} mm toward the {dir}", "drag_small": "Too small to move", "err_incomplete": "Complete all calibration checkpoints first.", diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 09c1745e..4534d8b2 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -10997,6 +10997,12 @@ def _calibration_timer_tick(self, eventtime): int(c.get('load_length_mm') or 0) - int(c.get('commanded_retract_mm') or 0) - partial))) + elif state == 'feed_jogging': + # forward correction: the page shows how far it has to go + c['tip_position_mm'] = max(0, int(round( + int(c.get('load_length_mm') or 0) + - int(c.get('commanded_retract_mm') or 0) + + self._calibration_move_travel(move, eventtime)))) if move.get('error'): self._calibration_fail(move['error']) self._calibration_timer = None diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 535b02f8..73b70f0f 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -1350,14 +1350,24 @@ createApp({ // pull-back never past the park position, a forward move never past the // toolhead-sensor start). Below 5 mm nothing is sent. const CALIBRATION_MIN_MOVE_MM = 5; - const calibrationDrag = reactive({active: false, targetMm: null}); + // `moving` is the move a release or a step button started: its target, + // length and direction, shown until the ACE has stopped. + const calibrationDrag = reactive({active: false, targetMm: null, moving: null}); + const CAL_TRACK_MOVES = ['retracting', 'feed_jogging']; const calibrationTipNowMm = computed(() => { const c = state.calibration || {}; + // while a move runs the session reports the tip live + if (CAL_TRACK_MOVES.includes(c.state) && c.tip_position_mm != null) { + return Math.max(0, Number(c.tip_position_mm)); + } return Math.max(0, Number(c.load_length_mm || 0) - Number(c.commanded_retract_mm || 0)); }); // The saved hub clearance point as a reference mark, if there is one. + // Only a hub clearance really saved for this route is drawn; the global + // default says nothing about where this route's hub is. const calibrationTrackHubMm = computed(() => { + if (!calibrationRouteCalibrated.value) return null; const load = Number(state.calibration?.load_length_mm || 0); const swap = calibrationConfiguredRoute.value.swap; return load > 0 && swap > 0 && swap < load ? load - swap : null; @@ -1367,6 +1377,13 @@ createApp({ return load > 0 ? Math.max(0, Math.min(100, 100 * Number(mm) / load)) : 0; } const calibrationDragLabel = computed(() => { + const moving = calibrationDrag.moving; + if (moving && calibrationDrag.targetMm == null) { + return t('ui.calibration.drag_moving', { + mm: moving.mm, dir: t(moving.dir), + left: Math.round(Math.abs(moving.target - calibrationTipNowMm.value)), + }); + } if (calibrationDrag.targetMm == null) return ''; const diff = calibrationDrag.targetMm - calibrationTipNowMm.value; if (Math.abs(diff) < CALIBRATION_MIN_MOVE_MM) return t('ui.calibration.drag_small'); @@ -1412,9 +1429,27 @@ createApp({ const target = Math.max(0, Math.min(load, Math.round(targetMm))); const diff = target - (load - retract); if (Math.abs(diff) < CALIBRATION_MIN_MOVE_MM) return false; - if (diff < 0) return calibrationAction('retract', -diff); - return calibrationAction('feed_jog', Math.min(diff, retract)); + const mm = diff < 0 ? -diff : Math.min(diff, retract); + calibrationDrag.moving = { + target: (load - retract) + Math.sign(diff) * mm, mm, + dir: diff < 0 ? 'ui.calibration.dir_ace' : 'ui.calibration.dir_head', + }; + const ok = await calibrationAction(diff < 0 ? 'retract' : 'feed_jog', mm); + if (!ok || !CAL_TRACK_MOVES.includes(state.calibration?.state)) { + calibrationDrag.moving = null; + } + return ok; + } + // The step buttons go through the same path, so they get the same + // "moving" label. + function calibrationJog(mm) { + return calibrationMoveTo(calibrationTipNowMm.value + mm); } + watch(() => state.calibration?.state, (s) => { + if (calibrationDrag.moving && !CAL_TRACK_MOVES.includes(s)) { + calibrationDrag.moving = null; + } + }); async function calibrationApply() { const c = state.calibration || {}; const v = calibrationProposed.value; @@ -8349,7 +8384,7 @@ createApp({ calibrationStart, calibrationMarkAndReturn, calibrationUndoMark, calibrationDrag, calibrationTipNowMm, calibrationTrackHubMm, calibrationTrackPercent, calibrationDragLabel, calibrationDragStart, - calibrationDragMove, calibrationDragEnd, calibrationDragCancel, + calibrationDragMove, calibrationDragEnd, calibrationDragCancel, calibrationJog, calibrationNextRoute, calibrationClear, calibrationSavedKind, calibrationSavedLevels, calibrationClearPlan, calibrationClearBlocked, calibrationClearSession, diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index dd73a872..85a38964 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -1172,8 +1172,10 @@

{{ t('ui.calibration.step' + calibrationMeasureStep + '_head') }}

{{ t('ui.calibration.hub_saved') }} - +

@@ -1181,7 +1183,12 @@

{{ t('ui.calibration.step' + calibrationMeasureStep + '_head') }}

{{ t('ui.calibration.track_park') }} {{ t('ui.calibration.track_head', {head: dispIdx(calibrationUi.head)}) }}
-
{{ calibrationDragLabel }}
+
+ {{ calibrationDragLabel }} + +
@@ -1191,7 +1198,7 @@

{{ t('ui.calibration.step' + calibrationMeasureStep + '_head') }}

:disabled='calibrationMoving || !!calibrationUi.busy || Number(state.calibration.commanded_retract_mm || 0) + mm > Number(state.calibration.load_length_mm || 0)' - @click='calibrationAction(`retract`, mm)'>← {{ mm }} mm + @click='calibrationJog(-mm)'>← {{ mm }} mm
@@ -1200,7 +1207,7 @@

{{ t('ui.calibration.step' + calibrationMeasureStep + '_head') }}

+ @click='calibrationJog(mm)'>{{ mm }} mm →
@@ -1216,9 +1223,6 @@

{{ t('ui.calibration.step' + calibrationMeasureStep + '_head') }}

&& Number(state.calibration.commanded_retract_mm || 0) >= Number(state.calibration.load_length_mm || 0)' class='muted'>{{ t('ui.calibration.at_park_hint') }} - diff --git a/multiace/web/frontend/style.css b/multiace/web/frontend/style.css index 4ae4ed71..a4f56229 100644 --- a/multiace/web/frontend/style.css +++ b/multiace/web/frontend/style.css @@ -2044,7 +2044,8 @@ body:has(.panel) .overlay .dialog { color: var(--muted); font-size: .68rem; } .calibration-drag-label { - min-height: 1.2rem; margin-top: .35rem; text-align: center; + display: flex; flex-wrap: wrap; align-items: center; justify-content: center; + gap: .5rem; min-height: 1.2rem; margin-top: .35rem; text-align: center; color: var(--accent); font-size: .76rem; } .calibration-review td small { diff --git a/tests/unit/test_calibration.py b/tests/unit/test_calibration.py index c81f93a9..347b2e0e 100644 --- a/tests/unit/test_calibration.py +++ b/tests/unit/test_calibration.py @@ -678,3 +678,14 @@ def test_check_tolerance_two_percent(): c['load_length_mm'] = c['retract_length_mm'] = 1400 _verify_to_hub(a, c) assert c['verify_remeasured_mm'] is not None + + +def test_forward_jog_reports_tip_live(): + a, c = new_session(1400, 'v2') + a.cmd_ACE_CALIBRATION_RETRACT(GCmd(LENGTH=600)) + assert pump(a, ('retract_ready',)) == 'retract_ready' + start = c['tip_position_mm'] + a.cmd_ACE_CALIBRATION_FEED_JOG(GCmd(LENGTH=300)) + run_until(a, lambda: c['state'] == 'feed_jogging' + and phys(a) > start + 120) + assert c['tip_position_mm'] > start + 60 From aba0d48b2910b17f0dcb55f8514b9b73f0776e70 Mon Sep 17 00:00:00 2001 From: Physics G Date: Tue, 27 Oct 2026 18:13:35 +0100 Subject: [PATCH 24/26] Calibration: clearer hub hints in the pull-back step Say up front that the left end of the track is the park position, not the hub, and describe the stop point by side (the tip has left the hub on the ACE side) instead of 'past the hub', which reads both ways. --- multiace/i18n/de.json | 4 ++-- multiace/i18n/en.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index a73a154c..1d882015 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -662,7 +662,7 @@ "anchor_splitter": "Freigabepunkt Verteiler", "anchors_none": "Dieser Weg wurde noch nicht gemessen.", "apply_config": "In die Konfiguration speichern", - "at_park_hint": "Ganz bis zur Parkposition zurückgezogen. Fahre Richtung Kopf, bis die Spitze knapp hinter dem Verteiler ist, dann markieren.", + "at_park_hint": "Zurück in der Parkposition, die Spitze ist also weit vor dem Verteiler. Fahr Richtung Kopf und stoppe mit der Spitze kurz vor dem Verteiler auf der ACE-Seite, nicht darin, dann markieren.", "blk_ace_off": "Die gewählte ACE ist nicht verbunden.", "blk_busy": "Warten, bis der laufende Filament-Vorgang fertig ist.", "blk_head_loaded": "Entlade zuerst den Kopf.", @@ -858,7 +858,7 @@ "step1_head": "Fördert zum Kopf", "step1_hint": "Der Vorschub stoppt von selbst, sobald der Kopfsensor das Filament sieht.", "step2_head": "Zurückziehen, bis die Spitze aus dem Verteiler ist", - "step2_hint": "Beobachte den Verteiler. Zieh den Griff dorthin, wo die Spitze stehen soll, oder nimm die Schritte; jede Bewegung ist ein begrenzter Befehl. Bist du zu weit, geht es auch wieder Richtung Kopf.", + "step2_hint": "Beobachte den Verteiler und stoppe, sobald die Spitze ihn auf der ACE-Seite verlassen hat. Das linke Ende der Leiste ist die Parkposition, nicht der Verteiler. Zieh den Griff oder nutze die Schritte; jede Bewegung ist ein begrenzter Befehl, und du kannst Richtung Kopf zurück, wenn du zu weit warst.", "step3_head": "Fährt zur Parkposition zurück", "step3_hint": "Ein Blick in den ACE ist nicht nötig: Es wird dieselbe Strecke zurückgezogen, die gefördert wurde.", "step_feed": "Zum Kopf fördern", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 033f720b..59532f41 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -662,7 +662,7 @@ "anchor_splitter": "hub clearance point", "anchors_none": "This route has not been measured yet.", "apply_config": "Save to config", - "at_park_hint": "Pulled all the way back to the park position. Move toward the toolhead until the tip is just past the hub, then mark.", + "at_park_hint": "Back at the park position, so the tip is well before the hub. Move toward the toolhead and stop with the tip just in front of the hub on the ACE side, not inside it, then mark.", "blk_ace_off": "The selected ACE is not connected.", "blk_busy": "Wait for the active filament operation to finish.", "blk_head_loaded": "Unload the toolhead first.", @@ -858,7 +858,7 @@ "step1_head": "Feeding to the toolhead", "step1_hint": "The feed stops by itself when the toolhead sensor sees the filament.", "step2_head": "Pull back until the tip is clear of the hub", - "step2_hint": "Watch the hub. Drag the handle to where the tip should be, or use the steps; each move is one bounded command. You can go back toward the toolhead if you overshoot.", + "step2_hint": "Watch the hub and stop as soon as the tip has left it on the ACE side. The left end of the track is the park position, not the hub. Drag the handle or use the steps; each move is one bounded command, and you can go back toward the toolhead if you overshoot.", "step3_head": "Returning to the park position", "step3_hint": "No need to look inside the ACE: the same distance that was fed is pulled back.", "step_feed": "Feed to the toolhead", From 5a9cbc24d2d71c96489bc8bf595330457cdb7590 Mon Sep 17 00:00:00 2001 From: Physics G Date: Tue, 27 Oct 2026 19:24:35 +0100 Subject: [PATCH 25/26] Web: keep config files readable by Klipper The service runs as root. Started with a strict umask it wrote ace.cfg as root 0600 and Klipper, running as the printer user, could not open it. Files written for Klipper (ace.cfg, its backup, slot overrides) are now readable by all and, when running as root, owned like their directory. --- multiace/web/backend/main.py | 23 ++++++++++++ tests/unit/test_config_file_access.py | 54 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/unit/test_config_file_access.py diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index d58ef69f..4c49527c 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -4893,8 +4893,30 @@ def _write_cfg_atomic(path, text: str) -> None: p = Path(path) tmp = p.with_suffix(p.suffix + ".tmp") tmp.write_text(text, encoding="utf-8") + _klipper_readable(tmp) os.replace(str(tmp), str(p)) + +def _klipper_readable(path) -> None: + """Make a file this service wrote readable by Klipper before it replaces + the real one. The service runs as root: with a strict umask (e.g. 0077, + inherited from an SSH shell that restarted it) the new file came out + root-owned 0600 and Klipper, running as the printer user, failed with + "Unable to open config file". Readable by all, owned like its directory + when running as root. Best-effort.""" + p = Path(path) + try: + os.chmod(str(p), 0o644) + except OSError: + pass + geteuid = getattr(os, "geteuid", None) + if geteuid is not None and geteuid() == 0: + try: + owner = p.parent.stat() + os.chown(str(p), owner.st_uid, owner.st_gid) + except OSError: + pass + @app.get("/api/config") async def get_config() -> dict: p = Path(_resolve_cfg_path()) @@ -5342,6 +5364,7 @@ def _save_overrides_to_disk() -> None: try: tmp = p.with_suffix(p.suffix + ".tmp") tmp.write_text(json.dumps(_slot_overrides, indent=2), encoding="utf-8") + _klipper_readable(tmp) os.replace(str(tmp), str(p)) try: _overrides_mtime = p.stat().st_mtime diff --git a/tests/unit/test_config_file_access.py b/tests/unit/test_config_file_access.py new file mode 100644 index 00000000..e05d73ff --- /dev/null +++ b/tests/unit/test_config_file_access.py @@ -0,0 +1,54 @@ +"""Files the web backend writes for Klipper stay readable by Klipper. + +The backend runs as root; started from a shell with umask 0077 a plain +write + os.replace left ace.cfg root-owned 0600 and Klipper (user lava) +failed with "Unable to open config file". +""" +import os + +from .test_mock_mode import _load_main # noqa: F401 + + +def _record_access(monkeypatch, euid): + calls = {'chmod': [], 'chown': []} + monkeypatch.setattr(os, 'chmod', + lambda p, mode: calls['chmod'].append((str(p), mode))) + monkeypatch.setattr(os, 'chown', + lambda p, uid, gid: calls['chown'].append( + (str(p), uid, gid)), raising=False) + monkeypatch.setattr(os, 'geteuid', lambda: euid, raising=False) + return calls + + +def test_config_write_is_readable_and_owned_like_its_directory( + monkeypatch, tmp_path): + main = _load_main(monkeypatch, tmp_path, mock=False) + cfg = tmp_path / 'ace.cfg' + cfg.write_text('[ace]\n', encoding='utf-8') + calls = _record_access(monkeypatch, euid=0) + main._write_cfg_atomic(cfg, '[ace]\nfoo: 1\n') + assert cfg.read_text(encoding='utf-8') == '[ace]\nfoo: 1\n' + tmp = str(cfg.with_suffix('.cfg.tmp')) + assert calls['chmod'] and calls['chmod'][-1][0] == tmp + assert calls['chmod'][-1][1] & 0o444 == 0o444 + owner = tmp_path.stat() + assert calls['chown'] == [(tmp, owner.st_uid, owner.st_gid)] + + +def test_config_write_as_user_does_not_chown(monkeypatch, tmp_path): + main = _load_main(monkeypatch, tmp_path, mock=False) + cfg = tmp_path / 'ace.cfg' + calls = _record_access(monkeypatch, euid=1000) + main._write_cfg_atomic(cfg, '[ace]\n') + assert calls['chmod'][-1][1] & 0o444 == 0o444 + assert calls['chown'] == [] + + +def test_slot_overrides_write_is_readable(monkeypatch, tmp_path): + main = _load_main(monkeypatch, tmp_path, mock=False) + target = tmp_path / 'slot_overrides.json' + monkeypatch.setattr(main, 'OVERRIDE_FILE', str(target)) + calls = _record_access(monkeypatch, euid=0) + main._save_overrides_to_disk() + assert target.exists() + assert calls['chmod'] and calls['chmod'][-1][1] & 0o444 == 0o444 From 56c7670b2d2a983b12f6a3da927caa533ce6b433 Mon Sep 17 00:00:00 2001 From: Physics G Date: Tue, 27 Oct 2026 19:34:17 +0100 Subject: [PATCH 26/26] Calibration: say what the drag and the mark button do The drag moves the filament either way, on release. The mark button records the hub clearance and goes to the park position; nothing is saved until Results, so it no longer says 'save'. The condition for marking (the tip out of the hub, in its own Bowden tube to the ACE) moves to a line above the button. --- multiace/i18n/de.json | 9 +++++---- multiace/i18n/en.json | 9 +++++---- multiace/web/frontend/index.html | 1 + multiace/web/frontend/style.css | 2 ++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 1d882015..68f255e1 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -729,7 +729,7 @@ "done_busy_hint": "Mach im Reiter Prüfen weiter.", "done_ready": "Messung abgeschlossen", "done_ready_hint": "Sieh dir die Werte unter Ergebnis an oder prüfe den Weg.", - "drag_hint": "Zieh den Griff dorthin, wo die Spitze stehen soll, und lass los. Beim Ziehen bewegt sich nichts.", + "drag_hint": "Zieh die Markierung, um das Filament zu bewegen; es fährt, wenn du loslässt. Für kleine Korrekturen die mm-Schritte nutzen.", "drag_moving": "Fährt {mm} mm Richtung {dir} · noch {left} mm", "drag_release": "Loslassen fährt {mm} mm Richtung {dir}", "drag_small": "Zu klein für eine Bewegung", @@ -770,8 +770,9 @@ "manual_correction": "Kleine Schritte bis zum Kopfsensor", "margin_included": "enthalten", "margin_none": "keiner", - "mark_splitter": "Spitze ist aus dem Verteiler — speichern und zurück", - "marked_at": "Verteiler-Freigabe bei {mm} mm gespeichert.", + "mark_splitter": "Hier markieren und parken", + "mark_splitter_hint": "Markieren, wenn die Spitze aus dem Verteiler ist, im eigenen Bowdenschlauch zur ACE.", + "marked_at": "Verteiler-Freigabe bei {mm} mm markiert – gespeichert wird unter Ergebnis.", "measure_head": "Diesen Weg messen", "measure_hint": "Das Filament wird kalt bis zum Kopfsensor gefördert, du ziehst es zurück, bis es aus dem Verteiler ist, und es fährt von selbst in den ACE zurück.", "next_route": "Nächsten Slot oder ACE kalibrieren", @@ -858,7 +859,7 @@ "step1_head": "Fördert zum Kopf", "step1_hint": "Der Vorschub stoppt von selbst, sobald der Kopfsensor das Filament sieht.", "step2_head": "Zurückziehen, bis die Spitze aus dem Verteiler ist", - "step2_hint": "Beobachte den Verteiler und stoppe, sobald die Spitze ihn auf der ACE-Seite verlassen hat. Das linke Ende der Leiste ist die Parkposition, nicht der Verteiler. Zieh den Griff oder nutze die Schritte; jede Bewegung ist ein begrenzter Befehl, und du kannst Richtung Kopf zurück, wenn du zu weit warst.", + "step2_hint": "Beobachte den Verteiler und stoppe, sobald die Spitze ihn auf der ACE-Seite verlassen hat. Das linke Ende der Leiste ist die Parkposition, nicht der Verteiler. Jede Bewegung ist ein begrenzter Befehl, und du kannst Richtung Kopf zurück, wenn du zu weit warst.", "step3_head": "Fährt zur Parkposition zurück", "step3_hint": "Ein Blick in den ACE ist nicht nötig: Es wird dieselbe Strecke zurückgezogen, die gefördert wurde.", "step_feed": "Zum Kopf fördern", diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 59532f41..e0d15300 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -729,7 +729,7 @@ "done_busy_hint": "Carry on in the Verify tab.", "done_ready": "Measurement finished", "done_ready_hint": "Review the values in Results, or verify the route.", - "drag_hint": "Drag the handle to where the tip should be, then let go. Nothing moves while you drag.", + "drag_hint": "Drag the marker to move the filament; it moves when you let go. Use the mm steps for small corrections.", "drag_moving": "Moving {mm} mm toward the {dir} · {left} mm to go", "drag_release": "Release to move {mm} mm toward the {dir}", "drag_small": "Too small to move", @@ -770,8 +770,9 @@ "manual_correction": "Small steps to the toolhead sensor", "margin_included": "included", "margin_none": "none", - "mark_splitter": "Tip is clear of the hub — save and return", - "marked_at": "Hub clearance saved at {mm} mm.", + "mark_splitter": "Mark here and go to park", + "mark_splitter_hint": "Mark when the tip is out of the hub, in its own Bowden tube to the ACE.", + "marked_at": "Hub clearance marked at {mm} mm – saved on Results.", "measure_head": "Measure this route", "measure_hint": "The filament is fed cold to the toolhead sensor, you pull it back until it is clear of the hub, and it returns to the ACE by itself.", "next_route": "Calibrate the next slot or ACE", @@ -858,7 +859,7 @@ "step1_head": "Feeding to the toolhead", "step1_hint": "The feed stops by itself when the toolhead sensor sees the filament.", "step2_head": "Pull back until the tip is clear of the hub", - "step2_hint": "Watch the hub and stop as soon as the tip has left it on the ACE side. The left end of the track is the park position, not the hub. Drag the handle or use the steps; each move is one bounded command, and you can go back toward the toolhead if you overshoot.", + "step2_hint": "Watch the hub and stop as soon as the tip has left it on the ACE side. The left end of the track is the park position, not the hub. Each move is one bounded command, and you can go back toward the toolhead if you overshoot.", "step3_head": "Returning to the park position", "step3_hint": "No need to look inside the ACE: the same distance that was fed is pulled back.", "step_feed": "Feed to the toolhead", diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 85a38964..f990b386 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -1211,6 +1211,7 @@

{{ t('ui.calibration.step' + calibrationMeasureStep + '_head') }}

+

{{ t('ui.calibration.mark_splitter_hint') }}