diff --git a/README.md b/README.md index 015f02374..4fe462db1 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): diff --git a/docs/Config_Changes.md b/docs/Config_Changes.md index a649826c3..4bbf8360b 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 diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 96840a015..fb4213998 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -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 diff --git a/klippy/extras/fan.py b/klippy/extras/fan.py index 1e8dfe508..ae774e02d 100644 --- a/klippy/extras/fan.py +++ b/klippy/extras/fan.py @@ -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( @@ -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) diff --git a/klippy/extras/heater_fan.py b/klippy/extras/heater_fan.py index 27fada599..23bb245f4 100644 --- a/klippy/extras/heater_fan.py +++ b/klippy/extras/heater_fan.py @@ -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): @@ -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 diff --git a/test/klippy/heater_fan_delegate.cfg b/test/klippy/heater_fan_delegate.cfg new file mode 100644 index 000000000..b7eaf59a7 --- /dev/null +++ b/test/klippy/heater_fan_delegate.cfg @@ -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 diff --git a/test/klippy/heater_fan_delegate.test b/test/klippy/heater_fan_delegate.test new file mode 100644 index 000000000..d04473146 --- /dev/null +++ b/test/klippy/heater_fan_delegate.test @@ -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 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 000000000..488b5cdbb --- /dev/null +++ b/test/klippy/heater_fan_delegate_missing_ref.cfg @@ -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 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 000000000..ec28332db --- /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 000000000..44fcae760 --- /dev/null +++ b/test/klippy/heater_fan_delegate_pin_and_fan.cfg @@ -0,0 +1,70 @@ +# Fixture: [heater_fan] with both pin: and fan: (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] +pin: PC0 +fan: fan +heater: extruder + +[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 000000000..a8bb2ceec --- /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 000000000..87958d7c2 --- /dev/null +++ b/test/klippy/heater_fan_delegate_wrong_type.cfg @@ -0,0 +1,80 @@ +# Fixture: [heater_fan] with fan: referencing a section that has no .fan attribute (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 + +[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 + +[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 000000000..25775c2ae --- /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 diff --git a/test/klippy/test_fan_floor.py b/test/klippy/test_fan_floor.py new file mode 100644 index 000000000..9aa5ea313 --- /dev/null +++ b/test/klippy/test_fan_floor.py @@ -0,0 +1,178 @@ +# 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 + + +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 + 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) + 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) + + # 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() + 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 + + +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