Skip to content

[RFC] Feature: Implement Region-Aware Filament Runout Pause#7274

Open
famtory wants to merge 9 commits into
Klipper3d:masterfrom
famtory:smart-runout
Open

[RFC] Feature: Implement Region-Aware Filament Runout Pause#7274
famtory wants to merge 9 commits into
Klipper3d:masterfrom
famtory:smart-runout

Conversation

@famtory

@famtory famtory commented May 26, 2026

Copy link
Copy Markdown

I would like to propose a region-aware filament runout pause mechanism for filament_switch_sensor / filament_motion_sensor (RunoutHelper).

Why this feature is needed:
Pausing immediately upon filament runout often leaves an ugly scar on the outer wall of the print. By delaying the pause until the toolhead reaches an infill area (where defects are hidden inside), we can protect the cosmetic print quality.

How it works:

  1. The slicer emits SET_PRINT_FEATURE FEATURE=<N> (1: Outer Wall, 2: Infill, 3: Other) on role changes.
  2. RunoutHelper registers the SET_PRINT_FEATURE command and uses toolhead.register_lookahead_callback to synchronize the active feature state with the lookahead queue.
  3. If a runout is detected while printing the outer wall, the helper defers the pause event and starts a periodic timer checking the physical print head position.
  4. The pause is triggered as soon as the print head moves to an infill area, or if the printed distance since the runout triggers exceeds runout_distance (safety margin, user-configurable).

Current Status:
I have implemented a working draft in klippy/extras/filament_switch_sensor.py where runout_distance defaults to 0.0 (disabled by default, requiring explicit user configuration to activate).

I would love to get the community's feedback on this approach and any suggestions regarding naming or architecture before moving forward.

Signed-off-by: Hwang Younsang famtory@gmail.com

@MRX8024

MRX8024 commented May 26, 2026

Copy link
Copy Markdown
Contributor

Are there any limitations why this can't be implemented through gcode macros and delayed_gcode methods?

Let's say, the slicer writes a key gcode command to the gcode file when switching to region "infill", that calls a macro. The macro checks whether the filament sensor has been triggered and, if so, sends the pause command. Along with this, the original "runout_gcode" parameter can set some global flag and activate delayed_gcode which will check the time or filament length, so that if we don't get to the "infill" region for a long time, the pause is still called.

This is just my opinion, but adding a new global gcode command to Klipper for such a niche functionality does not sound very practical.

@famtory

famtory commented May 26, 2026

Copy link
Copy Markdown
Author

@MRX8024
That is a very fair point! In fact, I also initially tried to implement this feature using custom G-code macros and checking a global flag at the infill transition. However, during testing, I realized this approach fails due to Klipper's lookahead and parsing behavior.

Here is the exact scenario of why a macro-based check does not work:

Imagine the slicer outputs the following G-code:

