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/.
5.3 KiB
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 fromextruder.json(x/y_check_pos..3), station basex/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_RELEASEare 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 namesservo_min/servo_max(lock mechanism end positions), but this printer's config declares neither -- see10-hardware.mdfor 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
- Home gate (abort if not homed, E0160).
- Dock-must-be-OCCUPIED precheck: 20 × 50 ms polls of
checkInLocation(tool); failure → E0127+tool, no motion. SET_VELOCITY_LIMIT ACCEL=8000;SET_GCODE_OFFSET X=0 Y=0 MOVE=1 MOVE_SPEED=100.- Approach, once, before the retry loop:
G1 X250 F<fast>(x_safe),G1 Y<dockY>,G1 X280(x_approach). - Then up to 3 attempts. Each begins by re-engaging the dock —
G1 X<dockX> F<slow>(+grabOffsetcorrection from test.json) — which is what undoes the previous attempt's back-off. Poll the grab sensor for up to 1 s; on successMOTOR_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 toG1 X280and wait.G1 X250 F1500is the retreat after the loop, not part of an attempt. - Verify: dock sensor released AND grab sensor pressed → else E0051+tool
(
ERR_GRAB_VERIFY_BASE; the app's own code here was not recovered). - Apply per-tool G-code offsets (see
40-offsets.md). SET_VELOCITY_LIMIT ACCEL=20000.
Feedrates: test.json grabSpeed/grabSpeedSlow × 60, fallbacks 24000/6000.
Release — doReleaseExtruderLatest @0x7aa394
Not symmetric with grab; the details matter:
- Home gate.
- Dock-must-be-EMPTY precheck, 20 × 50 ms; failure → E0131+tool, no motion.
ACCEL=8000; clear X/Y offsets. No ACTIVATE_EXTRUDER anywhere on release.- Approach once:
G1 X250 F<fast>,G1 Y<dockY>. No X280 stage (grab-only). - 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 thenMOTOR_RELEASE→ supervise the lock endstop; "ended untriggered" → re-issue MOTOR_RELEASE, max 3 sends per attempt. - Success only: retreat
G1 X250 F4800. On failure the carriage stays at the dock. - Verify: in dock AND grab sensor clear → else E0144; all-attempts-exhausted → E0135+tool.
- 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
- No 4th unsupervised
MOTOR_RELEASE(the app fires one before checking its own failure counter — energising the lock with nobody watching). - No
M119refresh polls — Klipper'sgcode_buttonstate is event-driven; the 20 × 50 ms gating semantics are kept as wall-clock deadlines. now_extrudernot written; identity is sensor-derived.- 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.