heater_fan: add delegate mode to impose a speed floor on an existing fan#875
Open
dderg wants to merge 15 commits intoKalicoCrew:mainfrom
Open
heater_fan: add delegate mode to impose a speed floor on an existing fan#875dderg wants to merge 15 commits intoKalicoCrew:mainfrom
dderg wants to merge 15 commits intoKalicoCrew:mainfrom
Conversation
…fective Fix: Mainsail and similar dashboards bind their fan slider to the value key in get_status, which derives from last_req_value. Task 2 inadvertently started setting last_req_value from _apply_speed's input, which is the effective (post-floor) speed. That caused M107 while a floor was active to still report the floor speed as user intent. Restore pre-change semantics: last_req_value tracks only what set_speed and set_speed_from_command were called with. _apply_speed no longer mutates it. Floor updates don't touch it.
…speed Previously merged target.get_status() + floor, so power/value/speed all came from the target fan. UI cards for [heater_fan hotend] then showed the user's M106 command to the shared part fan, which was confusing. Now delegate-mode get_status reports last_speed (the floor being applied right now: 0 when heater cold, fan_speed when hot) as power/value/speed. rpm still comes from the target fan since that's the physical hardware reading.
Webhooks can query object status before handle_ready runs (e.g. during an MCU connect retry), at which point _delegate_target is still None. The previous code fell through to the classic branch and crashed on self.fan.get_status() since self.fan is also None in delegate mode. Branch on _delegate_ref (set in __init__) instead, and return a status with rpm=None until the target is resolved.
6ca97af to
e9c0c1f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lets
[heater_fan]reference an existing fan config section with a newfan:option instead of owning its own PWM pin. In that mode the heater_fan doesn't drive any hardware — it imposes a speed floor on the referenced fan while its heater is active. The referenced fan still responds normally to M106/M107 / SET_FAN_SPEED above the floor.Motivation
On some toolheads the part-cooling fan is physically ducted so it also cools the hotend. Today you can't express "run the part fan at >= 40% whenever the extruder is hot" without giving the fan up entirely to
[heater_fan]and losing M106/M107 control. This PR closes that gap without touching classic[heater_fan]behavior.Example
With the extruder above 50 °C, the part fan is held at >= 40% regardless of M106/M107. When it cools, the fan returns to the user's last commanded speed.
fan:accepts a bare section name (fan) or a fully-qualified one (fan_generic my_fan,heater_fan other).pin:andfan:are mutually exclusive.Design
FanFloorRegistryinklippy/extras/fan.py. Pure-Python state machine that tracks the caller's last speed plus a dict of named floors; returnsmax(user_speed, max(floors)). One instance composed perfan.Fan.[heater_fan]. Whenfan:is set,__init__skips its ownfan.Fan(config, ...).handle_readyresolvesprinter.lookup_object(ref), validates the target exposes afan.Fan, and callsregister_floor(section_name). The 1 Hz callback updates that floor instead of driving a pin.New public methods on
fan.Fan:register_floor(source_id),update_floor(source_id, speed, print_time=None). All existingFanbehavior (kick-start, min/max power, shutdown_speed) applies to the effective speed unchanged. Multiple delegates on the same target stack viamax().Status reporting
[fan])get_status.valuereports user's commanded speed, so the UI slider reflects M106 intent even while a floor is holding the fan up physically.get_statusreports its own floor state:power/value/speed/floorall equal the current floor (0 cold,fan_speedhot);rpmis taken from the target fan.Classic
[heater_fan](withpin:) is behaviorally unchanged.Tests
test/klippy/test_fan_floor.py, 13 pytest tests):FanFloorRegistrystate machine andfan.Fanwiring via a stubbed gcrq.test/klippy/heater_fan_delegate*.cfg/.test): one success path with M106/M107 exercise + threeSHOULD_FAILfixtures for the three new config errors.Docs
docs/Config_Reference.md—fan:option documented under[heater_fan], with status-shape notes.Uploading Kapture 2026-04-21 at 23.48.22.mp4…
Checklist