(Line 1) G1 X10 Y10 ; Outer wall (toolhead physically executing this)
(Line 2) G1 X20 Y20 ; Outer wall (filament physically runs out here)
(Line 3) CHECK_RUNOUT_PAUSE ; Macro inserted by slicer to check the flag
(Line 4) G1 X30 Y30 ; Infill start
  1. Pre-parsing Delay: While the toolhead is physically executing Line 1, Klipper's host parser is already parsing ahead and evaluates Line 3 (CHECK_RUNOUT_PAUSE).
  2. Premature Evaluation: At the moment Line 3 is parsed, the filament is still physically present (since the toolhead hasn't reached Line 2 yet). The flag filament_sensor_triggered is evaluated as 0 (false), so no pause is triggered, and Klipper discards the macro.
  3. Delayed Trigger: Shortly after, the toolhead physically reaches Line 2 and the filament runs out. The sensor triggers and sets the flag to 1 (true).
  4. Failure: However, because Line 3 was already parsed and bypassed, the toolhead will continue to print the infill (Line 4) and keep going without pausing. The pause will only trigger much later (e.g., at the next layer's infill transition, or when a safety timer expires).

Why Python is Necessary:

  1. Lookahead Callback Synchronization:
    By implementing this in Python, we can leverage toolhead.register_lookahead_callback. This registers a callback that triggers at the exact moment the toolhead physically executes that coordinate. This is the only way to synchronize the feature state with actual motion execution, which is not possible via standard G-code macros.

  2. Accurate Extrusion Distance Tracking:
    To enforce the safety runout_distance margin, we need to query the exact extruded filament length since the runout was triggered. Inside the python module, we can utilize self.extruder.find_past_position(print_time) to calculate the traveled distance. Doing this in G-code macros is extremely difficult and prone to errors.

  3. G-code Compatibility and Avoiding Conflicts:
    Using custom M-codes (like M603) often leads to namespace conflicts since different firmwares (Marlin, RepRapFirmware, etc.) reserve M-codes differently. A dedicated, named command like SET_PRINT_FEATURE avoids collisions and can be safely ignored by non-Klipper firmwares.

I hope this clarifies why we moved past the macro-based approach and opted for a native Python implementation with lookahead callbacks!

@dewi-ny-je

Copy link
Copy Markdown

Useful idea! I hope it won't get lost and end up in the "never reviewed" trashbin

famtory added 3 commits June 1, 2026 04:03
Signed-off-by: Hwang Younsang <famtory@gmail.com>
Signed-off-by: Hwang Younsang <famtory@gmail.com>
Signed-off-by: Hwang Younsang <famtory@gmail.com>
@famtory

famtory commented Jun 4, 2026

Copy link
Copy Markdown
Author

Hi @dewi-ny-je, thanks for the support! I really appreciate it.
It’s a feature that practically every 3D printing enthusiast needs to prevent failed prints, so I also keep my fingers crossed that the core maintainers will review and merge it soon! 🚀

@github-actions

Copy link
Copy Markdown

Thank you for your contribution to Klipper. Unfortunately, a reviewer has not assigned themselves to this GitHub Pull Request. All Pull Requests are reviewed before merging, and a reviewer will need to volunteer. Further information is available at: https://www.klipper3d.org/CONTRIBUTING.html

There are some steps that you can take now:

  1. Perform a self-review of your Pull Request by following the steps at: https://www.klipper3d.org/CONTRIBUTING.html#what-to-expect-in-a-review
    If you have completed a self-review, be sure to state the results of that self-review explicitly in the Pull Request comments. A reviewer is more likely to participate if the bulk of a review has already been completed.
  2. Consider opening a topic on the Klipper Discourse server to discuss this work. The Discourse server is a good place to discuss development ideas and to engage users interested in testing. Reviewers are more likely to prioritize Pull Requests with an active community of users.
  3. Consider helping out reviewers by reviewing other Klipper Pull Requests. Taking the time to perform a careful and detailed review of others work is appreciated. Regular contributors are more likely to prioritize the contributions of other regular contributors.

Unfortunately, if a reviewer does not assign themselves to this GitHub Pull Request then it will be automatically closed. If this happens, then it is a good idea to move further discussion to the Klipper Discourse server. Reviewers can reach out on that forum to let you know if they are interested and when they are available.

Best regards,
~ Your friendly GitIssueBot

PS: I'm just an automated script, not a human being.

@dewi-ny-je

Copy link
Copy Markdown

It would be useful to have an opinion by @KevinOConnor about the feature: once reviewed, would it be accepted?

@dewi-ny-je dewi-ny-je 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.

[PROBLEM] Missing documentation (hard requirement)
New config options runout_distance and extruder, the new SET_PRINT_FEATURE command, and the feature-code convention (0=other, 1=outer wall, 2=infill) are not documented. At minimum update docs/Config_Reference.md and docs/G-Codes.md. The units of runout_distance (mm of filament/extruder advance) and the slicer-integration requirement should be stated explicitly.

[PROBLEM] No test coverage
A behavioral feature with non-trivial state machine and timer logic ships with no regression test under test/klippy/. Given the dead-code bug above slipped through, a test exercising defer → reinsert and defer → distance-limit would be valuable.

[SUGGESTION] Magic numbers for feature codes
current_feature == 1 / == 2 appear in three places (_runout_distance_check_event, note_filament_present). Define named constants (e.g. FEATURE_OUTER_WALL = 1, FEATURE_INFILL = 2) for readability and to keep the slicer contract in one place.

[SUGGESTION] Redundant initialization
feature_history and deferred_runout are initialized in both __init__ and _handle_ready. Intentional (reset on restart) but worth a one-line comment, or consolidate.

[DISCUSSION] Cosmetic scope of deferral
Deferral triggers only when the runout occurs exactly during feature 1 (outer wall). Runouts during other visible features (e.g. top surfaces, bridges, which may map to 0) pause immediately. If the goal is "protect print cosmetics," the author may want to consider whether other visible features should defer too — or document that only outer walls are protected by design.

Things that are correct (verified)

  • extruder.find_past_position(print_time) and mcu.estimated_print_time(eventtime) both exist and are used correctly; runout_distance is effectively measured in mm of commanded extruder advance.
  • The lookahead-callback → broadcast-event pattern correctly aligns feature state with toolhead position across multiple sensors.
  • The single reused timer (register_timer once, update_timer) avoids leaking timers — good.

is_printing = idle_timeout.get_status(now)["state"] == "Printing"
# Perform filament action associated with status change (if any)
if is_filament_present:
if self.deferred_runout:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reinsertion-clearing branch is dead code during deferral
In note_filament_present, the runout branch sets self.min_event_systime = self.reactor.NEVER before starting deferral. While deferred, if filament is reinserted, the method is called with is_filament_present=True, but the guard if eventtime < self.min_event_systime ... is eventtime < NEVERalways True → early return. So the block:

if is_filament_present:
    if self.deferred_runout:
        self.deferred_runout = False
        logging.info("... filament reinserted, clearing deferred runout")

can never execute during the deferral window. The deferred runout will therefore still fire and pause the print even if the user reinserts filament in time — defeating the purpose of that branch. The reinsertion check needs to run before/independently of the min_event_systime gate (or the gate needs an exception for the deferred state).

self.printer.register_event_handler("filament_sensor:set_feature",
self._handle_set_feature)
try:
self.gcode.register_command(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] register_command with swallowed config_error
SET_PRINT_FEATURE is registered per-RunoutHelper via the global register_command, relying on except self.printer.config_error: pass to absorb the duplicate registration from subsequent sensors. This works (the actual feature data is distributed via the broadcast event), but a blanket except config_error: pass could mask unrelated registration errors. Consider registering the command once (module-level guard, or only the first instance), which also makes the intent explicit.

self.deferred_runout = False
self.reactor.register_callback(self._runout_event_handler)
return self.reactor.NEVER
return eventtime + 0.100

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] Hardcoded poll interval
The return eventtime + 0.100 re-arm interval in _runout_distance_check_event is a magic literal; a named constant documents intent.

current_feature = self._get_feature_at_time(print_time)
if current_feature == 1 and self.runout_distance > 0.:
self.deferred_runout = True
self.runout_trigger_pos = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] Initialize dynamic attribute in __init__
self.runout_trigger_pos is created only inside the deferral branch. It's safe today (only read when deferred_runout is True, set in the same branch), but initializing it in __init__ alongside deferred_runout/runout_check_timer is more robust and consistent.

