Skip to content

(feat): Adding detecting toolhead runout for standalone toolhead for toolchangers#730

Merged
jimmyjon711 merged 9 commits into
AFCProject:DEVfrom
jimmyjon711:toolhead_runout
Jun 9, 2026
Merged

(feat): Adding detecting toolhead runout for standalone toolhead for toolchangers#730
jimmyjon711 merged 9 commits into
AFCProject:DEVfrom
jimmyjon711:toolhead_runout

Conversation

@jimmyjon711

@jimmyjon711 jimmyjon711 commented Jun 7, 2026

Copy link
Copy Markdown
Member

Major Changes in this PR

  • Added detecting toolhead runout for standalone toolhead for toolchangers
  • Fixed infinite runout to work correctly with toolchangers
  • Added parking and wiping when waiting for next extruder temperature
  • Fixing and updating unit tests

Note to reviewers

This PR is not meant to allow toolhead runout for toolheads that have units attached to them, that should be address with issue #729

How the changes in this PR are tested

On my Voron 2.4 toolchanger and Snapmaker U1

2.4 toolchanger logs:

Lane 2   connected to extruder1 in direct mode
Lane 3/4 connected to extruder2 via hub
09:19:51 In print: True, Filename: testing/Cube_PLA_t0_22m28s.gcode
09:19:51 Filament change count metadata not found for file:testing/Cube_PLA_t0_22m28s.gcode
09:19:51 Total number of toolchanges set to 0
09:25:48 Infinite Spool triggered for lane3
09:25:48 Heating next extruder: extruder1 to 220.0
09:25:48 Tool Change - lane3 -> lane2
09:25:52 Unloading lane3
09:25:52 Setting extruder temperature to 210.0 
09:26:21 Lane lane3 unload done t:32.313
09:26:36 LANE lane3 eject done
09:26:36 Cooling down last extruder: extruder2 to 0
09:26:36 Heating and waiting for extruder1 for infinite runout
09:26:36 Parking while waiting for extruder to heat.
09:27:01 extruder1 heated and ready to print
09:27:01 Wiping ooze...
09:27:22 Tool swap done (Δt:18.336s, t:93.298)
09:27:22 Total change time: t:93.325

09:27:33 Infinite Spool triggered for lane2
09:27:33 Heating next extruder: extruder2 to 220.0
09:27:33 Tool Change - lane2 -> lane4
09:27:36 Unloading lane2
09:27:36 Setting extruder temperature to 210.0 
09:28:06 Lane lane2 unload done t:33.174
09:28:12 LANE lane2 eject done
09:28:12 Cooling down last extruder: extruder1 to 0
09:28:12 Heating and waiting for extruder2 for infinite runout
09:28:12 Parking while waiting for extruder to heat.
09:28:12 extruder2 heated and ready to print
09:28:12 Wiping ooze...
09:28:33 Tool swap done (Δt:17.973s, t:59.937)
09:28:33 Loading lane4
09:28:50 lane4 is now loaded in toolhead t:44.481
09:28:51 Total change time: t:77.691

Extruder4 is setup as standalone lane
This test is testing toolhead runout for standalone lanes
10:30:50 Total number of toolchanges set to 0
10:37:05 Cannot trigger auto load/unload when toolhead is actively printing
10:37:05 Infinite Spool triggered for extruder4
10:37:05 Heating next extruder: extruder2 to 220.0
10:37:05 Tool Change - extruder4 -> lane4
10:37:22 Tool swap done (Δt:14.446s, t:17.853)
10:37:23 Cooling down last extruder: extruder4 to 0
10:37:23 Heating and waiting for extruder2 for infinite runout
10:37:23 Parking while waiting for extruder to heat.
10:38:23 extruder2 heated and ready to print
10:38:23 Wiping ooze...
10:38:24 Total change time: t:79.843

Snapmaker log

