From e1193a96b2fbc5cc1300a80174bf16e3cbf58d69 Mon Sep 17 00:00:00 2001 From: haze Date: Thu, 3 Sep 2026 18:57:51 +0700 Subject: [PATCH] feat(aux-fan): auxiliary part cooling fan support Add an independent secondary part cooling fan (AUX_FAN) with: - Hardware configs: aux_fan.cfg (basic) and aux_fan_tachometer.cfg (with tachometer monitoring) - fan_bridge.cfg: OrcaSlicer M106 P2 / M107 P2 bridge via rename_existing, included from part_fan.cfg and toolhead boards - Macros: START_AUX_FAN, STOP_AUX_FAN, tachometer safety loop (proportional min_rpm scaling with configurable poll interval) - Print flow integration: - START_PRINT: chamber venting with status messages, aux fan stop from previous run-down, tach monitoring start - END_PRINT/CANCEL_PRINT: post-print run-down with configurable speed/time, tach re-enabled before fan start - _MODULE_HEATSOAK_BED: aux fan for heat spreading, stopped at end - M84: aux fan stop removed (now in end_print/cancel_print) - Startup validation (_INIT_CHECKFANCONF): detects missing fan_bridge.cfg when [fan] + gcode M106 exist but M106.1 absent - Config check CI: standard + tachometer variants, lint_jinja.py, test_fan_conf_check.py for M106 fallback logic - Variables: aux_fan_enabled, aux_fan_name, aux_fan_tach_enabled, aux_fan_tach_poll_interval, aux_fan_tach_min_rpm_max/floor, aux_fan_tach_max_consecutive_stops, aux_fan_vent_temp/speed/time, aux_fan_vent_status_interval, aux_fan_soak_speed, aux_fan_speed_on_end_print, aux_fan_time_on_end_print --- .gitignore | 2 + CHANGELOG.md | 5 + README.md | 13 ++ config/hardware/fans/aux_fan.cfg | 13 ++ config/hardware/fans/aux_fan_tachometer.cfg | 17 ++ .../hardware/fans/hotend_fan_tachometer.cfg | 1 + config/hardware/fans/part_fan.cfg | 4 + config/hardware/fans/part_fan_tachometer.cfg | 4 + docs/pinout.md | 2 + macros/base/cancel_print.cfg | 22 ++ macros/base/control.cfg | 5 + macros/base/end_print.cfg | 23 ++ macros/base/start_print.cfg | 66 +++++- macros/hardware_functions/aux_fan.cfg | 115 ++++++++++ macros/hardware_functions/fan_bridge.cfg | 37 +++ macros/helpers/tachometer_check.cfg | 6 +- macros/miscs/startup.cfg | 19 +- tests/config_check.py | 213 ++++++++++++++++++ tests/lint_jinja.py | 104 +++++++++ tests/test_fan_conf_check.py | 111 +++++++++ user_templates/printer.cfg | 9 +- user_templates/variables.cfg | 23 +- 22 files changed, 804 insertions(+), 10 deletions(-) create mode 100644 config/hardware/fans/aux_fan.cfg create mode 100644 config/hardware/fans/aux_fan_tachometer.cfg create mode 100644 macros/hardware_functions/aux_fan.cfg create mode 100644 macros/hardware_functions/fan_bridge.cfg create mode 100644 tests/config_check.py create mode 100644 tests/lint_jinja.py create mode 100644 tests/test_fan_conf_check.py diff --git a/.gitignore b/.gitignore index 344064f33..5fc33ffe2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,10 @@ /*.cfg /*.conf +/*.log /.VERSION /scripts/K-ShakeTune +/klipper # ------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 07cb33efe..37dbef0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ ### 🐛 Bug Fixes +- Fix aux fan tach monitoring disabled before end-print run-down +- Fix aux fan M106/M107 bridge to not break config load when no [fan] section is defined (moved bridge to fan_bridge.cfg, included only from configs that define [fan]) +- Add `aux_fan_vent_temp > 0` guard to vent condition so room temperature no longer triggers a 2-minute delay +- Start aux fan tach monitoring before the chamber vent in START_PRINT +- Stop aux fan at end of `_MODULE_HEATSOAK_BED` to prevent run-away fan when chamber_soak is omitted - Relax chamber heatsoak temperature check - Use contact home action for Beacon - Correct BTT EBB GEN2 fan aliases diff --git a/README.md b/README.md index 7f3da803b..3be13e4d2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ Klippain is regularly updated with new features and merged PRs from users. You c Fun fact: "pain" \pɛ̃\ is the French word for bread, so there's no pain in this pain—only joy! Thanks to the French channel "honhonhonbaguette-FR" on the Voron Discord for the joke and name suggestion! +## What makes this fork different + +This repository is a fork of [Frix-x/klippain](https://github.com/Frix-x/klippain) that tracks upstream and adds the following on top of it: + +- **Auxiliary part cooling fan** (`AUX_FAN`): an independent secondary part cooling fan, natively controllable through `START_AUX_FAN`/`STOP_AUX_FAN` macros and compatible with OrcaSlicer's "Auxiliary Part Cooling Fan" via an `M106 P2`/`M107 P2` bridge. Optional `AUX_FAN_TACHO` tachometer monitoring provides the same safety checks as the base fans. It is integrated in the start/end/cancel print sequence for chamber venting (when too hot), heatsoak heat spreading, and a post-print run-down to cool the bed faster. Enabling it requires uncommenting `[include config/hardware/fans/aux_fan.cfg]` (or the `aux_fan_tachometer.cfg` variant) in `user_templates/printer.cfg`, wiring the `AUX_FAN`/`AUX_FAN_TACHO` pins in `mcu.cfg`, and tuning the `variable_aux_fan_*` settings in `user_templates/variables.cfg`. + +- **Automated config validation**: a CI pipeline (`tests/lint_jinja.py` and `tests/config_check.py`, run by `.github/workflows/validate.yml`) syntax-checks every templated macro block with Klipper's own Jinja2 environment, then assembles a full reference configuration and loads it with a real Klippy (standard and aux-fan-tachometer variants) to catch errors before they reach your printer. Run it locally with `python3 tests/lint_jinja.py` and `python3 tests/config_check.py standard `. + + ## Features Klippain is designed for versatility. By selecting and enabling the desired hardware and software options, it can be used on a wide range of machines. @@ -24,6 +33,10 @@ To install Klippain, first ensure you have already Klipper, Moonraker, and a Web Then, run the installation script using the following command over SSH. This script will backup your old configuration, download this GitHub repository to your RaspberryPi home directory, and set up Klippain in `~/printer_data/config`. You will also be prompted to select and install MCU board_pins templates. This is recommended for faster `mcu.cfg` setup, but you can do it manually later if you prefer. + > **Note for users of this fork**: + > + > The command below installs **upstream** Klippain (the `Frix-x/klippain` repository). If you want to use the fork-specific additions described in [What makes this fork different](#what-makes-this-fork-different), install from this fork instead, e.g. via the `Code` button on GitHub or by cloning `git@github.com:ndotzbgt/klippain.git` into `~/printer_data/config`. + ```bash wget -O - https://raw.githubusercontent.com/Frix-x/klippain/main/install.sh | bash ``` diff --git a/config/hardware/fans/aux_fan.cfg b/config/hardware/fans/aux_fan.cfg new file mode 100644 index 000000000..ad1930821 --- /dev/null +++ b/config/hardware/fans/aux_fan.cfg @@ -0,0 +1,13 @@ +# Secondary part cooling fan, independently controlled (e.g. OrcaSlicer "Auxiliary Part Cooling Fan") +[gcode_macro _USER_VARIABLES] +variable_aux_fan_enabled: True +variable_aux_fan_name: "aux_fan" +gcode: + +# Also include directly the auxiliary fan control macros from here +[include ../../../macros/hardware_functions/aux_fan.cfg] + +[fan_generic aux_fan] +pin: AUX_FAN +# Wired to the main MCU: define "AUX_FAN=" in your main mcu.cfg, +# or replace this pin with the raw main-board pin (e.g. PB6). diff --git a/config/hardware/fans/aux_fan_tachometer.cfg b/config/hardware/fans/aux_fan_tachometer.cfg new file mode 100644 index 000000000..8f21743c8 --- /dev/null +++ b/config/hardware/fans/aux_fan_tachometer.cfg @@ -0,0 +1,17 @@ +# Secondary part cooling fan with tachometer monitoring (requires an AUX_FAN_TACHO pin, e.g. Fysetc H36/SB Combo) +# Use this file instead of aux_fan.cfg +[gcode_macro _USER_VARIABLES] +variable_aux_fan_enabled: True +variable_aux_fan_name: "aux_fan" +variable_aux_fan_tach_enabled: True +variable_aux_fan_tach_poll_interval: 3 # seconds between tachometer safety checks +gcode: + +# Also include directly the auxiliary fan control macros from here +[include ../../../macros/hardware_functions/aux_fan.cfg] + +[fan_generic aux_fan] +pin: AUX_FAN +tachometer_pin: AUX_FAN_TACHO +# Wired to the main MCU: define "AUX_FAN=" and "AUX_FAN_TACHO=" in your +# main mcu.cfg, or replace the pins with the raw main-board pins. diff --git a/config/hardware/fans/hotend_fan_tachometer.cfg b/config/hardware/fans/hotend_fan_tachometer.cfg index dbf370a8c..b0d78ba53 100644 --- a/config/hardware/fans/hotend_fan_tachometer.cfg +++ b/config/hardware/fans/hotend_fan_tachometer.cfg @@ -1,5 +1,6 @@ [gcode_macro _USER_VARIABLES] variable_hotend_fan_tach_enabled: True +variable_hotend_tach_poll_interval: 3 # seconds between tachometer safety checks gcode: [heater_fan hotend_fan] diff --git a/config/hardware/fans/part_fan.cfg b/config/hardware/fans/part_fan.cfg index a85575c6d..00bef8f05 100644 --- a/config/hardware/fans/part_fan.cfg +++ b/config/hardware/fans/part_fan.cfg @@ -2,3 +2,7 @@ pin: PART_FAN kick_start_time: 0.100 cycle_time: 0.010 + +# OrcaSlicer M106 P2 / M107 P2 bridge for the auxiliary fan (only effective when an aux fan is configured). +# Uses rename_existing, so it must be included alongside a [fan] section (like this file). +[include ../../../macros/hardware_functions/fan_bridge.cfg] diff --git a/config/hardware/fans/part_fan_tachometer.cfg b/config/hardware/fans/part_fan_tachometer.cfg index 675ec97bd..e2e5f8b10 100644 --- a/config/hardware/fans/part_fan_tachometer.cfg +++ b/config/hardware/fans/part_fan_tachometer.cfg @@ -8,5 +8,9 @@ kick_start_time: 0.100 cycle_time: 0.010 tachometer_pin: PART_FAN_TACHO +# OrcaSlicer M106 P2 / M107 P2 bridge for the auxiliary fan (only effective when an aux fan is configured). +# Uses rename_existing, so it must be included alongside a [fan] section (like this file). +[include ../../../macros/hardware_functions/fan_bridge.cfg] + # And we also include the tachometer check macros from here [include ../../../macros/helpers/tachometer_check.cfg] diff --git a/docs/pinout.md b/docs/pinout.md index 150247038..3b7ab57a8 100644 --- a/docs/pinout.md +++ b/docs/pinout.md @@ -66,6 +66,8 @@ Here is a list of the common/public "Frix-x names" available to use in your own - `E_FAN_TACHO`: tachometer of the hotend fan, optional and used to validate that the fan is spinning as a safety feature - `PART_FAN`: part fan used during the print. This pin should be a PWM capable pin to allow modulation of the fan speed - `PART_FAN_TACHO`: tachometer of the part fan, optional and used to validate that the fan is spinning as a safety feature + - `AUX_FAN`: secondary part cooling fan, independently controlled (e.g. OrcaSlicer "Auxiliary Part Cooling Fan"). This pin should be a PWM capable pin to allow modulation of the fan speed + - `AUX_FAN_TACHO`: tachometer of the auxiliary fan, optional and used to validate that the fan is spinning as a safety feature - `EXHAUST_FAN`: for an exhaust filter (such as the Voron basic exhaust). This pin should be a PWM capable pin to allow modulation of the fan speed - `FILTER_FAN`: for a filter (such as a Nevermore filter). This pin should be a PWM capable pin to allow modulation of the fan speed - `CONTROLLER_FAN`: to cool down your MCUs or electronic bay diff --git a/macros/base/cancel_print.cfg b/macros/base/cancel_print.cfg index 8db7044ab..8d371bd7e 100644 --- a/macros/base/cancel_print.cfg +++ b/macros/base/cancel_print.cfg @@ -14,6 +14,10 @@ gcode: {% set filament_sensor_enabled = printer["gcode_macro _USER_VARIABLES"].filament_sensor_enabled|default(False) %} {% set filter_default_time = printer["gcode_macro _USER_VARIABLES"].filter_default_time_on_end_print|default(600)|int %} {% set hotend_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].hotend_fan_tach_enabled|default(False) %} + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_enabled|default(False) %} + {% set aux_fan_speed_on_end_print = printer["gcode_macro _USER_VARIABLES"].aux_fan_speed_on_end_print|default(100)|int %} + {% set aux_fan_time_on_end_print = printer["gcode_macro _USER_VARIABLES"].aux_fan_time_on_end_print|default(300)|int %} {% set retract_length = printer["gcode_macro _USER_VARIABLES"].retract_length|default(20)|float %} {% if printer.toolhead.homed_axes == "xyz" %} @@ -41,6 +45,10 @@ gcode: UPDATE_DELAYED_GCODE ID=_BACKGROUND_HOTEND_TACHO_CHECK DURATION=0 {% endif %} + {% if aux_fan_tach_enabled %} + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION=0 + {% endif %} + M107 M400 @@ -62,6 +70,20 @@ gcode: {% endif %} {% endif %} + # If an auxiliary fan is connected and enabled, keep it running after the cancel to + # cool the bed faster and help recirculating filters/exhaust fans work faster. + # Re-enable tach monitoring BEFORE starting the fan so it is monitored from the + # moment it spins up (previously tach was disabled earlier in this macro and only + # re-enabled after the fan was already running). + {% if aux_fan_enabled %} + {% set aux_fan_time = params.AUX_FAN_TIME|default(aux_fan_time_on_end_print)|int %} + {% if aux_fan_tach_enabled %} + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION=1 + {% endif %} + START_AUX_FAN SPEED={aux_fan_speed_on_end_print / 100} + UPDATE_DELAYED_GCODE ID=_STOP_AUX_FAN_DELAYED DURATION={aux_fan_time} + {% endif %} + {% if light_enabled %} LIGHT_ON S={light_intensity_end_print} {% endif %} diff --git a/macros/base/control.cfg b/macros/base/control.cfg index 1da4207cb..c486ca45a 100644 --- a/macros/base/control.cfg +++ b/macros/base/control.cfg @@ -4,9 +4,14 @@ gcode: {% set light_enabled = printer["gcode_macro _USER_VARIABLES"].light_enabled %} {% set status_leds_control_enabled = printer["gcode_macro _USER_VARIABLES"].status_leds_control_enabled|default(False) %} {% set display_leds_enabled = printer["gcode_macro _USER_VARIABLES"].status_leds_minidisplay_enabled %} + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} M84 ; turn steppers off TURN_OFF_HEATERS ; turn bed / hotend off M107 ; turn print cooling fan off + {% if aux_fan_enabled %} + UPDATE_DELAYED_GCODE ID=_STOP_AUX_FAN_DELAYED DURATION=0 ; deactivate any pending aux fan run-down + STOP_AUX_FAN ; turn auxiliary fan off + {% endif %} {% if light_enabled %} LIGHT_OFF ; turn off light {% endif %} diff --git a/macros/base/end_print.cfg b/macros/base/end_print.cfg index c82d6bf74..5d41a7fba 100644 --- a/macros/base/end_print.cfg +++ b/macros/base/end_print.cfg @@ -17,6 +17,10 @@ gcode: {% set filter_default_time = printer["gcode_macro _USER_VARIABLES"].filter_default_time_on_end_print|default(600)|int %} {% set filament_sensor_enabled = printer["gcode_macro _USER_VARIABLES"].filament_sensor_enabled|default(False) %} {% set hotend_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].hotend_fan_tach_enabled|default(False) %} + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_enabled|default(False) %} + {% set aux_fan_speed_on_end_print = printer["gcode_macro _USER_VARIABLES"].aux_fan_speed_on_end_print|default(100)|int %} + {% set aux_fan_time_on_end_print = printer["gcode_macro _USER_VARIABLES"].aux_fan_time_on_end_print|default(300)|int %} PARK @@ -66,6 +70,20 @@ gcode: {% endif %} {% endif %} + # If an auxiliary fan is connected and enabled, keep it running after the print to + # cool the bed faster and help recirculating filters/exhaust fans work faster. + # Re-enable tach monitoring BEFORE starting the fan so it is monitored from the + # moment it spins up (previously tach was disabled in _MODULE_TURN_OFF_FANS and + # only re-enabled after the fan was already running). + {% if aux_fan_enabled %} + {% set aux_fan_time = params.AUX_FAN_TIME|default(aux_fan_time_on_end_print)|int %} + {% if aux_fan_tach_enabled %} + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION=1 + {% endif %} + START_AUX_FAN SPEED={aux_fan_speed_on_end_print / 100} + UPDATE_DELAYED_GCODE ID=_STOP_AUX_FAN_DELAYED DURATION={aux_fan_time} + {% endif %} + {% if light_enabled %} LIGHT_ON S={light_intensity_end_print} {% endif %} @@ -127,11 +145,16 @@ gcode: # ----- TURN OFF FANS ------------------------------------- # Turn off fans and fan monitoring at the end of a print job {% set hotend_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].hotend_fan_tach_enabled|default(False) %} + {% set aux_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_enabled|default(False) %} {% if hotend_fan_tach_enabled %} UPDATE_DELAYED_GCODE ID=_BACKGROUND_HOTEND_TACHO_CHECK DURATION=0 {% endif %} + {% if aux_fan_tach_enabled %} + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION=0 + {% endif %} + M107 diff --git a/macros/base/start_print.cfg b/macros/base/start_print.cfg index 3fbe33bc1..240dc4735 100644 --- a/macros/base/start_print.cfg +++ b/macros/base/start_print.cfg @@ -24,7 +24,7 @@ gcode: # Get all the parameters passed from the slicer {% set BED_TEMP = params.BED_TEMP|default(printer["gcode_macro _USER_VARIABLES"].print_default_bed_temp)|float %} # Bed temperature {% set EXTRUDER_TEMP = params.EXTRUDER_TEMP|default(printer["gcode_macro _USER_VARIABLES"].print_default_extruder_temp)|float %} # Extruder temperature - {% set Z_ADJUST = params.Z_ADJUST|default(0)|float %} # Optionnal Z adjustement from the slicer profile (ex. use it if you have textured vs smooth slicer profiles) + {% set Z_ADJUST = params.Z_ADJUST|default(0)|float %} # Optional Z adjustment from the slicer profile (ex. use it if you have textured vs smooth slicer profiles) {% set SOAK = params.SOAK|default(printer["gcode_macro _USER_VARIABLES"].print_default_soak)|int %} # Heatsoak time of the bed in minutes {% set CHAMBER_TEMP = params.CHAMBER|default(printer["gcode_macro _USER_VARIABLES"].print_default_chamber_temp)|int %} # Chamber temperature setpoint {% set CHAMBER_MAXTIME = params.CHAMBER_MAXTIME|default(printer["gcode_macro _USER_VARIABLES"].print_default_chamber_max_heating_time)|int %} # Chamber heatsoak timeout in minutes @@ -73,6 +73,15 @@ gcode: {% set filament_sensor_enabled = filament_sensor_available %} {% set part_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].part_fan_tach_enabled|default(False) %} {% set hotend_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].hotend_fan_tach_enabled|default(False) %} + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_enabled|default(False) %} + {% set aux_fan_vent_temp = printer["gcode_macro _USER_VARIABLES"].aux_fan_vent_temp|default(0)|int %} + {% set aux_fan_vent_speed = printer["gcode_macro _USER_VARIABLES"].aux_fan_vent_speed|default(100)|int %} + {% set aux_fan_vent_time = printer["gcode_macro _USER_VARIABLES"].aux_fan_vent_time|default(120)|int %} + {% set aux_fan_vent_status_interval = printer["gcode_macro _USER_VARIABLES"].aux_fan_vent_status_interval|default(10)|int %} + {% set chamber_temperature_sensor_enabled = printer["gcode_macro _USER_VARIABLES"].chamber_temperature_sensor_enabled|default(False) %} + {% set chamber_temperature_sensor_name = printer["gcode_macro _USER_VARIABLES"].chamber_temperature_sensor_name|default("") %} + {% set chamber_temperature_sensor_type = printer["gcode_macro _USER_VARIABLES"].chamber_temperature_sensor_type|default("temperature_sensor") %} {% if MATERIAL not in printer["gcode_macro _USER_VARIABLES"].material_parameters %} RESPOND MSG="Material '{MATERIAL}' is unknown!" @@ -110,6 +119,13 @@ gcode: STOP_FILTER {% endif %} + # If an auxiliary fan is enabled, stop it and deactivate the potential pending delayed + # gcode from the end-print run-down of a previous print + {% if aux_fan_enabled %} + UPDATE_DELAYED_GCODE ID=_STOP_AUX_FAN_DELAYED DURATION=0 + STOP_AUX_FAN + {% endif %} + # If a tachometer is enabled on one of the fans, we check them: # - It's done only once for the part fan to be sure nothing block it before starting the print # - And as a safety feature, we start a monitoring loop for the hotend fan (that is automatically stopped at the end of a print) @@ -122,6 +138,28 @@ gcode: {% if hotend_fan_tach_enabled %} UPDATE_DELAYED_GCODE ID=_BACKGROUND_HOTEND_TACHO_CHECK DURATION=1 {% endif %} + {% if aux_fan_tach_enabled %} + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION=1 + {% endif %} + + # If an auxiliary fan is enabled, and a chamber sensor is available, vent it for a short + # time with the aux fan to equalize the temperature before starting to heat the printer + {% if aux_fan_enabled %} + {% if chamber_temperature_sensor_enabled %} + {% set chamber_sensor = (chamber_temperature_sensor_type ~ " " ~ chamber_temperature_sensor_name)|trim %} + {% set CURRENT_CHAMBER_TEMP = printer[chamber_sensor].temperature|float %} + {% else %} + {% set CURRENT_CHAMBER_TEMP = 0 %} + {% endif %} + {% if (aux_fan_vent_time > 0) and (aux_fan_vent_temp > 0) and (CURRENT_CHAMBER_TEMP > aux_fan_vent_temp) %} + START_AUX_FAN SPEED={aux_fan_vent_speed / 100} + {% for i in range(0, aux_fan_vent_time, aux_fan_vent_status_interval) %} + RESPOND MSG="Venting chamber, {aux_fan_vent_time - i}s left..." + G4 P{[aux_fan_vent_status_interval, aux_fan_vent_time - i]|min * 1000} + {% endfor %} + STOP_AUX_FAN + {% endif %} + {% endif %} SET_GCODE_OFFSET Z=0 M221 S100 @@ -211,7 +249,7 @@ gcode: {% endif %} {% endfor %} - # Fine adjustement of z offset (from the slicer profile). This is used to do a custom adjustement + # Fine adjustment of z offset (from the slicer profile). This is used to do a custom adjustment # when using textured/smooth PEI sheets, or for a special material from the slicer, etc... SET_GCODE_OFFSET Z_ADJUST={Z_ADJUST} MOVE=1 @@ -273,6 +311,8 @@ gcode: {% set status_leds_control_enabled = printer["gcode_macro _USER_VARIABLES"].status_leds_control_enabled|default(False) %} {% set filter_enabled = printer["gcode_macro _USER_VARIABLES"].filter_enabled %} + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_soak_speed = printer["gcode_macro _USER_VARIABLES"].aux_fan_soak_speed|default(0)|int %} {% set St = printer["gcode_macro _USER_VARIABLES"].travel_speed * 60 %} @@ -284,12 +324,17 @@ gcode: {% endif %} {% if printer.heater_bed.temperature < (BED_TEMP - 8) %} - # If the machine is equiped by a chamber temperature sensor and a recirculating filter (check is automatic under the hood), + # If the machine is equipped with a chamber temperature sensor and a recirculating filter (check is automatic under the hood), # then we look if a specific chamber temperature is needed and we power on the recirculating filter to spread the heat {% if (CHAMBER_TEMP > 0) and filter_enabled %} START_FILTER SPEED=1 {% endif %} + # The auxiliary fan can also be used to spread the heat faster during the soak + {% if (CHAMBER_TEMP > 0) and aux_fan_enabled and aux_fan_soak_speed > 0 %} + START_AUX_FAN SPEED={aux_fan_soak_speed / 100} + {% endif %} + # If we need a full soak (not 0 min), then move the toolhead to the center front to spread the heat using the hotend fan {% if SOAK > 0 %} G0 X{max_x|int / 2} Y{max_y|int / 3} Z50 F{St} @@ -301,6 +346,13 @@ gcode: HEATSOAK_BED TEMP={BED_TEMP} SOAKTIME=0 {% endif %} + # The auxiliary fan was only used to spread heat during the bed soak, stop it now. + # This is also a safety net in case _MODULE_HEATSOAK_CHAMBER is not in the start + # sequence and would otherwise never stop the fan started above. + {% if aux_fan_enabled %} + STOP_AUX_FAN + {% endif %} + [gcode_macro _MODULE_HEATSOAK_CHAMBER] gcode: @@ -313,6 +365,7 @@ gcode: {% set chamber_sensor_enabled = printer["gcode_macro _USER_VARIABLES"].chamber_temperature_sensor_enabled %} {% set filter_enabled = printer["gcode_macro _USER_VARIABLES"].filter_enabled %} + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} {% set St = printer["gcode_macro _USER_VARIABLES"].travel_speed * 60 %} @@ -347,6 +400,13 @@ gcode: {% endif %} {% endif %} + # The auxiliary fan is only used to spread the heat during the soak, so it is not + # needed anymore once this phase is reached. Stop it (also covers the case where + # the chamber was already at temperature and no soak was performed) + {% if aux_fan_enabled %} + STOP_AUX_FAN + {% endif %} + [gcode_macro _MODULE_TILTING] gcode: diff --git a/macros/hardware_functions/aux_fan.cfg b/macros/hardware_functions/aux_fan.cfg new file mode 100644 index 000000000..7112dc5e5 --- /dev/null +++ b/macros/hardware_functions/aux_fan.cfg @@ -0,0 +1,115 @@ +## Auxiliary fan control macros, used in conjunction with config/hardware/fans/aux_fan.cfg +## The aux fan is an independent secondary part cooling fan, controllable by: +## - START_AUX_FAN / STOP_AUX_FAN macros +## - SET_FAN_SPEED FAN=aux_fan +## - OrcaSlicer "Auxiliary Part Cooling Fan" via the M106 P2 / M107 P2 bridge +## +## Fallback M106/M107 macros below route P2 (aux fan) automatically for all configurations. +## When fan_bridge.cfg is also loaded (via part_fan.cfg), bare M106/M107 (P0) are forwarded +## to the native part fan via M106.1/M107.1. When using a toolhead board template instead of +## part_fan.cfg, you must include fan_bridge.cfg to enable this forwarding. Klippain validates +## this at startup and raises an error if the configuration is misconfigured. + +[gcode_macro START_AUX_FAN] +description: Turn on the auxiliary fan at a given speed (SPEED as 0.0 - 1.0) +gcode: + {% set SPEED = params.SPEED|default(1)|float %} + {% set verbose = printer["gcode_macro _USER_VARIABLES"].verbose|default(False) %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + + SET_FAN_SPEED FAN={aux_fan_name} SPEED={SPEED} + {% if verbose %} + RESPOND MSG="Auxiliary fan set to {SPEED * 100}%" + {% endif %} + + +[gcode_macro STOP_AUX_FAN] +description: Turn off the auxiliary fan +gcode: + {% set verbose = printer["gcode_macro _USER_VARIABLES"].verbose|default(False) %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + + SET_FAN_SPEED FAN={aux_fan_name} SPEED=0 + {% if verbose %} + RESPOND MSG="Auxiliary fan off" + {% endif %} + + +# Fallback M106/M107 bridge for OrcaSlicer P2 routing. +# These are overridden by fan_bridge.cfg when it is loaded via part_fan.cfg. +# P2 commands from OrcaSlicer are routed to the auxiliary fan, and all other +# commands are forwarded to M106.1/M107.1 if fan_bridge.cfg is loaded. +[gcode_macro M106] +description: Set fan speed (OrcaSlicer compatible, P2 = auxiliary fan) +gcode: + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + {% set fan_index = params.P|int if params.P is defined else 0 %} + + {% if fan_index == 2 and aux_fan_enabled %} + {% set speed = (params.S|float if params.S is defined else 255) / 255 %} + SET_FAN_SPEED FAN={aux_fan_name} SPEED={speed} + {% elif "M106.1" in printer.gcode.commands %} + M106.1 {rawparams|trim} + {% endif %} + + +[gcode_macro M107] +description: Turn off fans (OrcaSlicer compatible, P2 = auxiliary fan) +gcode: + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + {% set fan_index = params.P|int if params.P is defined else 0 %} + + {% if fan_index == 2 and aux_fan_enabled %} + SET_FAN_SPEED FAN={aux_fan_name} SPEED=0 + {% elif "M107.1" in printer.gcode.commands %} + M107.1 + {% endif %} + + +[delayed_gcode _STOP_AUX_FAN_DELAYED] +gcode: + {% set aux_fan_tach_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_enabled|default(False) %} + STOP_AUX_FAN + {% if aux_fan_tach_enabled %} + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION=0 + {% endif %} + + +# Monitoring loop for the auxiliary fan safety check (only used with a tachometer) +[delayed_gcode _BACKGROUND_AUX_TACHO_CHECK] +gcode: + {% set tach_poll_interval = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_poll_interval|default(3)|float %} + _AUX_FAN_CHECK + UPDATE_DELAYED_GCODE ID=_BACKGROUND_AUX_TACHO_CHECK DURATION={tach_poll_interval} + +[gcode_macro _AUX_FAN_CHECK] +description: Check the auxiliary fan tachometer to verify that it is running effectively +variable_aux_stop_count: 0 +gcode: + {% set tach_poll_interval = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_poll_interval|default(3)|float %} + {% set max_consecutive_stops = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_max_consecutive_stops|default(3)|int %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + {% set aux_fan = "fan_generic " ~ aux_fan_name %} + {% set rpm = printer[aux_fan].rpm|float %} + {% set aux_target = printer[aux_fan].speed|float %} + # Scale the minimum RPM threshold proportionally with the target fan speed. + # At full speed the threshold is aux_fan_tach_min_rpm_max; at low soak speeds it drops toward + # a floor of aux_fan_tach_min_rpm_floor so that intentionally slow fans don't false-positive. + {% set min_rpm_max = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_min_rpm_max|default(1000)|float %} + {% set min_rpm_floor = printer["gcode_macro _USER_VARIABLES"].aux_fan_tach_min_rpm_floor|default(200)|float %} + {% set min_rpm = [min_rpm_max * aux_target, min_rpm_floor]|max %} + {% set aux_stop_count = printer["gcode_macro _AUX_FAN_CHECK"].aux_stop_count|int %} + + {% if (aux_target > 0) and (rpm < min_rpm) %} + SET_GCODE_VARIABLE MACRO=_AUX_FAN_CHECK VARIABLE=aux_stop_count VALUE={aux_stop_count + 1} + RESPOND MSG="Auxiliary fan stoppage detected for {(aux_stop_count+1)*tach_poll_interval|int}sec (max {max_consecutive_stops*tach_poll_interval|int}sec allowed, min_rpm={min_rpm|int})" + M400 + {% if printer["gcode_macro _AUX_FAN_CHECK"].aux_stop_count|int >= max_consecutive_stops - 1 %} + CANCEL_PRINT + {% endif %} + {% else %} + SET_GCODE_VARIABLE MACRO=_AUX_FAN_CHECK VARIABLE=aux_stop_count VALUE=0 + {% endif %} + diff --git a/macros/hardware_functions/fan_bridge.cfg b/macros/hardware_functions/fan_bridge.cfg new file mode 100644 index 000000000..a986f8d35 --- /dev/null +++ b/macros/hardware_functions/fan_bridge.cfg @@ -0,0 +1,37 @@ +## OrcaSlicer fan bridge: Orca addresses the "Auxiliary Part Cooling Fan" as M106 P2 (Marlin standard), +## which Klipper does not support natively. This file uses rename_existing, which requires a [fan] +## section to be present. It is included from part_fan.cfg and part_fan_tachometer.cfg. +## +## When using a toolhead board template (e.g. BTT EBB, Mellow SHT) instead of part_fan.cfg, +## you must also include this file to enable M106/M107 bridging. Klippain validates this at +## startup and raises an error if the configuration is misconfigured. + +[gcode_macro M106] +description: Set fan speed (OrcaSlicer compatible, P2 = auxiliary fan) +rename_existing: M106.1 +gcode: + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + {% set fan_index = params.P|int if params.P is defined else 0 %} + + {% if fan_index == 2 and aux_fan_enabled %} + {% set speed = (params.S|float if params.S is defined else 255) / 255 %} + SET_FAN_SPEED FAN={aux_fan_name} SPEED={speed} + {% else %} + M106.1 {rawparams|trim} + {% endif %} + + +[gcode_macro M107] +description: Turn off fans (OrcaSlicer compatible, P2 = auxiliary fan) +rename_existing: M107.1 +gcode: + {% set aux_fan_enabled = printer["gcode_macro _USER_VARIABLES"].aux_fan_enabled|default(False) %} + {% set aux_fan_name = printer["gcode_macro _USER_VARIABLES"].aux_fan_name|default("aux_fan") %} + {% set fan_index = params.P|int if params.P is defined else 0 %} + + {% if fan_index == 2 and aux_fan_enabled %} + SET_FAN_SPEED FAN={aux_fan_name} SPEED=0 + {% else %} + M107.1 + {% endif %} diff --git a/macros/helpers/tachometer_check.cfg b/macros/helpers/tachometer_check.cfg index e42851805..a7ceaf24d 100644 --- a/macros/helpers/tachometer_check.cfg +++ b/macros/helpers/tachometer_check.cfg @@ -3,14 +3,16 @@ # Monitoring loop for hotend fan safety check [delayed_gcode _BACKGROUND_HOTEND_TACHO_CHECK] gcode: + {% set tach_poll_interval = printer["gcode_macro _USER_VARIABLES"].hotend_tach_poll_interval|default(3)|float %} _HOTEND_FAN_CHECK - UPDATE_DELAYED_GCODE ID=_BACKGROUND_HOTEND_TACHO_CHECK DURATION=3 + UPDATE_DELAYED_GCODE ID=_BACKGROUND_HOTEND_TACHO_CHECK DURATION={tach_poll_interval} [gcode_macro _HOTEND_FAN_CHECK] description: Check the hotend fan tachometer to verify that it is running effectively variable_he_stop_count: 0 gcode: {% set min_rpm = 1000|float %} # This is a relatively low value adapted to all fans to check that they are effectively running + {% set tach_poll_interval = printer["gcode_macro _USER_VARIABLES"].hotend_tach_poll_interval|default(3)|float %} {% set max_consecutive_stops = 3 %} {% set rpm = printer['heater_fan hotend_fan'].rpm|float %} {% set he_target = printer[printer.toolhead.extruder].target|float %} @@ -20,7 +22,7 @@ gcode: {% if (he_target >= fan_on_temp) and (rpm < min_rpm) and (he_temp >= fan_on_temp) %} SET_GCODE_VARIABLE MACRO=_HOTEND_FAN_CHECK VARIABLE=he_stop_count VALUE={he_stop_count + 1} - RESPOND MSG="Hotend fan stoppage detected for {(he_stop_count+1)*3}sec (max {max_consecutive_stops*3}sec allowed)" + RESPOND MSG="Hotend fan stoppage detected for {(he_stop_count+1)*tach_poll_interval|int}sec (max {max_consecutive_stops*tach_poll_interval|int}sec allowed)" M400 {% if printer["gcode_macro _HOTEND_FAN_CHECK"].he_stop_count|int >= max_consecutive_stops-1 %} CANCEL_PRINT diff --git a/macros/miscs/startup.cfg b/macros/miscs/startup.cfg index 1a05a1ad8..3f262aed6 100644 --- a/macros/miscs/startup.cfg +++ b/macros/miscs/startup.cfg @@ -33,6 +33,9 @@ gcode: # Check the probe configuration and compatibility with current includes _INIT_CHECKPROBECONF + # Check for M106/M107 silent-ignore misconfiguration (missing fan_bridge.cfg) + _INIT_CHECKFANCONF + # Check the filament sensor configuration and compatibility with current includes _INIT_CHECK_FILAMENT_SENSORS @@ -179,6 +182,20 @@ gcode: {% endif %} +[gcode_macro _INIT_CHECKFANCONF] +gcode: + # Detect: [fan] section exists, a [gcode_macro M106] is defined (from aux_fan.cfg or + # fan_bridge.cfg), but M106.1 is not available. This means the native [fan] M106 is + # overridden without rename_existing, so bare M106/M107 commands will be silently ignored. + {% set has_native_fan = printer.configfile.settings.fan is defined %} + {% set has_gcode_m106 = "gcode_macro M106" in printer %} + {% set has_m106_1 = "M106.1" in printer.gcode.commands %} + + {% if has_native_fan and has_gcode_m106 and not has_m106_1 %} + { action_raise_error("Klippain detected a [fan] section (native part cooling fan) but M106.1/M107.1 are not available. Bare M106/M107 commands will be silently ignored and the part cooling fan will not work. Add '[include macros/hardware_functions/fan_bridge.cfg]' to your printer.cfg after your fan includes, or remove the [fan] section.") } + {% endif %} + + [gcode_macro _INIT_CHECK_VIRTUAL_Z_PROBE] gcode: {% set vars = printer["gcode_macro _USER_VARIABLES"] %} @@ -259,7 +276,7 @@ gcode: {% if printer.ercf is not defined %} { action_raise_error("MMU support is enabled in Klippain, but HappyHare, the supported MMU/ERCF backend software is not detected. See the corresponding documentation: https://github.com/Frix-x/klippain/blob/main/docs/mmu.md") } {% else %} - { action_raise_error("ERCF support is enabled in Klippain, but the old version of version of HappyHare (ie. ERCF-Software-V3) is installed. Please uninstall it and install the latest HappyHare instead. See the corresponding documentation: https://github.com/Frix-x/klippain/blob/main/docs/mmu.md") } + { action_raise_error("ERCF support is enabled in Klippain, but the old version of HappyHare (ie. ERCF-Software-V3) is installed. Please uninstall it and install the latest HappyHare instead. See the corresponding documentation: https://github.com/Frix-x/klippain/blob/main/docs/mmu.md") } {% endif %} {% elif printer['gcode_macro _USER_VARIABLES'].mmu_force_homing_in_start_print is not defined or printer['gcode_macro _USER_VARIABLES'].mmu_unload_on_cancel_print is not defined or printer['gcode_macro _USER_VARIABLES'].mmu_unload_on_end_print is not defined or printer['gcode_macro _USER_VARIABLES'].mmu_check_gates_on_start_print is not defined or printer['gcode_macro _USER_VARIABLES'].mmu_check_errors_on_start_print is not defined %} { action_raise_error("MMU support is enabled in Klippain, but some variables are missing from your variables.cfg. Please update your template or refer to the corresponding documentation: https://github.com/Frix-x/klippain/blob/main/docs/mmu.md") } diff --git a/tests/config_check.py b/tests/config_check.py new file mode 100644 index 000000000..c88099872 --- /dev/null +++ b/tests/config_check.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Assemble a full Klippain printer config in a scratch dir and validate it +with a real Klipper (klippy) config load, without any hardware. + +The scratch dir mimics what the Klippain install script creates on a printer: +user_templates files are copied in, and the config/macros/scripts folders are +symlinked to the repository. A reference machine is enabled by uncommenting +the relevant lines of user_templates/printer.cfg. + +Usage: + python3 tests/config_check.py [standard|tachometer] [path/to/klipper] +""" + +import os +import re +import shutil +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +VARIANTS = { + "standard": "config/hardware/fans/aux_fan.cfg", + "tachometer": "config/hardware/fans/aux_fan_tachometer.cfg", +} + +# Reference machine: a Voron-style CoreXY 350mm with an inductive virtual Z +# endstop probe, chamber temperature sensor, a recirculating filter and both +# base + auxiliary part cooling fans. Keeps a real load of the Klippain macros +# (including the aux fan heatsink/vent/run-down logic) without extra plugins. +ENABLED_INCLUDES = [ + "config/kinematics/corexy.cfg", + "config/hardware/axis/X/1.8deg_1M.cfg", + "config/hardware/axis/Y/1.8deg_1M.cfg", + "config/hardware/axis/Z/Trident_TR8x8_1.8deg.cfg", + "config/hardware/axis/size/350mm.cfg", + "config/hardware/extruder/cw2.cfg", + "config/hardware/bed_heaters/keenovo.cfg", + "config/hardware/probes/inductive_virtual.cfg", + "config/hardware/fans/hotend_fan.cfg", + "config/hardware/fans/part_fan.cfg", + "config/hardware/fans/controller_fan.cfg", + "config/hardware/fans/rpi_fan.cfg", + "config/hardware/filters/nevermore_filter.cfg", + "config/hardware/temperature_sensors/chamber_temp.cfg", + "config/software/bed_mesh/bed_mesh_350mm.cfg", + "config/software/tilting/qgl_350mm.cfg", + "config/software/firmware_rectraction.cfg", +] + +# rpi_fan.cfg uses [temperature_fan] with sensor_type: temperature_host, +# which requires /sys/class/thermal/thermal_zone0/temp. Skip it when +# the host thermal zone is unavailable (e.g. CI runners, Docker, VMs). +if not os.path.exists("/sys/class/thermal/thermal_zone0/temp"): + ENABLED_INCLUDES = [p for p in ENABLED_INCLUDES if "rpi_fan" not in p] + +# Dummy main MCU + board_pins aliases covering every pin referenced by the +# reference machine above. Pins are only validated for existence, never used. +# Heater control is normally set in the user overrides.cfg (PID autotune), so +# it is provided here as well to satisfy Klipper's required options. +BOARD_PINS = """\ +[mcu] +serial: /dev/null + +[board_pins] +aliases: + X_STEP=PB0 , X_DIR=PB1 , X_ENABLE=PB2 , X_STOP=PB3 , + Y_STEP=PB4 , Y_DIR=PB5 , Y_ENABLE=PB6 , Y_STOP=PB7 , + Z_STEP=PB8 , Z_DIR=PB9 , Z_ENABLE=PB10 , Z_STOP=PB11 , + Z1_STEP=PB12, Z1_DIR=PB13 , Z1_ENABLE=PB14 , + Z2_STEP=PC0 , Z2_DIR=PC1 , Z2_ENABLE=PC2 , + E_STEP=PC3 , E_DIR=PC4 , E_ENABLE=PC5 , + E_HEATER=PA2, E_TEMPERATURE=PF4 , + BED_HEATER=PA3, BED_TEMPERATURE=PF3 , + PROBE_INPUT=PG15 , + E_FAN=PE5 , PART_FAN=PA8 , AUX_FAN=PC6 , AUX_FAN_TACHO=PC7 , + CONTROLLER_FAN=PD12 , HOST_CONTROLLER_FAN=PD13 , FILTER_FAN=PD14 , + CHAMBER_TEMPERATURE=PF5 + +[heater_bed] +control: pid +pid_Kp: 68.6 +pid_Ki: 1.95 +pid_Kd: 603.0 + +[extruder] +control: pid +pid_Kp: 26.0 +pid_Ki: 1.77 +pid_Kd: 95.6 +""" + + +def build_printer_cfg(variant): + """Uncomment the selected [include] lines of user_templates/printer.cfg.""" + template_path = os.path.join(REPO_ROOT, "user_templates", "printer.cfg") + selected = set(ENABLED_INCLUDES) | {VARIANTS[variant]} + out = [] + with open(template_path, encoding="utf-8") as handle: + for line in handle: + stripped = line.lstrip() + if stripped.startswith("# [include "): + spec = stripped[len("# [include "):].strip() + spec = spec.split("#", 1)[0].strip() + spec = spec.rstrip("]") + if spec in selected: + out.append(line[2:] if line.startswith("# ") else line) + continue + out.append(line) + return "".join(out) + + +def main(): + variant = sys.argv[1] if len(sys.argv) > 1 else "standard" + klipper = ( + sys.argv[2] + if len(sys.argv) > 2 + else os.path.join(REPO_ROOT, "klipper") + ) + if variant not in VARIANTS: + raise SystemExit("Unknown variant '%s' (use: %s)" + % (variant, ", ".join(sorted(VARIANTS)))) + klippy = os.path.join(klipper, "klippy", "klippy.py") + if not os.path.exists(klippy): + raise SystemExit( + "Klipper not found at '%s' - clone it first:\n" + " git clone --depth 1 https://github.com/Klipper3d/klipper.git klipper" + % (klipper,)) + + with tempfile.TemporaryDirectory(prefix="klippain_ci_") as scratch: + # User config files, mirroring what install.sh copies + for name in ("variables.cfg", "overrides.cfg"): + shutil.copy( + os.path.join(REPO_ROOT, "user_templates", name), scratch) + # save_variables is defined in variables.cfg and must point to a + # writable file inside the scratch dir (no real printer_data exists) + variables_path = os.path.join(scratch, "variables.cfg") + with open(variables_path, encoding="utf-8") as handle: + variables = handle.read() + variables = re.sub( + r"filename:.*", "filename: %s" + % os.path.join(scratch, "save_variables.cfg"), variables, count=1) + with open(variables_path, "w", encoding="utf-8") as handle: + handle.write(variables) + with open(os.path.join(scratch, "printer.cfg"), "w", + encoding="utf-8") as handle: + handle.write(build_printer_cfg(variant)) + with open(os.path.join(scratch, "mcu.cfg"), "w", + encoding="utf-8") as handle: + handle.write(BOARD_PINS) + + # Klippain-managed folders, symlinked like the install script does + for folder in ("config", "macros", "scripts"): + os.symlink(os.path.join(REPO_ROOT, folder), + os.path.join(scratch, folder)) + + # Stage the gcode_shell_command plugin used by scripts/*.cfg + plugin = os.path.join(REPO_ROOT, "scripts", "gcode_shell_command.py") + plugin_dest = os.path.join(klipper, "klippy", "extras", + "gcode_shell_command.py") + if not os.path.exists(plugin_dest): + shutil.copy(plugin, plugin_dest) + + cmd = [ + sys.executable, klippy, + "-i", os.devnull, + "-l", os.path.join(scratch, "klippy.log"), + os.path.join(scratch, "printer.cfg"), + ] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, + timeout=60) + except subprocess.TimeoutExpired: + proc = None + log = "" + log_path = os.path.join(scratch, "klippy.log") + if os.path.exists(log_path): + with open(log_path, encoding="utf-8", errors="replace") as handle: + log = handle.read() + combined = (proc.stdout + proc.stderr + log) if proc else log + + # klippy exits nonzero on the expected "no MCU connected" failure, and + # can also stay alive retrying the MCU serial connection, so classify + # based on the log content instead of the exit code: + # - a real config/template problem logs "Config error" or raises an + # unhandled exception during config loading + # - a healthy config either exits on the MCU connection step or hangs + # retrying it + if re.search(r"Config error|Unhandled exception during connect", + combined): + print("=== klippy output (first 4000 chars) ===") + print(combined[:4000]) + print("=== klippy log tail ===") + print(log[-4000:]) + raise SystemExit( + "FAIL: variant '%s' - config or macro is invalid" + % (variant,)) + + if "Unable to connect" in combined or "Unable to open serial port" in combined: + print("PASS: variant '%s' config loaded cleanly by klippy " + "(only the expected MCU connection failed)" % variant) + elif proc is None: + print("PASS: variant '%s' config loaded cleanly by klippy " + "(klippy timed out retrying the MCU connection)" % variant) + else: + print("PASS: variant '%s' config loaded cleanly by klippy" + % variant) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/lint_jinja.py b/tests/lint_jinja.py new file mode 100644 index 000000000..d02adea69 --- /dev/null +++ b/tests/lint_jinja.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Jinja2 template syntax check for all Klippain gcode config files. + +This catches template syntax errors in every [gcode_macro] / [delayed_gcode] +and other templated gcode blocks without needing a running Klipper instance. +It reuses the same Jinja2 delimiters Klipper uses, so a pass here matches what +klippy would accept at config load time. + +Usage: + python3 tests/lint_jinja.py +""" + +import os +import re +import sys + +import jinja2 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Options that Klipper renders through its Jinja2 engine +TEMPLATED_OPTIONS = { + "gcode", + "on_error_gcode", + "start_gcode", + "end_gcode", + "on_gcode", + "on_pause", + "on_resume", + "on_heating_error", +} + +SKIP_DIRS = {".git", "mcu_defaults"} +SECTION_RE = re.compile(r"^\s*\[") +OPTION_RE = re.compile(r"^([A-Za-z0-9_]+)\s*:\s?(.*)$") + + +def iter_cfg_files(root): + for dirpath, dirnames, filenames in os.walk(root): + if SKIP_DIRS & set(dirpath.split(os.sep)): + continue + for name in filenames: + if name.endswith(".cfg"): + yield os.path.join(dirpath, name) + + +def parse_templated_blocks(text): + """Yield (section, option, value, lineno) for each templated block.""" + lines = text.splitlines() + section = None + i = 0 + while i < len(lines): + line = lines[i] + if SECTION_RE.match(line): + section = line.strip()[1:-1].strip() + i += 1 + continue + match = OPTION_RE.match(line.lstrip()) + if match is not None and match.group(1) in TEMPLATED_OPTIONS: + value_lines = [match.group(2)] + j = i + 1 + while j < len(lines) and ( + lines[j] == "" or lines[j][:1] in (" ", "\t") + ): + value_lines.append(lines[j]) + j += 1 + yield section, match.group(1), "\n".join(value_lines), i + 1 + i = j + continue + i += 1 + + +def main(): + env = jinja2.Environment("{%", "%}", "{", "}") + errors = [] + checked = 0 + for fpath in iter_cfg_files(REPO_ROOT): + with open(fpath, encoding="utf-8", errors="replace") as handle: + text = handle.read() + for section, option, value, lineno in parse_templated_blocks(text): + checked += 1 + try: + env.parse(value) + except jinja2.TemplateSyntaxError as exc: + relpath = os.path.relpath(fpath, REPO_ROOT) + exc_line = lineno + (exc.lineno or 1) - 1 + errors.append( + "%s ([%s] %s, line %d): %s" + % (relpath, section, option, exc_line, exc.message) + ) + + if errors: + print("Jinja2 syntax errors found:") + for error in errors: + print(" " + error) + print("\n%d/%d templated blocks failed" % (len(errors), checked)) + return 1 + + print("Jinja2 syntax OK: %d templated blocks checked" % checked) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_fan_conf_check.py b/tests/test_fan_conf_check.py new file mode 100644 index 000000000..0fd9734b0 --- /dev/null +++ b/tests/test_fan_conf_check.py @@ -0,0 +1,111 @@ +"""Tests for the _INIT_CHECKFANCONF startup macro. + +Verifies that the startup check correctly detects the M106/M107 silent-ignore +misconfiguration: a [fan] section exists, a [gcode_macro M106] is defined, +but M106.1 is not available (no fan_bridge.cfg loaded). +""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +STARTUP_CFG = REPO_ROOT / "macros" / "miscs" / "startup.cfg" +FAN_BRIDGE_CFG = REPO_ROOT / "macros" / "hardware_functions" / "fan_bridge.cfg" +AUX_FAN_CFG = REPO_ROOT / "macros" / "hardware_functions" / "aux_fan.cfg" + + +class TestInitCheckFanConfExists(unittest.TestCase): + """Verify _INIT_CHECKFANCONF macro is defined and called at startup.""" + + def setUp(self) -> None: + self.startup_text = STARTUP_CFG.read_text(encoding="utf-8") + + def test_macro_is_defined(self) -> None: + assert "[gcode_macro _INIT_CHECKFANCONF]" in self.startup_text + + def test_macro_is_called_at_startup(self) -> None: + # Must appear inside _KLIPPAIN_STARTUP, after _INIT_CHECKPROBECONF + startup_macro = self.startup_text.split("[gcode_macro _KLIPPAIN_STARTUP]")[1] + assert "_INIT_CHECKFANCONF" in startup_macro + + def test_check_comes_after_probe_check(self) -> None: + probe_pos = self.startup_text.find("_INIT_CHECKPROBECONF") + fan_pos = self.startup_text.find("_INIT_CHECKFANCONF") + assert probe_pos > 0, "_INIT_CHECKPROBECONF not found" + assert fan_pos > probe_pos, "_INIT_CHECKFANCONF should come after _INIT_CHECKPROBECONF" + + +class TestInitCheckFanConfLogic(unittest.TestCase): + """Verify the detection logic in _INIT_CHECKFANCONF checks the right conditions.""" + + def setUp(self) -> None: + self.startup_text = STARTUP_CFG.read_text(encoding="utf-8") + # Extract the _INIT_CHECKFANCONF macro body + match = re.search( + r"\[gcode_macro _INIT_CHECKFANCONF\]\s*gcode:\s*(.*?)(?=\n\[gcode_macro|\Z)", + self.startup_text, + re.S, + ) + assert match is not None, "_INIT_CHECKFANCONF macro body not found" + self.macro_body = match.group(1) + + def test_checks_native_fan_section(self) -> None: + assert "printer.configfile.settings.fan" in self.macro_body + + def test_checks_gcode_macro_m106(self) -> None: + assert "gcode_macro M106" in self.macro_body + + def test_checks_m106_1_command(self) -> None: + assert "M106.1" in self.macro_body + + def test_raises_error_on_misconfiguration(self) -> None: + assert "action_raise_error" in self.macro_body + + def test_error_message_mentions_fan_bridge_cfg(self) -> None: + assert "fan_bridge.cfg" in self.macro_body + + def test_error_message_mentions_silent_ignore(self) -> None: + assert "silently ignored" in self.macro_body + + def test_logic_is_conjunction(self) -> None: + # The macro should only raise when ALL three conditions are true: + # has_native_fan AND has_gcode_m106 AND NOT has_m106_1 + assert "has_native_fan and has_gcode_m106 and not has_m106_1" in self.macro_body + + +class TestFanBridgeComment(unittest.TestCase): + """Verify fan_bridge.cfg header reflects the startup validation.""" + + def setUp(self) -> None: + self.bridge_text = FAN_BRIDGE_CFG.read_text(encoding="utf-8") + + def test_mentions_startup_validation(self) -> None: + assert "startup" in self.bridge_text.lower() + + def test_no_longer_suggests_manual_include_as_only_option(self) -> None: + # The old comment said "add this include in your printer.cfg" + # The new comment should mention that Klippain validates at startup + assert "validates this at startup" in self.bridge_text.lower() or \ + "validates" in self.bridge_text.lower() + + +class TestAuxFanComment(unittest.TestCase): + """Verify aux_fan.cfg header reflects the startup validation.""" + + def setUp(self) -> None: + self.aux_text = AUX_FAN_CFG.read_text(encoding="utf-8") + + def test_mentions_startup_validation(self) -> None: + assert "startup" in self.aux_text.lower() + + def test_no_longer_says_silently_ignored(self) -> None: + # The fallback comment should not say "silently ignored" anymore + fallback_section = self.aux_text.split("Fallback M106/M107")[1] if "Fallback M106/M107" in self.aux_text else "" + assert "silently ignored" not in fallback_section + + +if __name__ == "__main__": + unittest.main() diff --git a/user_templates/printer.cfg b/user_templates/printer.cfg index a32b64495..837c0ad44 100644 --- a/user_templates/printer.cfg +++ b/user_templates/printer.cfg @@ -129,6 +129,13 @@ # [include config/hardware/fans/hotend_fan_tachometer.cfg] # When having a tachometer monitoring hotend fan speed # [include config/hardware/fans/part_fan_tachometer.cfg] # When having a tachometer monitoring part fan speed +# [include config/hardware/fans/aux_fan.cfg] # Secondary part cooling fan (AUX_FAN, independent control, OrcaSlicer "Auxiliary Part Cooling Fan" support) +# [include config/hardware/fans/aux_fan_tachometer.cfg] # Same, with tachometer monitoring (requires an AUX_FAN_TACHO pin; use this instead of aux_fan.cfg) +# NOTE: When using a toolhead board template (e.g. BTT EBB, Mellow SHT) instead of part_fan.cfg, +# you must also include fan_bridge.cfg for M106/M107 bridging. Klippain validates this at startup. +# NOTE: When using both aux_fan.cfg and part_fan.cfg, include aux_fan.cfg BEFORE part_fan.cfg. +# This ensures fan_bridge.cfg's rename_existing M106/M107 takes precedence over the fallback. + # [include config/hardware/fans/controller_fan.cfg] # [include config/hardware/fans/rpi_fan.cfg] # ---------------------------------------------------------------------------------------- @@ -283,7 +290,7 @@ # --------------------------------------------------------------- FIRMWARE RETRACTION ---- ### -------------------------------------------------------------------------------------- -# [include config/software/firmware_rectraction.cfg] +# [include config/software/firmware_retraction.cfg] # ---------------------------------------------------------------------------------------- diff --git a/user_templates/variables.cfg b/user_templates/variables.cfg index fcaf06181..78e6152eb 100644 --- a/user_templates/variables.cfg +++ b/user_templates/variables.cfg @@ -125,7 +125,7 @@ variable_probe_servo_angle_deployed: 90 ## See diagram below for help variable_probe_before_attach_position: "front" variable_probe_after_attach_position : "front" -variable_probe_before_dock_position : "front" # generaly same as probe_after_attach_position +variable_probe_before_dock_position : "front" # generally same as probe_after_attach_position variable_probe_after_dock_position : "left" variable_probe_move_attach_length: 30 @@ -187,9 +187,9 @@ variable_print_default_soak: 8 variable_print_default_material: "XXX" ## Material configuration parameters applied during START_PRINT by using the slicer MATERIAL variable -## FYI, retract paramaters are used only if firmware retraction is enabled, filter speed (in %) is used if +## FYI, retract parameters are used only if firmware retraction is enabled, filter speed (in %) is used if ## there is a filter installed on the machine, filament sensor is on or off if installed, etc... -## If you are using another material, just extend the list bellow with a new material and everything should work :) +## If you are using another material, just extend the list below with a new material and everything should work :) variable_material_parameters: { 'PLA': { 'pressure_advance': 0.0525, @@ -252,6 +252,23 @@ variable_mmu_check_errors_on_start_print: False # Set to True if you want an ea variable_filter_default_time_on_end_print: 600 # seconds +################################################ +## Auxiliary fan specific variables +################################################ +## This section is only considered if an auxiliary fan is available (and enabled) +## The auxiliary fan is an independent secondary part cooling fan (e.g. OrcaSlicer "Auxiliary Part Cooling Fan") + +variable_aux_fan_speed_on_end_print: 100 # percent of max speed, aux fan keeps running after the print ends +variable_aux_fan_time_on_end_print: 300 # seconds the aux fan keeps running after the print ends +variable_aux_fan_soak_speed: 0 # percent of max speed during bed/chamber heatsoak to spread the heat (0 = disabled) +variable_aux_fan_vent_temp: 0 # chamber temperature (in °C) above which the aux fan vents the chamber at print start (0 = disabled) +variable_aux_fan_vent_speed: 100 # percent of max speed during the chamber vent at print start +variable_aux_fan_vent_time: 120 # seconds the aux fan vents the chamber at print start +variable_aux_fan_vent_status_interval: 10 # seconds between status messages during chamber vent at print start +variable_aux_fan_tach_min_rpm_max: 1000 # RPM threshold at full fan speed for tachometer monitoring +variable_aux_fan_tach_min_rpm_floor: 200 # minimum RPM threshold at low fan speeds +variable_aux_fan_tach_max_consecutive_stops: 3 # consecutive tach failures before auto-cancel + ################################################ # Other hardware options used in the macros ################################################