From cbacec1a02b3f0c8caa99ae7d5fd3dd97fb44af0 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 21:48:47 +0200 Subject: [PATCH 01/15] fan: add FanFloorRegistry helper with unit tests --- klippy/extras/fan.py | 34 +++++++++++++++ test/klippy/test_fan_floor.py | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 test/klippy/test_fan_floor.py diff --git a/klippy/extras/fan.py b/klippy/extras/fan.py index 1e8dfe5082..2dcbad1a4d 100644 --- a/klippy/extras/fan.py +++ b/klippy/extras/fan.py @@ -6,6 +6,40 @@ from . import output_pin, pulse_counter +class FanFloorRegistry: + # Tracks the last user-requested fan speed alongside zero or more named + # speed "floors" registered by other modules (e.g. `[heater_fan]` in + # delegate mode). The effective speed returned is always + # `max(user_speed, max(floors))`. + def __init__(self): + self._user_speed = 0.0 + self._floors = {} + + def register_floor(self, source_id): + if source_id in self._floors: + raise ValueError( + "fan floor %r already registered" % (source_id,) + ) + self._floors[source_id] = 0.0 + + def update_floor(self, source_id, speed): + if source_id not in self._floors: + raise KeyError( + "fan floor %r not registered" % (source_id,) + ) + self._floors[source_id] = speed + return self._effective() + + def set_user_speed(self, speed): + self._user_speed = speed + return self._effective() + + def _effective(self): + if not self._floors: + return self._user_speed + return max(self._user_speed, max(self._floors.values())) + + class Fan: def __init__(self, config, default_shutdown_speed=0.0): self.printer = config.get_printer() diff --git a/test/klippy/test_fan_floor.py b/test/klippy/test_fan_floor.py new file mode 100644 index 0000000000..431902a2c6 --- /dev/null +++ b/test/klippy/test_fan_floor.py @@ -0,0 +1,79 @@ +# Unit tests for FanFloorRegistry (klippy/extras/fan.py). +# +# These tests cover the pure Python speed-combine logic that backs +# `[heater_fan]` delegate mode. They deliberately avoid instantiating +# `fan.Fan` because that requires MCU setup. +import pytest + +from klippy.extras.fan import FanFloorRegistry + + +def test_no_floors_returns_user_speed(): + r = FanFloorRegistry() + assert r.set_user_speed(0.0) == 0.0 + assert r.set_user_speed(0.5) == 0.5 + assert r.set_user_speed(1.0) == 1.0 + + +def test_single_floor_below_user_speed(): + r = FanFloorRegistry() + r.register_floor("hotend") + r.set_user_speed(0.8) + assert r.update_floor("hotend", 0.4) == 0.8 + + +def test_single_floor_above_user_speed(): + r = FanFloorRegistry() + r.register_floor("hotend") + r.set_user_speed(0.2) + assert r.update_floor("hotend", 0.4) == 0.4 + + +def test_m107_while_floor_active(): + r = FanFloorRegistry() + r.register_floor("hotend") + r.update_floor("hotend", 0.4) + assert r.set_user_speed(0.0) == 0.4 + + +def test_floor_drops_back_to_user_speed(): + r = FanFloorRegistry() + r.register_floor("hotend") + r.set_user_speed(0.0) + r.update_floor("hotend", 0.4) + assert r.update_floor("hotend", 0.0) == 0.0 + + +def test_multiple_floors_take_max(): + r = FanFloorRegistry() + r.register_floor("hotend") + r.register_floor("bed") + r.set_user_speed(0.1) + r.update_floor("hotend", 0.4) + assert r.update_floor("bed", 0.3) == 0.4 + assert r.update_floor("bed", 0.5) == 0.5 + assert r.update_floor("hotend", 0.0) == 0.5 + assert r.update_floor("bed", 0.0) == 0.1 + + +def test_duplicate_register_raises(): + r = FanFloorRegistry() + r.register_floor("hotend") + with pytest.raises(ValueError): + r.register_floor("hotend") + + +def test_update_unknown_floor_raises(): + r = FanFloorRegistry() + with pytest.raises(KeyError): + r.update_floor("hotend", 0.4) + + +def test_set_user_speed_returns_effective_with_existing_floor(): + r = FanFloorRegistry() + r.register_floor("hotend") + r.update_floor("hotend", 0.4) + # User bumps above floor + assert r.set_user_speed(0.9) == 0.9 + # User drops below floor + assert r.set_user_speed(0.1) == 0.4 From 6a1229e06229858ece136a33140a8f4c576e7502 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 21:53:22 +0200 Subject: [PATCH 02/15] fan: route Fan.set_speed through FanFloorRegistry --- klippy/extras/fan.py | 14 ++++++-- test/klippy/test_fan_floor.py | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/klippy/extras/fan.py b/klippy/extras/fan.py index 2dcbad1a4d..75b6b7c63d 100644 --- a/klippy/extras/fan.py +++ b/klippy/extras/fan.py @@ -44,6 +44,7 @@ class Fan: def __init__(self, config, default_shutdown_speed=0.0): self.printer = config.get_printer() self.last_fan_value = self.last_req_value = 0.0 + self._floor_registry = FanFloorRegistry() self.last_pwm_value = 0.0 # Read config self.kick_start_time = config.getfloat( @@ -163,10 +164,19 @@ def _apply_speed(self, print_time, value): self.mcu_fan.set_pwm(print_time, pwm_value) def set_speed(self, value, print_time=None): - self.gcrq.send_async_request(value, print_time) + effective = self._floor_registry.set_user_speed(value) + self.gcrq.send_async_request(effective, print_time) def set_speed_from_command(self, value): - self.gcrq.queue_gcode_request(value) + effective = self._floor_registry.set_user_speed(value) + self.gcrq.queue_gcode_request(effective) + + def register_floor(self, source_id): + self._floor_registry.register_floor(source_id) + + def update_floor(self, source_id, speed, print_time=None): + effective = self._floor_registry.update_floor(source_id, speed) + self.gcrq.send_async_request(effective, print_time) def _handle_request_restart(self, print_time): self.set_speed(0.0, print_time) diff --git a/test/klippy/test_fan_floor.py b/test/klippy/test_fan_floor.py index 431902a2c6..6cfb2bfee8 100644 --- a/test/klippy/test_fan_floor.py +++ b/test/klippy/test_fan_floor.py @@ -77,3 +77,64 @@ def test_set_user_speed_returns_effective_with_existing_floor(): assert r.set_user_speed(0.9) == 0.9 # User drops below floor assert r.set_user_speed(0.1) == 0.4 + + +class _StubGCRQ: + def __init__(self): + self.async_calls = [] + self.gcode_calls = [] + + def send_async_request(self, value, print_time=None): + self.async_calls.append((value, print_time)) + + def queue_gcode_request(self, value): + self.gcode_calls.append(value) + + +def _make_fan_with_stub(): + # Bypass Fan.__init__ — it needs printer/MCU plumbing we don't have. + # Instead construct a bare object with just the attributes the + # speed-dispatch methods touch, then bind the methods off the real + # class. + from klippy.extras.fan import Fan, FanFloorRegistry + + stub = _StubGCRQ() + + class _FanLike: + pass + + f = _FanLike() + f._floor_registry = FanFloorRegistry() + f.gcrq = stub + # Bind the real methods unchanged + f.set_speed = Fan.set_speed.__get__(f, _FanLike) + f.set_speed_from_command = Fan.set_speed_from_command.__get__(f, _FanLike) + f.register_floor = Fan.register_floor.__get__(f, _FanLike) + f.update_floor = Fan.update_floor.__get__(f, _FanLike) + return f, stub + + +def test_fan_set_speed_dispatches_effective_async(): + f, stub = _make_fan_with_stub() + f.register_floor("hotend") + f.update_floor("hotend", 0.4) + assert stub.async_calls[-1] == (0.4, None) + + f.set_speed(0.2) + # user 0.2 < floor 0.4 => effective 0.4 + assert stub.async_calls[-1] == (0.4, None) + + f.set_speed(0.9, print_time=12.5) + assert stub.async_calls[-1] == (0.9, 12.5) + + +def test_fan_set_speed_from_command_dispatches_effective_gcode(): + f, stub = _make_fan_with_stub() + f.register_floor("hotend") + f.update_floor("hotend", 0.3) + + f.set_speed_from_command(0.1) + assert stub.gcode_calls[-1] == 0.3 + + f.set_speed_from_command(0.8) + assert stub.gcode_calls[-1] == 0.8 From 5377f966aed279e6a9895fc80188d1d736dad595 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 21:58:05 +0200 Subject: [PATCH 03/15] fan: cover M107-while-floor and update_floor print_time paths --- test/klippy/test_fan_floor.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/klippy/test_fan_floor.py b/test/klippy/test_fan_floor.py index 6cfb2bfee8..0251691f37 100644 --- a/test/klippy/test_fan_floor.py +++ b/test/klippy/test_fan_floor.py @@ -127,6 +127,20 @@ def test_fan_set_speed_dispatches_effective_async(): f.set_speed(0.9, print_time=12.5) assert stub.async_calls[-1] == (0.9, 12.5) + # Drop user speed back down so the floor dominates, then verify + # update_floor forwards its print_time kwarg through to gcrq. + f.set_speed(0.0) + f.update_floor("hotend", 0.6, print_time=5.0) + assert stub.async_calls[-1] == (0.6, 5.0) + + +def test_fan_set_speed_zero_clamps_to_active_floor(): + f, stub = _make_fan_with_stub() + f.register_floor("hotend") + f.update_floor("hotend", 0.4) + f.set_speed(0.0) # M107 analog + assert stub.async_calls[-1] == (0.4, None) + def test_fan_set_speed_from_command_dispatches_effective_gcode(): f, stub = _make_fan_with_stub() From e1a49c0e0f7a398a2df463d620b233cbf130446c Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 22:01:49 +0200 Subject: [PATCH 04/15] heater_fan: add delegate mode via fan: option --- klippy/extras/heater_fan.py | 44 +++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/klippy/extras/heater_fan.py b/klippy/extras/heater_fan.py index 27fada5993..14358a5e82 100644 --- a/klippy/extras/heater_fan.py +++ b/klippy/extras/heater_fan.py @@ -16,21 +16,58 @@ def __init__(self, config): self.heater_names = config.getlist("heater", ("extruder",)) self.heater_temp = config.getfloat("heater_temp", 50.0) self.heaters = [] - self.fan = fan.Fan(config, default_shutdown_speed=1.0) self.fan_speed = config.getfloat( "fan_speed", 1.0, minval=0.0, maxval=1.0 ) self.last_speed = 0.0 + # Delegate mode: `fan:` references an existing fan config section + # instead of giving this heater_fan its own pin. + self._delegate_ref = config.get("fan", None) + pin_set = config.get("pin", None) is not None + if self._delegate_ref is not None and pin_set: + raise config.error( + "[%s]: specify either `pin:` (classic heater_fan) or" + " `fan:` (delegate to another fan), not both" + % (config.get_name(),) + ) + self._section_name = config.get_name() + self._delegate_target = None + if self._delegate_ref is None: + # Classic mode — own a pin. + self.fan = fan.Fan(config, default_shutdown_speed=1.0) + else: + # Delegate mode — no self.fan; resolution happens at ready. + self.fan = None + def handle_ready(self): pheaters = self.printer.lookup_object("heaters") self.heaters = [pheaters.lookup_heater(n) for n in self.heater_names] + if self._delegate_ref is not None: + target = self.printer.lookup_object(self._delegate_ref, None) + if target is None: + raise self.printer.config_error( + "[%s]: fan reference %r not found" + % (self._section_name, self._delegate_ref) + ) + target_fan = getattr(target, "fan", None) + if not isinstance(target_fan, fan.Fan): + raise self.printer.config_error( + "[%s]: fan reference %r does not expose a fan.Fan" + % (self._section_name, self._delegate_ref) + ) + self._delegate_target = target_fan + self._delegate_target.register_floor(self._section_name) reactor = self.printer.get_reactor() reactor.register_timer( self.callback, reactor.monotonic() + PIN_MIN_TIME ) def get_status(self, eventtime): + if self._delegate_target is not None: + status = dict(self._delegate_target.get_status(eventtime)) + status["floor"] = self.last_speed + return status return self.fan.get_status(eventtime) def callback(self, eventtime): @@ -41,7 +78,10 @@ def callback(self, eventtime): speed = self.fan_speed if speed != self.last_speed: self.last_speed = speed - self.fan.set_speed(speed) + if self._delegate_target is not None: + self._delegate_target.update_floor(self._section_name, speed) + else: + self.fan.set_speed(speed) return eventtime + 1.0 From 8e597fad8bb5aef3f43bd17684d76a6e1b35d5e1 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 22:07:03 +0200 Subject: [PATCH 05/15] heater_fan: explicit floor sync and clarify get_status merge --- klippy/extras/heater_fan.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/klippy/extras/heater_fan.py b/klippy/extras/heater_fan.py index 14358a5e82..4b95ac3c13 100644 --- a/klippy/extras/heater_fan.py +++ b/klippy/extras/heater_fan.py @@ -58,6 +58,10 @@ def handle_ready(self): ) self._delegate_target = target_fan self._delegate_target.register_floor(self._section_name) + # Synchronize: the diff-check in callback pairs last_speed with + # the registered floor; force them both to 0.0 explicitly so the + # invariant doesn't depend on FanFloorRegistry's default. + self._delegate_target.update_floor(self._section_name, 0.0) reactor = self.printer.get_reactor() reactor.register_timer( self.callback, reactor.monotonic() + PIN_MIN_TIME @@ -65,9 +69,12 @@ def handle_ready(self): def get_status(self, eventtime): if self._delegate_target is not None: - status = dict(self._delegate_target.get_status(eventtime)) - status["floor"] = self.last_speed - return status + # Merge target's status; floor takes precedence over any + # same-named key the target might add in the future. + return { + **self._delegate_target.get_status(eventtime), + "floor": self.last_speed, + } return self.fan.get_status(eventtime) def callback(self, eventtime): From 65caccde5a061bd0248959fbd23d4a40cc1232ea Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 22:10:30 +0200 Subject: [PATCH 06/15] test: regression fixtures for heater_fan delegate mode --- test/klippy/heater_fan_delegate.cfg | 77 +++++++++++++++++ test/klippy/heater_fan_delegate.test | 3 + .../heater_fan_delegate_missing_ref.cfg | 75 ++++++++++++++++ .../heater_fan_delegate_missing_ref.test | 4 + .../heater_fan_delegate_pin_and_fan.cfg | 76 ++++++++++++++++ .../heater_fan_delegate_pin_and_fan.test | 4 + .../klippy/heater_fan_delegate_wrong_type.cfg | 86 +++++++++++++++++++ .../heater_fan_delegate_wrong_type.test | 4 + 8 files changed, 329 insertions(+) create mode 100644 test/klippy/heater_fan_delegate.cfg create mode 100644 test/klippy/heater_fan_delegate.test create mode 100644 test/klippy/heater_fan_delegate_missing_ref.cfg create mode 100644 test/klippy/heater_fan_delegate_missing_ref.test create mode 100644 test/klippy/heater_fan_delegate_pin_and_fan.cfg create mode 100644 test/klippy/heater_fan_delegate_pin_and_fan.test create mode 100644 test/klippy/heater_fan_delegate_wrong_type.cfg create mode 100644 test/klippy/heater_fan_delegate_wrong_type.test diff --git a/test/klippy/heater_fan_delegate.cfg b/test/klippy/heater_fan_delegate.cfg new file mode 100644 index 0000000000..3ce3de8411 --- /dev/null +++ b/test/klippy/heater_fan_delegate.cfg @@ -0,0 +1,77 @@ +# Config for extruder testing +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 210 + +[fan] +pin: PB5 +min_power: 0.1 +max_power: 1 + +[heater_fan hotend] +fan: fan +heater: extruder +heater_temp: 50 +fan_speed: 0.4 + +[fan_generic xxx] +pin: PB6 +min_power: 0.1 +shutdown_speed: 1 +max_power: 0.95 + +[mcu] +serial: /dev/ttyACM0 + +[printer] +kinematics: cartesian +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 diff --git a/test/klippy/heater_fan_delegate.test b/test/klippy/heater_fan_delegate.test new file mode 100644 index 0000000000..a360df6fe3 --- /dev/null +++ b/test/klippy/heater_fan_delegate.test @@ -0,0 +1,3 @@ +# Parse + resolve a [heater_fan] in delegate mode (fan: fan). +DICTIONARY atmega2560.dict +CONFIG heater_fan_delegate.cfg diff --git a/test/klippy/heater_fan_delegate_missing_ref.cfg b/test/klippy/heater_fan_delegate_missing_ref.cfg new file mode 100644 index 0000000000..9bf08f8097 --- /dev/null +++ b/test/klippy/heater_fan_delegate_missing_ref.cfg @@ -0,0 +1,75 @@ +# Config for extruder testing +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 210 + +[fan] +pin: PB5 +min_power: 0.1 +max_power: 1 + +[heater_fan hotend] +fan: fan_generic missing +heater: extruder + +[fan_generic xxx] +pin: PB6 +min_power: 0.1 +shutdown_speed: 1 +max_power: 0.95 + +[mcu] +serial: /dev/ttyACM0 + +[printer] +kinematics: cartesian +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 diff --git a/test/klippy/heater_fan_delegate_missing_ref.test b/test/klippy/heater_fan_delegate_missing_ref.test new file mode 100644 index 0000000000..ec28332db4 --- /dev/null +++ b/test/klippy/heater_fan_delegate_missing_ref.test @@ -0,0 +1,4 @@ +# Config error: fan reference does not exist. +DICTIONARY atmega2560.dict +CONFIG heater_fan_delegate_missing_ref.cfg +SHOULD_FAIL diff --git a/test/klippy/heater_fan_delegate_pin_and_fan.cfg b/test/klippy/heater_fan_delegate_pin_and_fan.cfg new file mode 100644 index 0000000000..5b0a82aeb6 --- /dev/null +++ b/test/klippy/heater_fan_delegate_pin_and_fan.cfg @@ -0,0 +1,76 @@ +# Config for extruder testing +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 210 + +[fan] +pin: PB5 +min_power: 0.1 +max_power: 1 + +[heater_fan hotend] +pin: PC0 +fan: fan +heater: extruder + +[fan_generic xxx] +pin: PB6 +min_power: 0.1 +shutdown_speed: 1 +max_power: 0.95 + +[mcu] +serial: /dev/ttyACM0 + +[printer] +kinematics: cartesian +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 diff --git a/test/klippy/heater_fan_delegate_pin_and_fan.test b/test/klippy/heater_fan_delegate_pin_and_fan.test new file mode 100644 index 0000000000..a8bb2ceec4 --- /dev/null +++ b/test/klippy/heater_fan_delegate_pin_and_fan.test @@ -0,0 +1,4 @@ +# Config error: pin: and fan: are mutually exclusive. +DICTIONARY atmega2560.dict +CONFIG heater_fan_delegate_pin_and_fan.cfg +SHOULD_FAIL diff --git a/test/klippy/heater_fan_delegate_wrong_type.cfg b/test/klippy/heater_fan_delegate_wrong_type.cfg new file mode 100644 index 0000000000..4bfc2cca89 --- /dev/null +++ b/test/klippy/heater_fan_delegate_wrong_type.cfg @@ -0,0 +1,86 @@ +# Config for extruder testing +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 210 + +[heater_bed] +heater_pin: PC0 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK6 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 110 + +[fan] +pin: PB5 +min_power: 0.1 +max_power: 1 + +[heater_fan hotend] +fan: heater_bed +heater: extruder + +[fan_generic xxx] +pin: PB6 +min_power: 0.1 +shutdown_speed: 1 +max_power: 0.95 + +[mcu] +serial: /dev/ttyACM0 + +[printer] +kinematics: cartesian +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 diff --git a/test/klippy/heater_fan_delegate_wrong_type.test b/test/klippy/heater_fan_delegate_wrong_type.test new file mode 100644 index 0000000000..25775c2aec --- /dev/null +++ b/test/klippy/heater_fan_delegate_wrong_type.test @@ -0,0 +1,4 @@ +# Config error: fan reference does not expose a fan.Fan. +DICTIONARY atmega2560.dict +CONFIG heater_fan_delegate_wrong_type.cfg +SHOULD_FAIL From f28283d5880469762386f5571b2cb84b03a703c2 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 22:17:02 +0200 Subject: [PATCH 07/15] test: tighten heater_fan delegate regression fixtures --- test/klippy/heater_fan_delegate.cfg | 8 +------- test/klippy/heater_fan_delegate.test | 7 ++++++- test/klippy/heater_fan_delegate_missing_ref.cfg | 8 +------- test/klippy/heater_fan_delegate_pin_and_fan.cfg | 8 +------- test/klippy/heater_fan_delegate_wrong_type.cfg | 8 +------- 5 files changed, 10 insertions(+), 29 deletions(-) diff --git a/test/klippy/heater_fan_delegate.cfg b/test/klippy/heater_fan_delegate.cfg index 3ce3de8411..b7eaf59a72 100644 --- a/test/klippy/heater_fan_delegate.cfg +++ b/test/klippy/heater_fan_delegate.cfg @@ -1,4 +1,4 @@ -# Config for extruder testing +# Fixture: [heater_fan] delegate-mode success path. [stepper_x] step_pin: PF0 dir_pin: PF1 @@ -60,12 +60,6 @@ heater: extruder heater_temp: 50 fan_speed: 0.4 -[fan_generic xxx] -pin: PB6 -min_power: 0.1 -shutdown_speed: 1 -max_power: 0.95 - [mcu] serial: /dev/ttyACM0 diff --git a/test/klippy/heater_fan_delegate.test b/test/klippy/heater_fan_delegate.test index a360df6fe3..d044731463 100644 --- a/test/klippy/heater_fan_delegate.test +++ b/test/klippy/heater_fan_delegate.test @@ -1,3 +1,8 @@ -# Parse + resolve a [heater_fan] in delegate mode (fan: fan). +# Verifies that [heater_fan] with fan: (no pin:) parses and reaches +# klippy:ready without error when the referenced [fan] section exists. +# M106/M107 below exercise that the delegated target fan still accepts +# manual commands while a floor source is registered on it. DICTIONARY atmega2560.dict CONFIG heater_fan_delegate.cfg +M106 S255 +M107 diff --git a/test/klippy/heater_fan_delegate_missing_ref.cfg b/test/klippy/heater_fan_delegate_missing_ref.cfg index 9bf08f8097..488b5cdbbc 100644 --- a/test/klippy/heater_fan_delegate_missing_ref.cfg +++ b/test/klippy/heater_fan_delegate_missing_ref.cfg @@ -1,4 +1,4 @@ -# Config for extruder testing +# Fixture: [heater_fan] with fan: referencing missing section (SHOULD_FAIL). [stepper_x] step_pin: PF0 dir_pin: PF1 @@ -58,12 +58,6 @@ max_power: 1 fan: fan_generic missing heater: extruder -[fan_generic xxx] -pin: PB6 -min_power: 0.1 -shutdown_speed: 1 -max_power: 0.95 - [mcu] serial: /dev/ttyACM0 diff --git a/test/klippy/heater_fan_delegate_pin_and_fan.cfg b/test/klippy/heater_fan_delegate_pin_and_fan.cfg index 5b0a82aeb6..44fcae7608 100644 --- a/test/klippy/heater_fan_delegate_pin_and_fan.cfg +++ b/test/klippy/heater_fan_delegate_pin_and_fan.cfg @@ -1,4 +1,4 @@ -# Config for extruder testing +# Fixture: [heater_fan] with both pin: and fan: (SHOULD_FAIL). [stepper_x] step_pin: PF0 dir_pin: PF1 @@ -59,12 +59,6 @@ pin: PC0 fan: fan heater: extruder -[fan_generic xxx] -pin: PB6 -min_power: 0.1 -shutdown_speed: 1 -max_power: 0.95 - [mcu] serial: /dev/ttyACM0 diff --git a/test/klippy/heater_fan_delegate_wrong_type.cfg b/test/klippy/heater_fan_delegate_wrong_type.cfg index 4bfc2cca89..87958d7c2e 100644 --- a/test/klippy/heater_fan_delegate_wrong_type.cfg +++ b/test/klippy/heater_fan_delegate_wrong_type.cfg @@ -1,4 +1,4 @@ -# Config for extruder testing +# Fixture: [heater_fan] with fan: referencing a section that has no .fan attribute (SHOULD_FAIL). [stepper_x] step_pin: PF0 dir_pin: PF1 @@ -69,12 +69,6 @@ max_power: 1 fan: heater_bed heater: extruder -[fan_generic xxx] -pin: PB6 -min_power: 0.1 -shutdown_speed: 1 -max_power: 0.95 - [mcu] serial: /dev/ttyACM0 From eeaf4e8d4d9089bfd95214b31b24d653ab54f788 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 22:18:10 +0200 Subject: [PATCH 08/15] docs: document [heater_fan] fan: delegate option --- docs/Config_Reference.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 96840a0156..8e834183b3 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -3816,6 +3816,15 @@ a shutdown_speed equal to max_power. #enable_pin: #initial_speed: # See the "fan" section for a description of the above parameters. +#fan: +# Optional name of an existing fan config section (e.g. "fan" for +# [fan], "fan_generic my_fan" for [fan_generic my_fan]) to delegate +# to instead of driving a PWM pin. In delegate mode this heater_fan +# does not own a pin; it imposes a speed floor on the referenced +# fan while the heater is active. The referenced fan still responds +# normally to M106/M107 or SET_FAN_SPEED above the floor. "fan:" and +# "pin:" are mutually exclusive. The default is to use "pin:" +# (classic standalone heater_fan). #heater: extruder # Name of the config section defining the heater that this fan is # associated with. If a comma separated list of heater names is From dd208ec5415df12ac4f1b3db934fd04d3350dca7 Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 22:22:39 +0200 Subject: [PATCH 09/15] docs: note [heater_fan] delegate-mode status shape and rejected options --- docs/Config_Reference.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 8e834183b3..2ef71ff956 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -3824,7 +3824,11 @@ a shutdown_speed equal to max_power. # fan while the heater is active. The referenced fan still responds # normally to M106/M107 or SET_FAN_SPEED above the floor. "fan:" and # "pin:" are mutually exclusive. The default is to use "pin:" -# (classic standalone heater_fan). +# (classic standalone heater_fan). In delegate mode this section's +# status object reflects the referenced fan's state with an added +# "floor" key (the speed the heater_fan is currently imposing), and +# fan-hardware options like "pin:", "max_power:", "kick_start_time:", +# etc. are not accepted. #heater: extruder # Name of the config section defining the heater that this fan is # associated with. If a comma separated list of heater names is From 41adc75a15247056d11ed821e13c80f5d26e543d Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 23:37:37 +0200 Subject: [PATCH 10/15] fan: report user-commanded value as last_req_value, not post-floor effective 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. --- klippy/extras/fan.py | 8 +++++--- test/klippy/test_fan_floor.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/klippy/extras/fan.py b/klippy/extras/fan.py index 75b6b7c63d..27e49c14e5 100644 --- a/klippy/extras/fan.py +++ b/klippy/extras/fan.py @@ -151,23 +151,25 @@ def _apply_speed(self, print_time, value): and (not self.last_fan_value or value - self.last_fan_value > 0.5) ): # Run fan at full speed for specified kick_start_time - self.last_req_value = value - self.last_fan_value = 1.0 self.last_pwm_value = self.max_power self.mcu_fan.set_pwm(print_time, self.max_power) return "delay", self.kick_start_time - self.last_fan_value = self.last_req_value = value + self.last_fan_value = value self.last_pwm_value = pwm_value self.mcu_fan.set_pwm(print_time, pwm_value) def set_speed(self, value, print_time=None): + # last_req_value reflects the caller's commanded value so get_status + # reports user intent, not post-floor effective speed. + self.last_req_value = value effective = self._floor_registry.set_user_speed(value) self.gcrq.send_async_request(effective, print_time) def set_speed_from_command(self, value): + self.last_req_value = value effective = self._floor_registry.set_user_speed(value) self.gcrq.queue_gcode_request(effective) diff --git a/test/klippy/test_fan_floor.py b/test/klippy/test_fan_floor.py index 0251691f37..7569ba7738 100644 --- a/test/klippy/test_fan_floor.py +++ b/test/klippy/test_fan_floor.py @@ -106,6 +106,7 @@ class _FanLike: f = _FanLike() f._floor_registry = FanFloorRegistry() f.gcrq = stub + f.last_req_value = 0.0 # Bind the real methods unchanged f.set_speed = Fan.set_speed.__get__(f, _FanLike) f.set_speed_from_command = Fan.set_speed_from_command.__get__(f, _FanLike) @@ -152,3 +153,26 @@ def test_fan_set_speed_from_command_dispatches_effective_gcode(): f.set_speed_from_command(0.8) assert stub.gcode_calls[-1] == 0.8 + + +def test_fan_last_req_value_reflects_user_intent_not_floor(): + # Regression: get_status reports last_req_value as `value`. Mainsail + # and similar UIs bind their fan slider to this. When the user issues + # M107 while a floor holds the fan up, the slider must show the + # commanded 0, not the effective floor speed. + f, stub = _make_fan_with_stub() + f.register_floor("hotend") + f.update_floor("hotend", 0.3) + + f.set_speed_from_command(0.5) + assert f.last_req_value == 0.5 + + # Floor climbs — user intent unchanged. + f.update_floor("hotend", 0.8) + assert f.last_req_value == 0.5 + + # M107 analog while floor is active: user intent is 0, fan effective + # is the floor (0.8), but reported value stays the user's 0. + f.set_speed_from_command(0.0) + assert f.last_req_value == 0.0 + assert stub.gcode_calls[-1] == 0.8 From 4450aefb82719675b333709ed3ea8830a5a9963f Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 23:43:09 +0200 Subject: [PATCH 11/15] heater_fan: delegate-mode get_status reports own floor, not target's 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. --- docs/Config_Reference.md | 5 +++-- klippy/extras/heater_fan.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 2ef71ff956..fb42139981 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -3825,8 +3825,9 @@ a shutdown_speed equal to max_power. # normally to M106/M107 or SET_FAN_SPEED above the floor. "fan:" and # "pin:" are mutually exclusive. The default is to use "pin:" # (classic standalone heater_fan). In delegate mode this section's -# status object reflects the referenced fan's state with an added -# "floor" key (the speed the heater_fan is currently imposing), and +# status reports the heater_fan's own floor state (power/value/speed +# /floor all equal the currently applied floor: 0 when the heater is +# cold, fan_speed when hot; rpm is taken from the target fan), and # fan-hardware options like "pin:", "max_power:", "kick_start_time:", # etc. are not accepted. #heater: extruder diff --git a/klippy/extras/heater_fan.py b/klippy/extras/heater_fan.py index 4b95ac3c13..1d922fd792 100644 --- a/klippy/extras/heater_fan.py +++ b/klippy/extras/heater_fan.py @@ -69,10 +69,18 @@ def handle_ready(self): def get_status(self, eventtime): if self._delegate_target is not None: - # Merge target's status; floor takes precedence over any - # same-named key the target might add in the future. + # In delegate mode this section reports its own floor state, + # not the target fan's user-commanded speed. That way the + # heater_fan card in a UI shows the floor % (0 when the + # heater is cold, `fan_speed` when hot), independent of what + # M106/SET_FAN_SPEED is doing to the target fan. Keep rpm + # from the target since that's the physical fan's actual RPM. + target_status = self._delegate_target.get_status(eventtime) return { - **self._delegate_target.get_status(eventtime), + "power": self.last_speed, + "value": self.last_speed, + "speed": self.last_speed, + "rpm": target_status.get("rpm"), "floor": self.last_speed, } return self.fan.get_status(eventtime) From e9c0c1f779943f8b5febd23354c6c86e7e2be03e Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Tue, 21 Apr 2026 23:44:52 +0200 Subject: [PATCH 12/15] heater_fan: make delegate get_status safe before handle_ready 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. --- klippy/extras/heater_fan.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/klippy/extras/heater_fan.py b/klippy/extras/heater_fan.py index 1d922fd792..23bb245f43 100644 --- a/klippy/extras/heater_fan.py +++ b/klippy/extras/heater_fan.py @@ -68,19 +68,25 @@ def handle_ready(self): ) def get_status(self, eventtime): - if self._delegate_target is not None: + # Branch on _delegate_ref (known at __init__) rather than + # _delegate_target (set at handle_ready): webhooks can query + # status before handle_ready runs, e.g. during an MCU connect + # retry. + if self._delegate_ref is not None: # In delegate mode this section reports its own floor state, # not the target fan's user-commanded speed. That way the # heater_fan card in a UI shows the floor % (0 when the # heater is cold, `fan_speed` when hot), independent of what # M106/SET_FAN_SPEED is doing to the target fan. Keep rpm # from the target since that's the physical fan's actual RPM. - target_status = self._delegate_target.get_status(eventtime) + rpm = None + if self._delegate_target is not None: + rpm = self._delegate_target.get_status(eventtime).get("rpm") return { "power": self.last_speed, "value": self.last_speed, "speed": self.last_speed, - "rpm": target_status.get("rpm"), + "rpm": rpm, "floor": self.last_speed, } return self.fan.get_status(eventtime) From 3261cb90cff0f33d455efdc7ab7bd7857def91cf Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Wed, 22 Apr 2026 00:06:26 +0200 Subject: [PATCH 13/15] docs(Config_Changes): note heater_fan fan: delegate option --- docs/Config_Changes.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/Config_Changes.md b/docs/Config_Changes.md index a649826c3a..4bbf8360b4 100644 --- a/docs/Config_Changes.md +++ b/docs/Config_Changes.md @@ -8,6 +8,14 @@ All dates in this document are approximate. ## Changes +20260422: The `[heater_fan]` section adds a new option `fan:` that +references an existing fan config section (e.g. `fan` for `[fan]`, or +`fan_generic my_fan` for `[fan_generic my_fan]`) to delegate to instead +of driving a PWM pin. In this delegate mode the heater_fan does not own +a pin; it imposes a speed floor on the referenced fan while the heater +is active. The referenced fan still responds to M106/M107 or +SET_FAN_SPEED above the floor. `fan:` and `pin:` are mutually exclusive. + 20260121: Kalico now uses automatic monthly release tags in the format `vYYYY.MM.NN` (e.g., `v2026.01.00`). Users can configure Moonraker to track stable monthly releases instead of the latest commits. See From 01d2a0bd6c19fb41be720ce2c20d8992c64b48ed Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Wed, 22 Apr 2026 00:09:20 +0200 Subject: [PATCH 14/15] fan: apply ruff format --- klippy/extras/fan.py | 8 ++------ test/klippy/test_fan_floor.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/klippy/extras/fan.py b/klippy/extras/fan.py index 27e49c14e5..ae774e02dc 100644 --- a/klippy/extras/fan.py +++ b/klippy/extras/fan.py @@ -17,16 +17,12 @@ def __init__(self): def register_floor(self, source_id): if source_id in self._floors: - raise ValueError( - "fan floor %r already registered" % (source_id,) - ) + raise ValueError("fan floor %r already registered" % (source_id,)) self._floors[source_id] = 0.0 def update_floor(self, source_id, speed): if source_id not in self._floors: - raise KeyError( - "fan floor %r not registered" % (source_id,) - ) + raise KeyError("fan floor %r not registered" % (source_id,)) self._floors[source_id] = speed return self._effective() diff --git a/test/klippy/test_fan_floor.py b/test/klippy/test_fan_floor.py index 7569ba7738..9aa5ea3137 100644 --- a/test/klippy/test_fan_floor.py +++ b/test/klippy/test_fan_floor.py @@ -139,7 +139,7 @@ def test_fan_set_speed_zero_clamps_to_active_floor(): f, stub = _make_fan_with_stub() f.register_floor("hotend") f.update_floor("hotend", 0.4) - f.set_speed(0.0) # M107 analog + f.set_speed(0.0) # M107 analog assert stub.async_calls[-1] == (0.4, None) From ce4bb0b81993c488bd98b9c75c5ac03bd6856ffe Mon Sep 17 00:00:00 2001 From: Danila Dergachev Date: Wed, 22 Apr 2026 00:13:05 +0200 Subject: [PATCH 15/15] docs: link heater_fan delegate-mode PR in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 015f023740..4fe462db13 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ See the [Kalico Additions document](https://docs.kalico.gg/Kalico_Additions.html - [extruder: cold_extrude](https://github.com/KalicoCrew/kalico/pull/750) +- [heater_fan: delegate mode to impose a speed floor on an existing fan](https://github.com/KalicoCrew/kalico/pull/875) + If you're feeling adventurous, take a peek at the extra features in the bleeding-edge-v2 branch [feature documentation](docs/Bleeding_Edge.md) and [feature configuration reference](docs/Config_Reference_Bleeding_Edge.md):