famtory added 2 commits June 20, 2026 13:43
Signed-off-by: Hwang Younsang <famtory@gmail.com>
- Fix dead code bug in reinsertion clearing logic during deferred runout.
- Add module-level constants for feature codes and poll interval.
- Consolidate and clarify variable initialization in RunoutHelper.
- Safely handle config_error during command registration.

Signed-off-by: Hwang Younsang <famtory@gmail.com>
@famtory

famtory commented Jun 20, 2026

Copy link
Copy Markdown
Author

Hi @dewi-ny-je,
Wow, thank you so much for the incredibly detailed and insightful review! I really appreciate the thorough line-by-line feedback.

I have just pushed a new commit to address all of your suggestions:

  1. Dead Code Bug Fixed: Moved the reinsertion logic out of the min_event_systime gate so that deferred runouts correctly clear when filament is reinserted in time.
  2. Magic Numbers & Hardcoded Poll: Defined FEATURE_OUTER_WALL = 1, FEATURE_INFILL = 2, and POLL_INTERVAL = 0.100 as module-level constants.
  3. Initialization: Consolidated and clarified the initialization of feature_history, deferred_runout, and runout_trigger_pos in __init__.
  4. Command Registration: Refactored the SET_PRINT_FEATURE registration to explicitly check for duplicate commands rather than swallowing the general config_error.
  5. Cosmetic Scope: (Regarding deferral scoping) For now, the goal is specifically to protect outer wall cosmetics since they are the most sensitive to scarring, whereas top surfaces or bridges might actually fail mechanically if under-extruded. I will make sure this is clear in any broader discussions.

