Skip to content

heater_fan: add delegate mode to impose a speed floor on an existing fan#875

Open
dderg wants to merge 15 commits intoKalicoCrew:mainfrom
dderg:heater-fan-delegate
Open

heater_fan: add delegate mode to impose a speed floor on an existing fan#875
dderg wants to merge 15 commits intoKalicoCrew:mainfrom
dderg:heater-fan-delegate

Conversation

@dderg
Copy link
Copy Markdown
Contributor

@dderg dderg commented Apr 21, 2026

Summary

Lets [heater_fan] reference an existing fan config section with a new fan: 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

[fan]
pin: PA0            # ordinary part fan, still driven by M106/M107

[heater_fan hotend]
fan: fan            # delegate — no pin of its own
heater: extruder    # existing option
heater_temp: 50     # existing option
fan_speed: 0.4      # existing option — becomes the floor

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: and fan: are mutually exclusive.

Design

  1. FanFloorRegistry in klippy/extras/fan.py. Pure-Python state machine that tracks the caller's last speed plus a dict of named floors; returns max(user_speed, max(floors)). One instance composed per fan.Fan.
  2. Delegate mode on [heater_fan]. When fan: is set, __init__ skips its own fan.Fan(config, ...). handle_ready resolves printer.lookup_object(ref), validates the target exposes a fan.Fan, and calls register_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 existing Fan behavior (kick-start, min/max power, shutdown_speed) applies to the effective speed unchanged. Multiple delegates on the same target stack via max().

Status reporting

  • Target fan ([fan]) get_status.value reports user's commanded speed, so the UI slider reflects M106 intent even while a floor is holding the fan up physically.
  • Delegate heater_fan get_status reports its own floor state: power/value/speed/floor all equal the current floor (0 cold, fan_speed hot); rpm is taken from the target fan.

Classic [heater_fan] (with pin:) is behaviorally unchanged.

Tests

  • Unit (test/klippy/test_fan_floor.py, 13 pytest tests): FanFloorRegistry state machine and fan.Fan wiring via a stubbed gcrq.
  • Regression (test/klippy/heater_fan_delegate*.cfg/.test): one success path with M106/M107 exercise + three SHOULD_FAIL fixtures for the three new config errors.
  • Hardware: validated on a Trident (stm32h723).

Docs

docs/Config_Reference.mdfan: option documented under [heater_fan], with status-shape notes.


Uploading Kapture 2026-04-21 at 23.48.22.mp4…

Checklist

  • PR title makes sense
  • Added a test case if possible
  • If new feature, added to the README
  • CI is happy and green

dderg added 12 commits April 21, 2026 21:48
…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.
@dderg dderg force-pushed the heater-fan-delegate branch from 6ca97af to e9c0c1f Compare April 21, 2026 22:05
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.

1 participant