Files
firmware/docs/notes/30-toolchange.md
Oleksandr Shyshatskyi 53a2bc010b Five extras were on the firmware partition because of a flag
pkgs/klipper/prog/klippy/extras/ff_*.py sat on /usr/prog for one
reason, and it was not a technical one: anvil-klipper answers to
BUILD_KLIPPER and the extras answered to BUILD_TOOLCHANGE, and a
package cannot hold files gated differently from itself. So they stayed
outside every package, copied to the firmware partition by bin/patch.sh.

They are anvil-klipper's now, at payload/klipper/klippy/extras/, and
BUILD_TOOLCHANGE no longer gates them. An extra is inert until a config
section instantiates it, and those sections are anvil-klipper-config's,
where the flag still applies -- so the gate bought nothing and cost the
mismatch.

Inside the tree is the only place they can go: klippy resolves an extra
as dirname(klippy.py)/extras/<name>.py (klippy.py:90-103), with no
search path and no environment variable.

bin/patch.sh still copies them into the software component -- klippy
runs from /usr/prog until the s6 service lands -- but reads them out of
the payload now, the way section 1 already reads the klippy tree, so the
recipe directory is not a second source for a file a package holds. It
names the five rather than globbing ff_*.py: the fork ships an
ff_eddy.py of its own and the glob could not tell them apart. Verified
the component still gets exactly the same five files.

The flags are coupled by this, and patch.sh says so rather than shipping
a broken printer: BUILD_TOOLCHANGE=1 with BUILD_KLIPPER=stock now fails
with the fix in the message. It has to -- the extras are written against
the fork's v0.13 klippy, and dropping them onto FlashForge's v0.12 is
the half-overwritten mixture that shipped as v20260824. The fixture lane
in test/run-tests.py used exactly that pair (BUILD_KLIPPER=stock is a
no-network convenience there) and now sets BUILD_TOOLCHANGE=0.

pkgs/klipper/pkg.conf gains PKG_STAMP_EXTRA=$(pkg_payload_hash):
KLIPPER_VERSION says nothing about payload/, so without it an edit to
ff_toolchange.py would read as "already current".

make qa-static 261 passed; make test 9 gates, 0 failed. The .ipk carries
all five under klipper/klippy/extras/.
2026-08-29 13:26:09 +03:00

5.3 KiB
Raw Permalink Blame History

Toolchange: sequences, sensors, mounted-tool state

Address-verified against the live binary (CommMgr methods). Ported in pkgs/klipper/payload/klipper/klippy/extras/ff_toolchange.py; divergences are deliberate and listed at the end.

Physical model

  • 4 docks on the right, X ≈ 297 (250 and 280 are the staging and approach waypoints, x_safe / x_approach), per-dock X/Y from extruder.json (x/y_check_pos..3), station base x/y/z_station_pos.
  • Lock motor on the carriage grabs/releases the head. Its endstop is registered on manual_stepper gear_stepper; MOTOR_GRAB/MOTOR_RELEASE are STOP_ON_ENDSTOP-style moves that respond "endstop triggered / not triggered".
  • Sensors (gcode_button): extruder_pos1..4 = head present in dock N; extruder_grab(1..4) = something locked on the carriage. The app also names servo_min/servo_max (lock mechanism end positions), but this printer's config declares neither -- see 10-hardware.md for the family that actually exists.
  • Invariant checked constantly: a head is EITHER in its dock OR on the carriage (E0127-134, E0139-146 cover every disagreement).

Grab — doGrabExtruderLatest @0x7a8190

  1. Home gate (abort if not homed, E0160).
  2. Dock-must-be-OCCUPIED precheck: 20 × 50 ms polls of checkInLocation(tool); failure → E0127+tool, no motion.
  3. SET_VELOCITY_LIMIT ACCEL=8000; SET_GCODE_OFFSET X=0 Y=0 MOVE=1 MOVE_SPEED=100.
  4. Approach, once, before the retry loop: G1 X250 F<fast> (x_safe), G1 Y<dockY>, G1 X280 (x_approach).
  5. Then up to 3 attempts. Each begins by re-engaging the dock — G1 X<dockX> F<slow> (+grabOffset correction from test.json) — which is what undoes the previous attempt's back-off. Poll the grab sensor for up to 1 s; on success MOTOR_GRAB, G1 X<dockX-20> F4800 (the app's literal pullback feed, not the calibrated one), MOTOR_GRAB2, and leave the loop. On failure back off to G1 X280 and wait. G1 X250 F1500 is the retreat after the loop, not part of an attempt.
  6. Verify: dock sensor released AND grab sensor pressed → else E0051+tool (ERR_GRAB_VERIFY_BASE; the app's own code here was not recovered).
  7. Apply per-tool G-code offsets (see 40-offsets.md).
  8. SET_VELOCITY_LIMIT ACCEL=20000.

Feedrates: test.json grabSpeed/grabSpeedSlow × 60, fallbacks 24000/6000.

Release — doReleaseExtruderLatest @0x7aa394

Not symmetric with grab; the details matter:

  1. Home gate.
  2. Dock-must-be-EMPTY precheck, 20 × 50 ms; failure → E0131+tool, no motion.
  3. ACCEL=8000; clear X/Y offsets. No ACTIVATE_EXTRUDER anywhere on release.
  4. Approach once: G1 X250 F<fast>, G1 Y<dockY>. No X280 stage (grab-only).
  5. Up to 3 attempts, each: G1 X<dockX-10> (no F, modal feed) → G1 X<dockX> F<slow> (slow fallback here is 5400, not 6000) → wait up to 20 × 50 ms for the dock sensor to read "seated" → only then MOTOR_RELEASE → supervise the lock endstop; "ended untriggered" → re-issue MOTOR_RELEASE, max 3 sends per attempt.
  6. Success only: retreat G1 X250 F4800. On failure the carriage stays at the dock.
  7. Verify: in dock AND grab sensor clear → else E0144; all-attempts-exhausted → E0135+tool.
  8. Unconditional tail (success AND failure): MOTOR_STOP, ACCEL=20000 (hence try/finally in the port).

In-process advantage: the app greps response text for "endstop (not) triggered" and polls 40 × 50 ms; a klippy extra just catches printer.command_error from MOTOR_RELEASE synchronously — a strictly stronger signal.

Mid-print change (changeExtruderChannel)

Save state (SAVE_GCODE_STATE NAME=PAUSE_state), manage temps, release old → grab new, prime G1 E15 F240 / retract with part-fan ooze control, RESTORE_GCODE_STATE MOVE=1, restore M220.

Which tool is mounted

The app persists an imperative index (now_extruder in extruder.json), reset to −1 by homing, and never derives it from sensors — because its grab-sensor read (getGrabSensorStatus @0x76f294) is a plain OR of four bytes: it knows only that something is held, never which.

ff_toolchange.py derives identity instead: extruder_pos1..4 are per-tool dock switches, so the mounted tool is the one whose dock is empty (while the grab sensor is pressed). Exactly-one-empty + held → that tool; all-occupied + held, or several-empty + held → abort (faulty switch / ambiguous — refusing beats guessing, since guessing wrong would drop the carried tool into another tool's dock). Nothing is persisted; the value cannot go stale. now_extruder is never written (see 40-offsets.md on why writing the JSON is forbidden), and it is no longer read either: the JSON reader was removed, so TOOLCHANGE_STATUS reports only sensors and Klipper config and has nothing to compare against.

Deliberate divergences in the port

  1. No 4th unsupervised MOTOR_RELEASE (the app fires one before checking its own failure counter — energising the lock with nobody watching).
  2. No M119 refresh polls — Klipper's gcode_button state is event-driven; the 20 × 50 ms gating semantics are kept as wall-clock deadlines.
  3. now_extruder not written; identity is sensor-derived.
  4. Modal-state restore added: the app leaks G90 + last feedrate + accel into whatever runs next; the port snapshots and restores absolute/relative mode, feedrate, and the pre-sequence accel limit.