both extruder1/2 are setup in standalone mode, verifying that it work on snapmaker
12:30:24 Cannot trigger auto load/unload when toolhead is actively printing
12:30:24 Infinite Spool triggered for extruder2
12:30:24 Heating next extruder: extruder1 to 220.0
12:30:24 Tool Change - extruder2 -> extruder1
12:30:27 Running custom select: PICK_EXTRUDER1
12:30:27 // extruder2 -> extruder1
12:30:27 // park extruder2 !!!
12:30:29 // pick extruder1 !!!
12:30:31 // Activating extruder extruder1
12:30:36 Tool swap done (Δt:8.648s, t:11.363)
12:30:36 Cooling down last extruder: extruder2 to 0
12:30:36 Heating and waiting for extruder1 for infinite runout
12:30:36 Parking while waiting for extruder to heat.
12:30:51 extruder1 heated and ready to print
12:30:51 Wiping ooze...
12:30:51 Total change time: t:26.374
12:30:51 // Success: Changed main state to PRINTING
12:31:53 Cannot trigger auto load/unload when toolhead is actively printing
12:31:53 !! Runout triggered for lane extruder1 and runout lane is not setup to switch to another lane.
Please manually load next spool into toolhead and then hit resume to continue.
12:31:53 PAUSING
12:31:53 // Pausing...

PR Checklist: (Checked-off items are either done or do not apply to this PR)

  • I have performed a self-review of my code
  • CHANGELOG.md is updated (if end-user facing)
  • Documentation updated in AT-Documentation repository
  • Sent notification to software-design/software-discussions channel (as appropriate) requesting review
  • If this PR address a GitHub issue, the issue number is referenced in the PR description

NOTE: GitHub Copilot may be used for automated code reviews, however as it is an automated system, it's suggestions
may not be correct. Please do not rely on it to catch all issues. Please review any suggestions it makes and use your
own judgement to determine if they are correct.

Summary by CodeRabbit

  • New Features

    • Toolhead sensor runout support for standalone toolheads with toolchangers
    • Optional park and wipe actions during tool changes
  • Bug Fixes

    • Corrected infinite-runout behavior for toolchangers and single-toolhead printers
    • Suppresses auto load/unload while printing
    • More reliable forced unloading and spool ejection during infinite-runout transitions
  • Tests

    • Expanded unit tests covering toolchange, runout, park/wipe, and lane-change scenarios
  • Documentation

    • Added 2026-06-07 changelog entry

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: c8dd9a6f-d4a1-47b8-9bee-6df0b1b0cf00

📥 Commits

Reviewing files that changed from the base of the PR and between 25da48f and cc99096.

📒 Files selected for processing (2)
  • extras/AFC.py
  • tests/test_AFC.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • extras/AFC.py

📝 Walkthrough

Walkthrough

Refactors infinite-runout changeover and TOOL_UNLOAD behavior, adds standalone toolhead sensor runout support and related lane/extruder state flags, updates small function callsites and release notes, and expands and refactors unit tests and fixtures for the new flows.

Changes

Infinite Runout & Standalone Toolhead Implementation & Validation

Layer / File(s) Summary
All changes (core, lane, extruder, tests, changelog)
extras/AFC.py, extras/AFC_lane.py, extras/AFC_extruder.py, extras/AFC_functions.py, CHANGELOG.md, tests/*
Implements FORCE_UNLOAD option in TOOL_UNLOAD and updates CHANGE_TOOL sequencing for infinite runout (park/wipe, conditional LANE_UNLOAD, cooldown logic); refactors AFCLane infinite-runout flow to use CHANGE_TOOL, adds normal_toolchange parameter to lane load/unload methods and callsites; rewires extruder filament sensor callbacks for standalone toolheads and adjusts ready/runout handling and LED behavior; updates unset_lane_loaded callsite; adds changelog entry and extensive test additions/refactors and fixture mocks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • ejsears
  • kekiefer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jimmyjon711 jimmyjon711 requested review from ejsears and kekiefer June 7, 2026 18:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
CHANGELOG.md (1)

10-10: 💤 Low value

Minor wording improvement for clarity.

The phrase "for standalone toolheads for toolchangers" contains awkward repetition. Consider rewording for better readability.

📝 Suggested rewording
-- Added support for toolhead sensor runout for standalone toolheads for toolchangers
+- Added support for toolhead sensor runout on standalone toolheads in toolchanger setups

or

-- Added support for toolhead sensor runout for standalone toolheads for toolchangers
+- Added support for toolhead sensor runout for standalone toolheads in toolchangers
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 10, The changelog line "Added support for toolhead
sensor runout for standalone toolheads for toolchangers" is repetitive; update
the phrasing in CHANGELOG.md (the affected changelog entry) to a clearer form
such as "Added support for toolhead sensor runout on standalone toolheads used
in toolchangers" or "Added support for toolhead sensor runout for standalone
toolheads in toolchangers" to remove the duplicate "for" and improve
readability.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@extras/AFC_lane.py`:
- Around line 1590-1603: The unconditional cleanup after runout corrupts a
newly-loaded lane because _perform_infinite_runout() lets CHANGE_TOOL load the
new lane but the subsequent set_tool_unloaded() calls clear the active spool;
modify the logic in the branch that calls
_perform_infinite_runout()/_perform_pause_runout() so that set_tool_unloaded(),
set_unloaded(), and afc.save_vars() are only executed when infinite runout was
NOT performed (e.g., check self.runout_lane/is_standalone or set a local flag
like performed_infinite_runout when calling _perform_infinite_runout() and skip
the unload/save calls when that flag is true).

In `@extras/AFC.py`:
- Around line 2248-2264: The park/wipe macros use unload_lane which can be
undefined when current_lane_name is None; guard those calls by checking
unload_lane (or current_lane_name) before invoking
self.gcode.run_script_from_command: where you see the blocks referencing
unload_lane and calling f"{self.park_cmd}
EXTRUDER={unload_lane.extruder_obj.name}" and f"{self.wipe_cmd}
EXTRUDER={unload_lane.extruder_obj.name}" (and the surrounding if
self.park/self.wipe checks), wrap or augment those conditionals to ensure
unload_lane is defined (e.g., if unload_lane is not None and self.park and
self.park_cmd is not None) so the park/wipe macros are only executed when
unload_lane exists.

In `@tests/test_AFC_extruder.py`:
- Line 1086: Replace the non-specific assertion on the mock with a
cardinality-specific one: change the call to ext.afc.save_vars.assert_called()
to ext.afc.save_vars.assert_called_once() so the test fails if save_vars is
invoked more than once; update the assertion in the test that references
ext.afc.save_vars to use assert_called_once() to enforce a single call.

In `@tests/test_AFC_vivid.py`:
- Line 537: The tests set lane.raw_load_state to a PropertyMock instance on the
MagicMock itself, making the attribute truthy and preventing the prep_load(lane)
loop from observing the mocked property behavior; instead patch the property on
the lane's class so property access triggers side_effects. Replace assignments
like lane.raw_load_state = PropertyMock(...) with a patch.object call on
type(lane) for "raw_load_state" using new_callable=PropertyMock (create=True)
and set mock_prop.side_effect = [...] before calling unit.prep_load(lane); do
the same for the second test (line ~558) and ensure prep_state remains correctly
set so the loop conditions exercise the property reads.

---

Nitpick comments:
In `@CHANGELOG.md`:
- Line 10: The changelog line "Added support for toolhead sensor runout for
standalone toolheads for toolchangers" is repetitive; update the phrasing in
CHANGELOG.md (the affected changelog entry) to a clearer form such as "Added
support for toolhead sensor runout on standalone toolheads used in toolchangers"
or "Added support for toolhead sensor runout for standalone toolheads in
toolchangers" to remove the duplicate "for" and improve readability.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: a1989b47-2fad-4de0-8952-6e20761125bd

📥 Commits

Reviewing files that changed from the base of the PR and between 5be711d and 113d077.

⛔ Files ignored due to path filters (1)
  • .coderabbit/rules.yaml is excluded by !.coderabbit/**
📒 Files selected for processing (12)
  • CHANGELOG.md
  • extras/AFC.py
  • extras/AFC_extruder.py
  • extras/AFC_lane.py
  • tests/conftest.py
  • tests/test_AFC.py
  • tests/test_AFC_BoxTurtle.py
  • tests/test_AFC_error.py
  • tests/test_AFC_extruder.py
  • tests/test_AFC_hub.py
  • tests/test_AFC_lane.py
  • tests/test_AFC_vivid.py

Comment thread extras/AFC_lane.py Outdated
Comment thread extras/AFC.py Outdated
Comment thread tests/test_AFC_extruder.py Outdated
Comment thread tests/test_AFC_vivid.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
extras/AFC.py (1)

2236-2239: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the active spool after forced ejection.

This new infinite-runout path ejects the old lane with LANE_UNLOAD(), but it never clears the active spool. If the following TOOL_LOAD() fails or the printer pauses before another lane becomes active, spoolman still points at the ejected spool. extras/AFC_spool.py:240-252 already supports set_active_spool(None) for this state.

Proposed fix
                         if (force_unload
                             and not unload_lane.is_direct_hub()):
                             # Eject spool before loading next lane for infinite rollover
                             self.LANE_UNLOAD(unload_lane)
+                            self.spool.set_active_spool(None)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extras/AFC.py` around lines 2236 - 2239, The infinite-rollover path calls
LANE_UNLOAD(unload_lane) but never clears the active spool, so after the forced
ejection (inside the branch with force_unload and not
unload_lane.is_direct_hub()) call the spool manager's set_active_spool(None) to
clear the pointer; locate the branch where LANE_UNLOAD(unload_lane) is invoked
and add a call to set_active_spool(None) (using the same manager object used
elsewhere in AFC, matching extras/AFC_spool.py's set_active_spool signature) so
the active spool is cleared before any subsequent TOOL_LOAD() or pause.
🧹 Nitpick comments (3)
tests/test_AFC_vivid.py (2)

447-447: 💤 Low value

Consolidate PropertyMock import with line 14.

PropertyMock is being imported here, but unittest.mock is already imported at line 14. Consider adding PropertyMock to the line 14 import statement for consistency.

♻️ Consolidate imports

At line 14:

-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, patch, PropertyMock

Then remove line 447:

-from unittest.mock import PropertyMock
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_AFC_vivid.py` at line 447, The test imports duplicate PropertyMock
separately; add PropertyMock to the existing unittest.mock import on the earlier
import line (the import that currently imports Mock/patch/etc.) and remove the
standalone "from unittest.mock import PropertyMock" import so all unittest.mock
names are consolidated into a single import; update references to PropertyMock
if needed to use the consolidated import name.

549-563: ⚡ Quick win

Use consistent PropertyMock pattern for raw_load_state.

Line 559 assigns PropertyMock directly to the lane instance attribute, which doesn't work as a property descriptor (it becomes a truthy object instead). While this doesn't affect this test's behavior (since prep_state = False short-circuits the loop condition and raw_load_state is never accessed), it's inconsistent with the refactored pattern used in all other prep_load tests in this file (lines 461, 497, 517, 537).

For maintainability and semantic correctness, use _make_afc_lane() and the patch.object(type(lane), "raw_load_state", new_callable=PropertyMock) pattern.

♻️ Align with the refactoring pattern
 def test_uncalibrated_lane_updates_dist_hub_no_prep(self):
-    from unittest.mock import PropertyMock
     unit = _make_vivid()
-    lane = MagicMock()
+    lane = _make_afc_lane()
     lane.calibrated_lane = False
     lane.prep_state = False
-    lane.move_to.return_value = (True, 300.0, False)
+    lane.move_to = MagicMock(return_value=(True, 300.0, False))
     unit.lane_loading = MagicMock()
     unit.select_lane = MagicMock()
     unit.lane_loaded = MagicMock()
-    lane.raw_load_state = PropertyMock(side_effect=[False])
-
-    unit.prep_load(lane)
+    with patch.object(type(lane), "raw_load_state", new_callable=PropertyMock) as mock_prop:
+        mock_prop.return_value = False
+        unit.prep_load(lane)

     assert lane.calibrated_lane is False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_AFC_vivid.py` around lines 549 - 563, The test
test_uncalibrated_lane_updates_dist_hub_no_prep should use the same PropertyMock
pattern as other tests: create the lane via _make_afc_lane()/or _make_vivid()
helper and replace the direct assignment lane.raw_load_state = PropertyMock(...)
with a patch.object on the lane's class (e.g. patch.object(type(lane),
"raw_load_state", new_callable=PropertyMock, side_effect=[False])) so
raw_load_state becomes a proper property descriptor; update imports/usages
accordingly and keep lane.calibrated_lane, lane.prep_state, lane.move_to,
unit.select_lane, unit.lane_loading, and unit.lane_loaded interactions
unchanged.
tests/test_AFC_lane.py (1)

1528-1535: 💤 Low value

Unused variable sensor can be removed.

The variable sensor = "None" is declared but never used since handle_toolhead_runout() is called without arguments.

♻️ Suggested cleanup
 def test_standalone_runout_disabled_None(self):
-    sensor = "None"
     lane = self._make_lane_for_toolhead_runout(standalone=True)
     lane.extruder_obj.fila_tool_end.runout_helper.sensor_enabled = False
     lane.handle_toolhead_runout()

     warn_msgs = [m for lvl, m in lane.logger.messages if lvl == "warning"]
     assert any("toolhead runout has been detected," in m for m in warn_msgs)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_AFC_lane.py` around lines 1528 - 1535, Remove the unused local
variable `sensor = "None"` from the test `test_standalone_runout_disabled_None`;
the test calls `lane.handle_toolhead_runout()` with no arguments so `sensor` is
never used—delete the `sensor` assignment line in that test to clean up the code
and avoid the unused-variable warning.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@extras/AFC.py`:
- Around line 2236-2239: The infinite-rollover path calls
LANE_UNLOAD(unload_lane) but never clears the active spool, so after the forced
ejection (inside the branch with force_unload and not
unload_lane.is_direct_hub()) call the spool manager's set_active_spool(None) to
clear the pointer; locate the branch where LANE_UNLOAD(unload_lane) is invoked
and add a call to set_active_spool(None) (using the same manager object used
elsewhere in AFC, matching extras/AFC_spool.py's set_active_spool signature) so
the active spool is cleared before any subsequent TOOL_LOAD() or pause.

---

Nitpick comments:
In `@tests/test_AFC_lane.py`:
- Around line 1528-1535: Remove the unused local variable `sensor = "None"` from
the test `test_standalone_runout_disabled_None`; the test calls
`lane.handle_toolhead_runout()` with no arguments so `sensor` is never
used—delete the `sensor` assignment line in that test to clean up the code and
avoid the unused-variable warning.

In `@tests/test_AFC_vivid.py`:
- Line 447: The test imports duplicate PropertyMock separately; add PropertyMock
to the existing unittest.mock import on the earlier import line (the import that
currently imports Mock/patch/etc.) and remove the standalone "from unittest.mock
import PropertyMock" import so all unittest.mock names are consolidated into a
single import; update references to PropertyMock if needed to use the
consolidated import name.
- Around line 549-563: The test test_uncalibrated_lane_updates_dist_hub_no_prep
should use the same PropertyMock pattern as other tests: create the lane via
_make_afc_lane()/or _make_vivid() helper and replace the direct assignment
lane.raw_load_state = PropertyMock(...) with a patch.object on the lane's class
(e.g. patch.object(type(lane), "raw_load_state", new_callable=PropertyMock,
side_effect=[False])) so raw_load_state becomes a proper property descriptor;
update imports/usages accordingly and keep lane.calibrated_lane,
lane.prep_state, lane.move_to, unit.select_lane, unit.lane_loading, and
unit.lane_loaded interactions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ff91020-a993-40eb-9959-2252f064f001

📥 Commits

Reviewing files that changed from the base of the PR and between 113d077 and e0771ec.

📒 Files selected for processing (6)
  • extras/AFC.py
  • extras/AFC_functions.py
  • extras/AFC_lane.py
  • tests/test_AFC_extruder.py
  • tests/test_AFC_lane.py
  • tests/test_AFC_vivid.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_AFC_extruder.py

@kekiefer kekiefer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There were some behavioral changes to tool change temp drop that we can discuss out of band if you like. Maybe the unified code path is desirable (at least for the heater sequencing) but the park and wipe on reheat aren't needed for this case.

Comment thread extras/AFC.py
Comment thread extras/AFC.py
Comment thread tests/test_AFC.py Outdated

@ejsears ejsears left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a purely non-toolchanger, functional point of view (not code based), I couldn't find any regressions .

- Added back original hotend cooldown and added not infinite runout
  qualifier to this cooldown
- Added infinite rundown qualifier to hotend cooldown, park and wipe
  after TOOL_UNLOAD
@jimmyjon711 jimmyjon711 requested a review from kekiefer June 9, 2026 00:15
@kekiefer

kekiefer commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Lgtm

@jimmyjon711 jimmyjon711 merged commit de16e6b into AFCProject:DEV Jun 9, 2026
5 checks passed
@jimmyjon711 jimmyjon711 deleted the toolhead_runout branch June 9, 2026 02:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants