Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
8 changes: 8 additions & 0 deletions docs/Config_Changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/Config_Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3816,6 +3816,20 @@ 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). In delegate mode this section's
# 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
# Name of the config section defining the heater that this fan is
# associated with. If a comma separated list of heater names is
Expand Down
52 changes: 47 additions & 5 deletions klippy/extras/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,41 @@
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()
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(
Expand Down Expand Up @@ -116,23 +147,34 @@ 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):
self.gcrq.send_async_request(value, print_time)
# 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.gcrq.queue_gcode_request(value)
self.last_req_value = 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)
Expand Down
65 changes: 63 additions & 2 deletions klippy/extras/heater_fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,79 @@ 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)
# 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
)

def get_status(self, eventtime):
# 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.
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": rpm,
"floor": self.last_speed,
}
return self.fan.get_status(eventtime)

def callback(self, eventtime):
Expand All @@ -41,7 +99,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


Expand Down
71 changes: 71 additions & 0 deletions test/klippy/heater_fan_delegate.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Fixture: [heater_fan] delegate-mode success path.
[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

[mcu]
serial: /dev/ttyACM0

[printer]
kinematics: cartesian
max_velocity: 300
max_accel: 3000
max_z_velocity: 5
max_z_accel: 100
8 changes: 8 additions & 0 deletions test/klippy/heater_fan_delegate.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 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
69 changes: 69 additions & 0 deletions test/klippy/heater_fan_delegate_missing_ref.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Fixture: [heater_fan] with fan: referencing missing section (SHOULD_FAIL).
[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

[mcu]
serial: /dev/ttyACM0

[printer]
kinematics: cartesian
max_velocity: 300
max_accel: 3000
max_z_velocity: 5
max_z_accel: 100
4 changes: 4 additions & 0 deletions test/klippy/heater_fan_delegate_missing_ref.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Config error: fan reference does not exist.
DICTIONARY atmega2560.dict
CONFIG heater_fan_delegate_missing_ref.cfg
SHOULD_FAIL
Loading
Loading