Thanks again for catching that dead code logic and helping me polish this PR! Hopefully, this makes it easier for @KevinOConnor to review when he has the time.

Signed-off-by: Hwang Younsang <famtory@gmail.com>
@dewi-ny-je

dewi-ny-je commented Jun 20, 2026

Copy link
Copy Markdown

Thanks again for catching that dead code logic and helping me polish this PR! Hopefully, this makes it easier for @KevinOConnor to review when he has the time

First of all, I don't want credit I don't deserve: I spent the time to feed a LLM and I checked some answers, but it's still work of an LLM.
For smaller modules/modifications it works pretty well, as you noticed.

5. Cosmetic Scope: (Regarding deferral scoping) For now, the goal is specifically to protect outer wall cosmetics since they are the most sensitive to scarring, whereas top surfaces or bridges might actually fail mechanically if under-extruded. I will make sure this is clear in any broader discussions.

Yes the scope is cosmetic, but the idea you had is really good and it might be a good idea to apply it to bridges as well, since bridges are usually short enough and deferring pause would surely allow completion without any filament feed issue, while preserving the bridge which will definitely fail if printing is stopped and restarted.

I would add bridges to the scope, but it's only my personal opinion.

Signed-off-by: Hwang Younsang <famtory@gmail.com>
@famtory

famtory commented Jun 23, 2026

Copy link
Copy Markdown
Author

Hi @dewi-ny-je,

You raise an excellent point about bridges! A pause mid-bridge is practically a guaranteed failure due to the lack of underlying support and tension loss, whereas deferring the pause to let the bridge finish on residual filament is much safer.

I completely agree with your suggestion, so I have just pushed an update to include Bridges in the deferral scope.

  • Added FEATURE_BRIDGE = 3 alongside FEATURE_OUTER_WALL and FEATURE_INFILL to keep the code clean.
  • Updated the logic to defer runouts during both Outer Wall (1) and Bridge (3) features.
  • Updated docs/G-Codes.md to reflect 3 as a valid feature code for SET_PRINT_FEATURE.

Thanks again for the thoughtful feedback. Your insights are really helping shape this into a much more robust feature!

@dewi-ny-je

Copy link
Copy Markdown

@famtory can you check the following remarks?
I can follow the explanation provided by LLM and it seems correct to me.


"reinsert to cancel a pending runout" behavior does not work

Every place min_event_systime and deferred_runout are written or read:

Line Code Role
42 self.min_event_systime = self.reactor.NEVER initial value (__init__)
64 self.min_event_systime = self.reactor.monotonic() + 2. set finite on ready
128 self.min_event_systime = self.reactor.monotonic() + self.event_delay only place it returns to finite after a runout/insert (inside _exec_gcode)
134 if eventtime < self.min_event_systime or not self.sensor_enabled:return the gate at the top of note_filament_present
144–149 if is_filament_present:if self.deferred_runout:self.deferred_runout = False + log the reinsertion-clear branch
159 self.min_event_systime = self.reactor.NEVER runout branch, set before deferral starts
169 self.deferred_runout = True the only place deferral is armed
97 / 107 self.deferred_runout = False (in _runout_distance_check_event) deferral resolved by infill / distance

The core invariant

deferred_runout is set to True in exactly one place — line 169. But just ten lines earlier, in the same elif block, line 159 has already set min_event_systime = self.reactor.NEVER:

157        elif is_printing and self.runout_gcode is not None:
158            # runout detected
159            self.min_event_systime = self.reactor.NEVER     # <-- gate slammed shut
...
166            if current_feature in (self.FEATURE_OUTER_WALL,
167                                   self.FEATURE_BRIDGE) \
168                    and self.runout_distance > 0.:
169                self.deferred_runout = True                 # <-- deferral armed

So the instant deferred_runout becomes True, min_event_systime is already NEVER.

Now, when is min_event_systime restored to a finite value? Only at line 128, inside _exec_gcode. And _exec_gcode is reached only via _runout_event_handler (line 120), which the deferral timer schedules at line 98 or 108 — and both of those lines run after deferred_runout has already been set back to False (lines 97 / 107):

97             self.deferred_runout = False
98             self.reactor.register_callback(self._runout_event_handler)

Therefore there is no moment in time where deferred_runout == True and min_event_systime is finite. For the entire duration of the deferral window, min_event_systime == NEVER.

Why that makes lines 145–149 unreachable

When the user reinserts filament during the deferral window, note_filament_present(eventtime, True) runs:

  1. Line 130: is_filament_present (True) == self.filament_present (False) → not equal, so it does not early-return here.
  2. Line 132: self.filament_present = True (state updated).
  3. Line 134: if eventtime < self.min_event_systimeeventtime < NEVER. reactor.NEVER is effectively +inf, so this is always Truereturn at line 138.

Execution never reaches line 144, so the block at lines 145–149:

145            if self.deferred_runout:
146                self.deferred_runout = False
147                logging.info(
148                    "Filament Sensor %s: filament reinserted, "
149                    "clearing deferred runout" % (self.name,))

can never execute while a deferral is active — which is the only situation it was written for. It is dead code.

Observable consequence

  • User is printing an outer wall / bridge, filament runs out → runout is deferred (line 169), timer starts polling _runout_distance_check_event.
  • User notices and reinserts filament before the head reaches infill or before runout_distance mm are consumed.
  • The reinsertion is silently swallowed at line 138. deferred_runout stays True.
  • The timer keeps running and, at line 93 (infill) or line 102 (distance reached), fires _runout_event_handler → the printer pauses anyway, despite filament being present.

So the advertised "reinsert to cancel a pending runout" behavior does not work. (Note also that because of the same early return, an insert during deferral can't trigger insert_gcode at lines 150–156 either — though that path also requires not is_printing, so it's less likely to matter.)

Fix direction

The reinsertion/deferral-cancel check must run before (or be exempted from) the min_event_systime gate at line 134. For example, handle the deferred-cancel case immediately after line 132 and return, e.g.:

self.filament_present = is_filament_present
if is_filament_present and self.deferred_runout:
    self.deferred_runout = False
    logging.info("Filament Sensor %s: filament reinserted, "
                 "clearing deferred runout" % (self.name,))
    return
if eventtime < self.min_event_systime or not self.sensor_enabled:
    return

That keeps the existing gate intact for the normal debounce/event-delay purposes while letting a genuine reinsertion cancel an in-flight deferral.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants