Add optional Z-hop and in-dock retract to the return after a tool change

With restore_axis naming X or Y, a real tool change can now raise the old
tool before it crosses the part to its dock, retract the new tool while it
is still seated in its dock, travel back raised, descend, and give back part
of the retract at the target. The options are restore_z_hop, restore_retract
and restore_unretract with their feeds; all are zero as shipped.

From Niels Sedat's #29 without the prime-tower registration, the model-skip
rule, the shared-stepper sync or the stats hooks.

Co-authored-by: Niels Sedat <nlssdt@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-10-03 17:30:49 +03:00
1 parent 7e8ea9dbf4
commit 308d97ef21
5 files changed
+371 -6

No files matched your search

+8
View File
@@ -24,6 +24,14 @@ both printer models or through every real-world workflow.
default; nothing changes for a profile that does not call them.
- `printer.ff_print` reports the active plate's prime tower: position, size,
brim, rotation and measured outline.
- Gentler return to the print after a tool change, for a printer that restores
X/Y (`restore_axis`): `restore_z_hop` raises the old tool before it crosses the
part and the new one travels back high, `restore_retract` pulls filament back
while the new tool is still seated in its dock so it does not string, and
`restore_unretract` gives part of it back at the target. All are zero as
shipped, which changes nothing. From
[#29](https://github.com/Klipper4FlashForge/firmware/pull/29), without its
prime-tower handling.
- The shared-stepper adapter `ff_extruder.py`, for a printer whose four
logical extruders repeat one physical stepper's options. It does nothing
until a `[ff_extruder]` section is configured, and none is yet.
+17
View File
@@ -347,6 +347,23 @@ parking zeroes the tool offsets and the same G-code Z becomes a different
machine Z (~3.2 mm, this tool's nozzle-to-station-trigger gap -- the station,
not the eddy coil; see the note under Calibration).
When X or Y is restored after a real change, the trip back can be made gentler
on the part. Three groups of options in `[ff_toolchange]`, all zero as shipped:
- `restore_z_hop` raises the old tool that far before it crosses the part to
its dock, and the new tool travels back at that height and descends at the
target.
- `restore_retract` pulls that much filament back while the new tool is still
seated in its dock, before it is drawn out, so a hot nozzle does not drag an
ooze string. A tool below the minimum extrusion temperature is not retracted.
- `restore_unretract` pushes back at most that much at the target. Setting it
below the retract is deliberate: the slicer's own extrusion supplies the rest
while moving, instead of a blob from a stationary prime.
Feeds are `restore_z_feed`, `restore_retract_feed` and
`restore_unretract_feed`. Re-selecting the tool that is already mounted, and any
change that does not restore X or Y, behaves as before.
`SET_TOOL_TEMPERATURE` addresses the extruder behind the tool; `WAIT=1` waits
only for heat-up, as Klipper's own `TEMPERATURE_WAIT MINIMUM` does.
`VERIFY_TOOL_DETECTED` accepts `ASYNC` and ignores it — we read switches after
@@ -125,6 +125,26 @@ accel_move: 8000
#restore_axis: xy
#restore_feed: 9000
# The trip across the part and back, used only when restore_axis names X or Y
# and the tool really changes. Off as shipped; set them in your printer.cfg.
# restore_z_hop mm. The old tool rises this far before it crosses the
# part to its dock, and the new one travels back this
# high, then descends at the target.
# restore_retract mm of filament the new tool pulls back while it is still
# seated in its dock, before it is drawn out, so a hot
# nozzle does not string. Skipped below the minimum
# extrusion temperature.
# restore_unretract mm pushed back at the target, at most restore_retract.
# Less than the retract on purpose: the slicer's own
# extrusion supplies the rest while moving.
# restore_z_feed, restore_retract_feed, restore_unretract_feed mm/min
#restore_z_hop: 2.0
#restore_z_feed: 1200
#restore_retract: 0.4
#restore_retract_feed: 1800
#restore_unretract: 0.4
#restore_unretract_feed: 200
# Sensor names -- microswitches, exposed as [gcode_button] sections in
# printer.base.cfg, but what they ARE is the app's sensors. dock_sensors is
# one per tool, PRESSED meaning that tool IS docked; grab_sensors are OR'd,
@@ -297,6 +297,29 @@ class FFToolchange:
self.restore_axis = self._parse_axes(
config.get('restore_axis', ''), config.error, 'restore_axis')
self.restore_feed = config.getint('restore_feed', 9000, minval=1)
# Protection for the trip back to the restored X/Y after a real
# change, used only when restore_axis names X or Y. The old tool is
# raised restore_z_hop before it crosses the part to its dock; the
# new tool, still seated in its dock, retracts restore_retract of
# filament before it is pulled out so a hot nozzle does not drag an
# ooze string; and it travels at the raised Z, descends, then
# unretracts restore_unretract at the target. Raising and retracting
# are separate switches. All default to zero, which leaves the
# change exactly as it was.
self.restore_z_hop = config.getfloat('restore_z_hop', 0., minval=0.)
self.restore_z_feed = config.getint('restore_z_feed', 1200, minval=1)
self.restore_retract = config.getfloat(
'restore_retract', 0., minval=0.)
self.restore_retract_feed = config.getint(
'restore_retract_feed', 1800, minval=1)
# Less than the retract is deliberate: the slicer's own extrusion
# fills the rest while moving, instead of a blob from a stationary
# prime at the target.
self.restore_unretract = config.getfloat(
'restore_unretract', self.restore_retract, minval=0.,
maxval=self.restore_retract)
self.restore_unretract_feed = config.getint(
'restore_unretract_feed', self.restore_retract_feed, minval=1)
# Sensor names.
# position buttons: one per tool, PRESSED == that tool is docked.
@@ -782,7 +805,55 @@ class FFToolchange:
gcode_move = self.printer.lookup_object('gcode_move')
return list(gcode_move.get_status()['gcode_position'])
def _restore_position(self, axes, pos):
def _travel_protected(self, axes):
"""Does a change that restores `axes` raise, retract and unretract?"""
return (('X' in axes or 'Y' in axes)
and (self.restore_z_hop > 0. or self.restore_retract > 0.))
def _retract_in_dock(self):
"""Retract the newly locked tool before it leaves its dock.
The lock macro is synchronous (STEPPER_LOCK ends in M400), so the
tool and its electrical contacts are seated when this is called.
Relative E is borrowed for the move, and RESTORE_GCODE_STATE keeps
the slicer's own E coordinate. Returns the distance retracted.
"""
if self.restore_retract <= 0.:
return 0.
extruder = self.printer.lookup_object('toolhead').get_extruder()
if not extruder.get_status(
self.reactor.monotonic())['can_extrude']:
self.gcode.respond_info(
"ff_toolchange: in-dock retract skipped because the"
" mounted tool is below minimum extrusion temperature")
return 0.
self._run('SAVE_GCODE_STATE NAME=_ff_dock_retract')
try:
self._run('M83')
self._run('G1 E-%.3f F%d'
% (self.restore_retract, self.restore_retract_feed))
finally:
self._run('RESTORE_GCODE_STATE NAME=_ff_dock_retract')
return self.restore_retract
def _raise_before_docking(self, pos):
"""Raise the mounted nozzle before it crosses the print to its dock.
Z stays raised through the release and the grab; _restore_position
lowers it once the new tool is back over the target X/Y.
"""
if self.restore_z_hop <= 0.:
return
self._run('SAVE_GCODE_STATE NAME=_ff_dock_zhop')
try:
self._run('G90')
self._run('G1 Z%.3f F%d'
% (pos[2] + self.restore_z_hop, self.restore_z_feed))
finally:
self._run('RESTORE_GCODE_STATE NAME=_ff_dock_zhop')
def _restore_position(self, axes, pos, protected=False,
return_retract=0.):
"""Put the toolhead back where the change found it.
A GCODE position is captured and replayed, not a machine one, so it
@@ -791,7 +862,9 @@ class FFToolchange:
the point of the parameter.
X/Y first and Z last: descending before the carriage is over the
target would drag the nozzle across the part.
target would drag the nozzle across the part. With `protected` the
trip is made restore_z_hop higher, and the Z descent is followed by
the unretract of the filament pulled back in the dock.
Restoring Z after a PARK is the one sharp edge. Parking zeroes the
tool offsets, so the same G-code Z is a different machine Z -- by
@@ -802,13 +875,27 @@ class FFToolchange:
return
xy_move = ' '.join('%s%.3f' % (letter, pos[i])
for i, letter in enumerate('XY') if letter in axes)
hop = protected and bool(xy_move) and self.restore_z_hop > 0.
unretract = (min(return_retract, self.restore_unretract)
if protected and xy_move else 0.)
# The sequence has already put the modal state back; borrow it and
# return it rather than leaving G90 and the restore feed behind.
self._run('SAVE_GCODE_STATE NAME=_ff_restore_axis')
try:
self._run('G90')
if hop:
# The new tool's Z frame differs from the old one's, so the
# hop is applied again here rather than assumed.
self._run('G1 Z%.3f F%d'
% (pos[2] + self.restore_z_hop, self.restore_z_feed))
if xy_move:
self._run('G1 %s F%d' % (xy_move, self.restore_feed))
if hop:
self._run('G1 Z%.3f F%d' % (pos[2], self.restore_z_feed))
if unretract > 0.:
self._run('M83')
self._run('G1 E%.3f F%d'
% (unretract, self.restore_unretract_feed))
if 'Z' in axes:
self._run('G1 Z%.3f F%d' % (pos[2], self.restore_feed))
finally:
@@ -862,9 +949,13 @@ class FFToolchange:
# ---------------- grab ----------------
def _grab(self, tool):
"""Port of CommMgr::doGrabExtruderLatest @0x7a8190."""
def _grab(self, tool, retract_in_dock=False):
"""Port of CommMgr::doGrabExtruderLatest @0x7a8190.
Returns the filament retracted in the dock, which is owed back when
the tool reaches its restored position."""
dock_x, dock_y = self._dock(tool)
return_retract = 0.
# Precheck: the target must be detected in its dock (20 x 50 ms).
# No motion at all on failure.
@@ -902,6 +993,13 @@ class FFToolchange:
if self._poll_until(self._grab_sensor, SEAT_TIMEOUT):
self._run(self.grab_macro)
if retract_in_dock:
# Select this tool's heater and motion queue before
# touching E, and retract before the pullback can
# draw an ooze string out of the dock.
self._run('ACTIVATE_EXTRUDER EXTRUDER=%s'
% self._extruder_name(tool))
return_retract = self._retract_in_dock()
# The pullback feed is the app's literal F4800
# (@0x7a9074), NOT the calibrated slow feed.
self._run('G1 X%.3f F%d'
@@ -948,6 +1046,7 @@ class FFToolchange:
# sensors become the live ones (the app does this 3 s later
# from a thread; here the grab moves are already complete).
self._arm_runout(tool)
return return_retract
# ---------------- release ----------------
@@ -1077,6 +1176,8 @@ class FFToolchange:
# succeeded, since a half-finished sequence has no position worth
# returning to.
resume = self._capture_position() if restore_axis else None
protected = self._travel_protected(restore_axis)
return_retract = 0.
self.changing = True
try:
self._wait_moves()
@@ -1087,10 +1188,12 @@ class FFToolchange:
current, _ = self._derive_current_tool()
if current != tool:
if current >= 0:
if protected:
self._raise_before_docking(resume)
self._release(current)
# _grab activates the extruder and applies the tool offsets,
# as the app does inside doGrabExtruderLatest.
self._grab(tool)
return_retract = self._grab(tool, retract_in_dock=protected)
else:
# Same tool re-selected: still re-activate and re-apply, so
# the first Tn after a RESTART (which wiped the gcode
@@ -1105,7 +1208,12 @@ class FFToolchange:
# channel. Upstream needs none: both apply to the ACTIVE extruder,
# which _grab has just set to this tool.
if resume is not None:
self._restore_position(restore_axis, resume)
# Re-selecting the mounted tool crossed nothing and retracted
# nothing, so it keeps the plain X/Y-first return.
self._restore_position(
restore_axis, resume,
protected=protected and current != tool,
return_retract=return_retract)
except FFToolchangeError as err:
raise gcmd.error(str(err))
except self.printer.command_error:
+212
View File
@@ -0,0 +1,212 @@
"""The trip across the part during a tool change: hop, in-dock retract, unretract.
These run the real _toolchange, _restore_position, _retract_in_dock and
_raise_before_docking. The dock mechanics (_release, _grab's lock dance) are
substituted, so they do not validate motion on a printer; they pin the order
of the commands and which changes are left exactly as they were.
"""
import importlib.util
from types import SimpleNamespace
import pytest
from lib.paths import ROOT
pytestmark = pytest.mark.static
MODULE = (ROOT / "pkgs" / "klipper" / "payload" / "klipper" / "klippy" /
"extras" / "ff_toolchange.py")
POS = [100., 50., 3.2, 0.]
class CommandError(Exception):
pass
class Extruder:
def __init__(self, can_extrude=True):
self.can_extrude = can_extrude
def get_status(self, eventtime):
return {"can_extrude": self.can_extrude}
@pytest.fixture
def travel(monkeypatch):
spec = importlib.util.spec_from_file_location("anvil_ff_toolchange", MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
tc = module.FFToolchange.__new__(module.FFToolchange)
tc.log = []
tc.changing = False
tc.restore_axis = "xy"
tc.restore_feed = 9000
tc.restore_z_hop = 2.0
tc.restore_z_feed = 1200
tc.restore_retract = 0.4
tc.restore_retract_feed = 1800
tc.restore_unretract = 0.3
tc.restore_unretract_feed = 200
tc.extruder = Extruder()
tc.mounted = 0
tc.gcode = SimpleNamespace(respond_info=tc.log.append)
tc.reactor = SimpleNamespace(monotonic=lambda: 0.)
tc.printer = SimpleNamespace(
command_error=CommandError,
lookup_object=lambda name, default=None: SimpleNamespace(
get_extruder=lambda: tc.extruder))
tc.tools = [SimpleNamespace(
extruder_name="extruder" if i == 0 else "extruder%d" % i)
for i in range(4)]
monkeypatch.setattr(tc, "_run", lambda script: tc.log.append(script))
monkeypatch.setattr(tc, "_wait_moves", lambda: None)
monkeypatch.setattr(tc, "_ensure_homed", lambda *a: None)
monkeypatch.setattr(tc, "_capture_position", lambda: list(POS))
monkeypatch.setattr(tc, "_derive_current_tool",
lambda: (tc.mounted, "sensors"))
monkeypatch.setattr(tc, "_set_tool_frame", lambda tool: None)
monkeypatch.setattr(tc, "_arm_runout", lambda tool: None)
monkeypatch.setattr(tc, "_release",
lambda tool: tc.log.append("release T%d" % tool))
def grab(tool, retract_in_dock=False):
tc.log.append("grab T%d in_dock=%s" % (tool, retract_in_dock))
return tc._retract_in_dock() if retract_in_dock else 0.
monkeypatch.setattr(tc, "_grab", grab)
gcmd = SimpleNamespace(get=lambda name, default=None: default,
error=CommandError)
return tc, gcmd
def change(tc, gcmd, tool=2):
tc._toolchange(gcmd, tool)
return tc.log
def test_nothing_set_leaves_the_change_exactly_as_it_was(travel):
tc, gcmd = travel
tc.restore_z_hop = tc.restore_retract = 0.
tc.restore_unretract = 0.
assert change(tc, gcmd) == [
"release T0", "grab T2 in_dock=False",
"SAVE_GCODE_STATE NAME=_ff_restore_axis", "G90",
"G1 X100.000 Y50.000 F9000",
"RESTORE_GCODE_STATE NAME=_ff_restore_axis"]
def test_a_protected_change_raises_retracts_travels_lowers_and_unretracts(travel):
tc, gcmd = travel
assert change(tc, gcmd) == [
# the old tool rises before it crosses the part to its dock
"SAVE_GCODE_STATE NAME=_ff_dock_zhop", "G90", "G1 Z5.200 F1200",
"RESTORE_GCODE_STATE NAME=_ff_dock_zhop",
"release T0",
# the new tool pulls filament back while it is still seated
"grab T2 in_dock=True",
"SAVE_GCODE_STATE NAME=_ff_dock_retract", "M83", "G1 E-0.400 F1800",
"RESTORE_GCODE_STATE NAME=_ff_dock_retract",
# travel high, descend, give back part of it
"SAVE_GCODE_STATE NAME=_ff_restore_axis", "G90",
"G1 Z5.200 F1200", "G1 X100.000 Y50.000 F9000", "G1 Z3.200 F1200",
"M83", "G1 E0.300 F200",
"RESTORE_GCODE_STATE NAME=_ff_restore_axis"]
def test_the_unretract_never_exceeds_what_was_retracted(travel, monkeypatch):
tc, gcmd = travel
tc.restore_unretract = 0.4
monkeypatch.setattr(tc, "_retract_in_dock", lambda: 0.1)
log = change(tc, gcmd)
assert "G1 E0.100 F200" in log
def test_a_hop_alone_does_not_touch_the_filament(travel):
tc, gcmd = travel
tc.restore_retract = tc.restore_unretract = 0.
log = change(tc, gcmd)
assert "G1 Z5.200 F1200" in log and "G1 Z3.200 F1200" in log
assert not any(command.startswith("G1 E") for command in log)
def test_a_retract_alone_does_not_move_z(travel):
tc, gcmd = travel
tc.restore_z_hop = 0.
log = change(tc, gcmd)
assert "G1 E-0.400 F1800" in log and "G1 E0.300 F200" in log
assert not any(command.startswith("G1 Z") for command in log)
def test_a_cold_tool_is_not_retracted_or_unretracted(travel):
tc, gcmd = travel
tc.extruder = Extruder(can_extrude=False)
log = change(tc, gcmd)
assert not any(command.startswith("G1 E") for command in log)
assert any("below minimum extrusion temperature" in line
for line in log if not line.startswith(("G", "M", "SAVE")))
def test_the_first_pickup_has_nothing_to_raise_but_still_retracts(travel):
tc, gcmd = travel
tc.mounted = -1
log = change(tc, gcmd)
assert "SAVE_GCODE_STATE NAME=_ff_dock_zhop" not in log
assert "G1 E-0.400 F1800" in log and "G1 E0.300 F200" in log
def test_reselecting_the_mounted_tool_keeps_the_plain_return(travel):
tc, gcmd = travel
tc.mounted = 2
log = change(tc, gcmd, tool=2)
assert "release T2" not in log
assert log[-4:] == ["SAVE_GCODE_STATE NAME=_ff_restore_axis", "G90",
"G1 X100.000 Y50.000 F9000",
"RESTORE_GCODE_STATE NAME=_ff_restore_axis"]
assert not any(command.startswith("G1 E") for command in log)
assert not any(command.startswith("G1 Z") for command in log)
def test_without_xy_restored_the_protection_is_off(travel):
tc, gcmd = travel
tc.restore_axis = ""
log = change(tc, gcmd)
assert log == ["release T0", "grab T2 in_dock=False"]
def test_z_only_restore_is_not_protected(travel):
tc, gcmd = travel
tc.restore_axis = "Z"
log = change(tc, gcmd)
assert "grab T2 in_dock=False" in log
assert "SAVE_GCODE_STATE NAME=_ff_dock_zhop" not in log
assert not any(command.startswith("G1 E") for command in log)
def test_the_hop_follows_the_captured_z_not_a_fixed_height(travel, monkeypatch):
tc, gcmd = travel
monkeypatch.setattr(tc, "_capture_position",
lambda: [10., 20., 0.6, 0.])
log = change(tc, gcmd)
assert log.count("G1 Z2.600 F1200") == 2
assert "G1 Z0.600 F1200" in log