PID Controlled Exhaust Fan (Quiet!)

Update 2025-11-17: Now a PI controller (proportional + integral) instead of just proportional

Update 2025-11-22: M141 macro & auto-stop after a print has finished
Update 2025-11-23: bugfixes to CHAMBER_CONFIG, updated tuning values


I think we are all frustrated with the exhaust_fan! It is loud, and the watermark controller leaves horizontal banding on the prints!

This overhaul allows the fan to be quieter, actually throttle, and even reach 100% fan in the rare cases where it is necessary!


Implementation

You will need to edit both printer.cfg and macro.cfg, and optionally change your Starting G-Code to have chamber temperature controlled by your cliser

Macro.cfg

Replace your START_PRINT with

Modified START_PRINT macro
[gcode_macro START_PRINT]
gcode:
    {% if printer.save_variables.variables.was_interrupted|float == True %}
        PRINT_END #Clear the last printed power-off information
        CHANG_ROOT_MAIN
        SET_DISPLAY_TEXT
    {% endif %}
    
    {% set cur_target = printer.save_variables.variables.ch_target|default(0.0) %}
    {% if cur_target <= 0 %}
        SAVE_VARIABLE VARIABLE=ch_target VALUE=32.0
        RESPOND PREFIX="chamber" MSG="START_PRINT: chamber target defaulted to 32C"
    {% else %}
        RESPOND PREFIX="chamber" MSG="START_PRINT: chamber target kept at {cur_target}C"
    {% endif %}
    CHAMBER_ON

    CLEAR_PAUSE
    M117 Clean Nozzle
    CLEAN_NOZZLE
    SET_GCODE_OFFSET Z=0
    M117 Eddy Calibration
    Z_OFFSET_CALIBRATION METHOD=force_overlay BED_TEMP={printer.heater_bed.target}
    SET_GCODE_VARIABLE MACRO=_global_var VARIABLE=has_z_offset_calibrated VALUE=True
    G28 Z
    M117 Mesh Calibration
    BED_MESH_CALIBRATE
    SET_GCODE_VARIABLE MACRO=_global_var VARIABLE=has_z_offset_calibrated VALUE=False
    save_last_file
    M117 {last_file}

Printer.cfg

  1. Remove the entire [temperature_fan exhaust_fan section]
  2. Add the following
Additions to printer.cfg
[temperature_sensor chamber_temp]
sensor_type: EPCOS 100K B57560G104F
sensor_pin: PC4
min_temp: 0
max_temp: 80
[fan_generic exhaust_fan]
pin: PB0
tachometer_pin: PB1
tachometer_ppr: 1
tachometer_poll_interval: 0.0046
cycle_time: 0.01
max_power: 1.0
kick_start_time: 0.80
[save_variables]
filename: ~/variables.cfg


[gcode_macro CHAMBER_LOOP]
gcode:
    {% set temp = printer["temperature_sensor chamber_temp"].temperature %}
    {% set target = printer.save_variables.variables.ch_target|default(32.0) %}
    {% set kp = printer.save_variables.variables.ch_kp|default(0.15) %}
    {% set ki = printer.save_variables.variables.ch_ki|default(0.020) %}
    {% set minfan = printer.save_variables.variables.ch_minfan|default(0.15) %}
    {% set maxfan = printer.save_variables.variables.ch_maxfan|default(1.00) %}
    {% set i_max = printer.save_variables.variables.ch_i_max|default(0.6) %}
    {% set i_primed = printer.save_variables.variables.ch_i_primed|default(0) %}
    {% set auto_off_delta = 3.0 %}
    {% set i_acc = printer.save_variables.variables.ch_integral|default(0.0) %}

    {% set error = temp - target %}
    {% set i_acc = i_acc + (error * ki) %}

    {% if i_acc > i_max %}
        {% set i_acc = i_max %}
        RESPOND PREFIX="ch" MSG="Accumulator clamped Ki={ki}"
    {% endif %}
    {% if i_acc < (0 - i_max) %}
        {% set i_acc = 0 - i_max %}
        RESPOND PREFIX="ch" MSG="Accumulator clamped Ki={ki}"
    {% endif %}

    {% if i_primed == 0 and ( error < +1.0 and error > -1.0 ) %}
        {% set i_acc = 0 %}
        {% set i_primed = 1 %}
        RESPOND PREFIX="ch" MSG="Target reached"
    {% endif %}
    {% if i_primed == 1 and error <= (0 - auto_off_delta) %}
        CHAMBER_OFF
    {% endif %}
    
    {% set raw = (error * kp) + i_acc %}

    {% if raw < 0 %}
        {% set duty = 0 %}
    {% elif raw > maxfan %}
        {% set duty = maxfan %}
        RESPOND PREFIX="ch" MSG="Fan at maximum S={duty}"
    {% else %}
        {% set duty = raw %}
    {% endif %}

    {% if duty < minfan and duty > 0 %}
        {% set duty = 0 %}
    {% endif %}

    SET_FAN_SPEED FAN=exhaust_fan SPEED={duty}

    SAVE_VARIABLE VARIABLE=ch_last_duty VALUE={duty}
    SAVE_VARIABLE VARIABLE=ch_integral  VALUE={i_acc}
    SAVE_VARIABLE VARIABLE=ch_i_primed  VALUE={i_primed}

    UPDATE_DELAYED_GCODE ID=CHAMBER_TIMER DURATION=60


[gcode_macro CHAMBER_ON]
gcode:
    SAVE_VARIABLE VARIABLE=ch_integral VALUE=0.0
    SAVE_VARIABLE VARIABLE=ch_i_primed VALUE=0
    UPDATE_DELAYED_GCODE ID=CHAMBER_TIMER DURATION=60
    RESPOND PREFIX="ch" MSG="Chamber control on."
    

[gcode_macro CHAMBER_OFF]
gcode:
    UPDATE_DELAYED_GCODE ID=CHAMBER_TIMER DURATION=0
    SET_FAN_SPEED FAN=exhaust_fan SPEED=0
    SAVE_VARIABLE VARIABLE=ch_integral VALUE=0
    RESPOND PREFIX="ch" MSG="Chamber control off, I reset."
    CH_STATUS_STOP


[gcode_macro CHAMBER_CONFIG]
gcode:
    {% set old_target = printer.save_variables.variables.ch_target|default(32.0) %}
    {% set old_kp     = printer.save_variables.variables.ch_kp|default(0.30) %}
    {% set old_minfan = printer.save_variables.variables.ch_minfan|default(0.40) %}
    {% set old_maxfan = printer.save_variables.variables.ch_maxfan|default(1.00) %}
    {% set old_ki     = printer.save_variables.variables.ch_ki|default(0.00) %}
    {% set old_i_max  = printer.save_variables.variables.ch_i_max|default(0.30) %}

    {% set target = params.TARGET|default(old_target)|float %}
    {% set kp     = params.KP|default(old_kp)|float %}
    {% set minfan = params.MINFAN|default(old_minfan)|float %}
    {% set maxfan = params.MAXFAN|default(old_maxfan)|float %}
    {% set ki     = params.KI|default(old_ki)|float %}
    {% set i_max  = params.I_MAX|default(old_i_max)|float %}

    SAVE_VARIABLE VARIABLE=ch_target  VALUE={target}
    SAVE_VARIABLE VARIABLE=ch_kp      VALUE={kp}
    SAVE_VARIABLE VARIABLE=ch_minfan  VALUE={minfan}
    SAVE_VARIABLE VARIABLE=ch_maxfan  VALUE={maxfan}
    SAVE_VARIABLE VARIABLE=ch_ki      VALUE={ki}
    SAVE_VARIABLE VARIABLE=ch_i_max   VALUE={i_max}

    RESPOND PREFIX="ch" MSG="Config updated: T={target} Kp={kp} Ki={ki} clamp={i_max} min={minfan} max={maxfan}"


[gcode_macro CH_STATUS]
gcode:
    {% set temp_raw = printer["temperature_sensor chamber_temp"].temperature %}
    {% set target_raw = printer.save_variables.variables.ch_target|default(32.0) %}
    {% set kp = printer.save_variables.variables.ch_kp|default(0.15) %}
    {% set error_raw = (temp_raw - target_raw) * kp %}
    {% set i_raw = printer.save_variables.variables.ch_integral|default(0.00) %}

    {% set temp_1 = (temp_raw * 10) // 1 %}
    {% set temp = temp_1 / 10 %}
    {% set target_1 = (target_raw * 10) // 1 %}
    {% set target = target_1 / 10 %}
    {% set error_3 = (error_raw * 1000) // 1 %}
    {% set error = error_3 / 1000 %}
    {% set i_3 = (i_raw * 1000) // 1 %}
    {% set i_val = i_3 / 1000 %}

    {% set duty = printer.save_variables.variables.ch_last_duty|default(0.0) %}
    {% set fan_i = (duty * 100) // 1 %}

    {% set rpm_raw = printer["fan_generic exhaust_fan"].rpm|default(0) %}
    {% set rpm_i = (rpm_raw * 1) // 1 %}
    {% set rpm = rpm_i %}

    RESPOND PREFIX="ch" MSG="T={target} A={temp} P={error} I={i_val} F={fan_i}% R={rpm}"


[delayed_gcode CHAMBER_TIMER]
gcode:
    CHAMBER_LOOP


[gcode_macro CH_STATUS_LOOP]
gcode:
    CH_STATUS
    UPDATE_DELAYED_GCODE ID=CH_STATUS_TIMER DURATION=60


[delayed_gcode CH_STATUS_TIMER]
gcode:
    CH_STATUS_LOOP


[gcode_macro CH_STATUS_STOP]
gcode:
    RESPOND PREFIX="ch" MSG="Live status stopped."
    UPDATE_DELAYED_GCODE ID=CH_STATUS_TIMER DURATION=0


[gcode_macro CH_RESET_I]
gcode:
    SAVE_VARIABLE VARIABLE=ch_integral VALUE=0
    RESPOND PREFIX="ch" MSG="Integral reset."


[gcode_macro M141]
gcode:
    {% set s = params.S|default(0)|float %}

    SAVE_VARIABLE VARIABLE=ch_target VALUE={s}
    RESPOND PREFIX="ch" MSG="M141: chamber target set to {s}C"

    {% if s <= 0 %}
        CHAMBER_OFF
    {% else %}
        CHAMBER_ON
    {% endif %}


[gcode_macro M191]
gcode:
    {% set s = params.S|default(-9999)|float %}
    {% set r = params.R|default(-9999)|float %}

    {% if r != -9999 %}
        {% set target = r %}
    {% else %}
        {% set target = s %}
    {% endif %}

    SAVE_VARIABLE VARIABLE=ch_target VALUE={target}
    RESPOND PREFIX="ch" MSG="M191: target={target}C"

    {% if target <= 0 %}
        CHAMBER_OFF
    {% else %}
        CHAMBER_ON

        {% if r != -9999 %}
            {% set tol = 1.0 %}
            {% set t_min = target - tol %}
            {% set t_max = target + tol %}
            TEMPERATURE_WAIT SENSOR="temperature_sensor chamber_temp" MIN={t_min} MAX={t_max}
        {% else %}
            TEMPERATURE_WAIT SENSOR="temperature_sensor chamber_temp" MIN={target}
        {% endif %}
    {% endif %}

Starting G-Code

This part is optional, as the default behavior with just the above Klipper modifications results in the same behavior as stock (exhaust fan regulates at 32C, just better).

If you want slicer control to set other chambers, see Chamber temperature control - #5 by PicobelloBV and PrusaSlicer profile for Sovol Zero! - #13 by rpcyan for OrcaSlicer and PrusaSlicer/SuperSlicer implementations.


Adjusting Chamber temperature via Console

To set other temperatures, use any M141 / M191 command:

M141 S33 ; set and continue
M191 S33 ; set and wait
M191 R33 ; set and wait (either heating or cooling; tolerance of +/-1 deg)

or

CHAMBER_CONFIG TARGET=33

(I tried to preserve the old dashboard card control, but I don’t think that’s possible given the limitations below)


Tuning

These values work well for me, and are set as defaults, so hopefully additional tuning is not required.

The integral term should really help cope with different filament, bed, and room temperatures. But I admit I am hot a high temperature filament guy (yet).

  • Chamber Temp = 32°C
  • Kp = 0.20
  • Ki = 0.10
  • Minimum fan = 0.14
  • I_max = 0.80
  • kick_start_time = 0.80

All of these parameters, except the fan’s kick time, are configurable with CHAMBER_CONFIG and can be changed on the fly without editing the configuration file & restarting the firmware. These y will also persist across prints by being stored in saved_variables.cfg (where other unrelated variables are also stored)

To Tune:

  1. run CH_STATUS_LOOP in the console
  2. Use Kp to adjust your gain until you reach an oscillation, then halve it (rule of thumb)
    1. This will likely undershoot & that’s OK, let Ki fix the remainder
  3. Slowly increase Ki to reduce error
  4. Adjust min_fan if you notice stall conditions

This is a classic PI controller (no Derivative term is needed for something this slow) and there are plenty of online tutorials out there to assist. I used CH_STATUS_LOOP and pasted the contents into a spreadsheet for plotting. This is admittedly tedious, but frankly chamber temp is pretty forgiving of imperfect tuning.

I’ll continue to post my coefficients refinements here, especially as I try other filaments.


Chamber Temperatures

According to the Chamber Heating Module for Zero product page

  • ≤30°C for PLA. HP-PLA, PLA-CF (door & lid open)
  • 50°C for PETG, PETG-CF
    • This is different than generic online online advice of keeping the chamber below 45°C to avoid heat creep related problems late in the print. But presumably those printers don’t have the zero’s powerful extruder fan
    • @Liberty4Ever has noted that PETG clogs occur when the chamber reaches 61°C
  • 60°C for ABS, ASA, PA/PC

Limitations

Sovol’s embedded / ultra-stripped-down implementation of Klipper did not make this easy to implement. Specifically:

  • The built-in watermark control method could be somewhat improved with a smaller delta to reduce banding, but this would mean the fan would continue to be loud and bang-bang even quicker
  • The built-in PID control method is entirely broken and does NOT follow proper PID behavior at all. It isn’t even proportional!
    • Best I can determine is that the fan_speed is not proportional to the error signal (chamber-target) , but simply to just the chamber temperature. While a moderately successful gain could be found under narrow circumstances, a change in filament or even the room’s temperature throws this off. It just isn’t worth pursuing
  • While the Temperature dashboard is similar to before, the “Exhaust Fan” had to be replaced with “Chamber Temp”, losing the target temperature reporting and setting. Fan speed and RPM are also missing.
    • The Exhaust Fan percentage and RPM are viewable in the Miscellaneous card
    • any speed adjustments made here are overridden by the chamber controller at the next loop cycle
  • Variables (like the integral accumulator) are not persistent and instead have to be written to the filesystem to be called upon from one loop execution to the next
  • no default Klipper or custom auto-PID tuning macro
  • Chamber thermistor suffers from poor placement, airflow, wall’s thermal inertia, and power supply heat, per Chamber Temperature Sensor Whacked?

Known bugs

  • Chamber cooling does not always continue after print has finished
  • The reported RPM is quite inaccurate - 35% speed reported about 6500 RPM, and higher speed report less RPM, not more, with 100% being around around 1020 RPM

To-do

  • Plotting and/or logging without copy-pasting from console
  • Stall detection & automatic increase of minfan

Chamber Heater

While this is about the exhaust fan, not the chamber heater, they are obviously related. I hope this may be a jumping off point for those struggling with the new chamber heater accessory (honestly, I’m on the fence as to whether I’ll purchase one when they’re back in stock)


Nevermore or other chamber re-circulation filters

I have not yet implemented this on my printer. Anyone who has should expect to have different chamber characteristics and should perform a manual tuning outlined above.

Given the issues with the chassis temperature sensor, I would expect additional re-circulatory air flow, whether from a filter or a heater, will help improve the chassis temperature sensor. I would also expect it to slow down the initial bed heating.


Changelog

  • 2025-11-17
    • increased loop period from 1s to 10s. chamber moves slowly, and I was getting very quick fan pulses yesterday that made me worried I’d burnout the fan in the long term
    • integral windup protection
    • removed offset/trim value; deprecated by integral term
    • automatic integral reset when off
    • shorter console status lines
  • 2025-11-22
    • Changed loop period from 10s to 5s for convenience
    • Added M141 & M191 like @PicobelloBV did along with corresponding Starting G-code Chamber Temperature Control
    • Reset integral accumulator once target temperature is initially reached (“primed”) to avoid windup
    • Modified START_PRINT macro to maintain stock 32C behavior without blocking other desired temperatures being issued from the slicer
  • 2025-11-22 continued
    • Actually increased loop period to 60s to reduce filesystem writes - don’t want to wear out the onboard eMMC!
    • some bugfixes (accumulator was broken)
    • stop the chamber controller once a print has ended and it has cooled down a few degrees
    • added some console messages when the various conditionals are reached
  • 2025-11-23
    • Fixed CHAMBER_CONFIG bug where changes would not save
    • Updated Ki to be much stronger since polling is less frequent; other values tweaked
5 Likes

bump for major update.

Let me know how this works for you!

2 Likes

Works great for me. Printing PETG enclosed with 50 c max on the chamber.

Thank you

2 Likes

I have updated start print macro and printer.cfg, now it does throttle nicely when I sent M141 commands. But I get errors when using M191:

Also it seems to send M191 commands when starting a print, cancelling the print with an error. Not sure where that command comes from, I did not see M191 in the START_PRINT.

Any ideas where I might have made a mistake?

Typo on my part, try replacing the M141 & M191 macros with this. I made the following changes:

  • Fixed the min/max bug
  • If the print bed isn’t already on, turn it on
    • OrcaSlicer sends its chamber “Activate temperature control” command before the Starting gcode, so it’ll never actually begin the print, it’ll just wait indefinitely controlling the exhaust fan without any heat load
    • Guesses the desired print bed temp based on solely the chamber setpoint
      • Aims a little hot in order to speed up this preheat
      • Limits bed to 124°C in case the bed (requires bed to be tuned!)
    • The actual desired bed temp will still be set, but not until the chamber’s gotten within tolerance and the Starting G-Code is finally executed
    • The actual bed temp will be reached roughly
  • Stir the air w/ heat-brake and nozzle-cooling fans
    • Considerably speeds up chamber_temp sensor response (see subsequent posts)
  • Widened starting tolerance to ±10°
    • Excessive waiting without the extruder’s heat load & motion mixing the air isn’t ideal behavior
    • Variable adjustable and saved, as it is a matter of preference,
      • ±15° may be better for PETG
      • ±5° may be desired for the the higher temp folks
      • ±1°with the “whacked” physical chamber_temp sensor placement is just silly
    • This starts a PLA print at 20° chamber, and PETG at 40°, for their respective 30°C and 50°C targets, which seems like a reasonable all-rounder behavior
  • Refactored M191 as a “waiting wrapper” around M141 to reduce code duplication
[gcode_macro M141]
gcode:
    {% set s    = params.S   |default(0)|float %}
    SAVE_VARIABLE VARIABLE=ch_target VALUE={s}

    {% set stir = TRUE %}

    {% if s <= 0 %}
        CHAMBER_OFF
        RESPOND PREFIX="M141:" MSG="target={s}C (off)"
    {% else %}
        {% set bed_target_cur = printer.heater_bed.target|default(0.0) %}

        {% if bed_target_cur <= 0 %}
            {% set bed_temp = ((0.0045*s*s*s) - (0.575*s*s) + (25.2 * s) - 290.0)|int %}
            {% if bed_temp > 124 %}
                {% set bed_temp = 124 %}
            {% endif %}
            M140 S{bed_temp}
            RESPOND PREFIX="M141:" MSG="bed not set, guessing {bed_temp}C"
            {% set bed_report = bed_temp %}
        {% else %}
            {% set bed_1 = bed_target_cur * 1 %}
            {% set bed_report = bed_1 // 1 %}
        {% endif %}

        {% if stir == TRUE %}
            {% if printer["fan_generic fan0"].speed <= 0.0 %}
                SET_FAN_SPEED FAN=fan0 SPEED=1
                RESPOND PREFIX="M141:" MSG="stirring with nozzle cooling fan0 @ 100%"
            {% endif %}
            {% if  printer.extruder.target <= 45 %}
                M104 S46
                RESPOND PREFIX="M141:" MSG="stirring with hotend @ 46C to enable hotend_fan"
            {% endif %}
        {% endif %}

        CHAMBER_ON
        RESPOND PREFIX="M141:" MSG="target={s}C bed={bed_report}C"
    {% endif %}


[gcode_macro M191]
gcode:
    {% set s    = params.S    |default(-9999)|float %}
    {% set r    = params.R    |default(-9999)|float %}
    {% set tol  = params.TOL  |default(printer.save_variables.variables.ch_wtol |default(10))|float %}
    SAVE_VARIABLE VARIABLE=ch_wtol   VALUE={tol}

    {% if r != -9999 %}
        {% set target = r %}
    {% else %}
        {% set target = s %}
    {% endif %}

    M141 S{target}

    {% if target > 0 %}
        {% set t_min = target - tol %}
        {% if r != -9999 %}
            {% set t_max = target + tol %}
            RESPOND PREFIX="M191:" MSG="R: target={target}C tol +/-{tol}C"
            TEMPERATURE_WAIT SENSOR="temperature_sensor chamber_temp" MINIMUM={t_min} MAXIMUM={t_max}
        {% else %}
            RESPOND PREFIX="M191:" MSG="S: target={target}C tol -{tol}C"
            TEMPERATURE_WAIT SENSOR="temperature_sensor chamber_temp" MINIMUM={t_min}
        {% endif %}
        RESPOND PREFIX="M191:" MSG="Wait complete"
    {% endif %}```
1 Like

Thanks for the help, it is working again :slight_smile:

Regarding chamber temperatures, I’d also like to cite @Fabio 's experience that PETG clogs at 55°C, which is considerably less than @Liberty4Ever 's 61°C that I initially quoted. This further corroborates the chamber heater suggested 50°C for PETG.

1 Like

Bugfix: Incorrect RPM

The reported exhaust_fan RPM was bogus and reporting slower values when set above 35% because the poll interval was incorrect.

[fan_generic exhaust_fan]
pin: PB0
tachometer_pin: PB1
tachometer_ppr: 1
tachometer_poll_interval: 0.0019 #was 0.0046
cycle_time: 0.05 #was 0.01
max_power: 1.0
kick_start_time: 0.3

I checked the whole range for aliasing or other errors, and I got the expected smooth quadratic fan curve. This can now report up to ~15.8 krpm, so a bit of headroom over my 100% 14.2 krpm for hardware variation.

This means the stock/typical 80% fan speed is actually 12 krpm.

Lowest fan speed

Lengthening the cycle time ( reducing the PWM frequency) to from 0.01s to 0.05s (100Hz to 20Hz) achieves a slightly lower minimum fan speed. I’m now able to get 2 krpm (12%), whereas before the best I could achieve was 14%.

Even longer cycle times don’t help.

Kick

Surprisingly, adjusting kick_start_time doesn’t influence the minimum achieved speed. I can set it to 0, or remove the line altogether, and 12% still works. I suspect the firmware is adding a kick of its own though, since I see it report 3krpm for a moment before it settles in.

Of course, no kick isn’t robust, so here are various durations & achieved (& reported) speeds if you want to determine your own trade-offs. While there’s a clear audible difference between actual response time and reported peak RPM, that’s harder to quantify.

kick [s] peak revs [krpm] throttle [%] peak SPL [dB(A)] notes
0.0 3 13% 38
0.2 3 13% 38 imperceptible kick
0.3 3.5 14% 38 hardly perceptible kick
0.4 5 21% 39
1.1 8 40% 44 stock/waterfall min
2.3 11 71% 52
3.0 12 80% 54 stock/waterfall max
4.6 14 100% 58 actual max

I’ll use 0.3s for now, but I wonder if deliberately leaving it long may help with the PID’s initial overshoot…

SPL decibel

I placed a cheap decibel meter about 3ft away in front, door and lid closed, to record the steady state intensity for a given RPM. The Zero, without exhaust, idles at 37dB(A), which is the same as my background environment when the printer is off.

Nice and i am currently just replacing them with alix press fan , the smaller one for the mainboard is swapped , still working on custom holder yet its working more quite

Working on buck converted for the exhaust fan till i find a replacement ( accidentally ordered a 12v )

I have a voron fan for the back i am working on next designing it from other design and making it partially a mk4s super sized version

I’ll try an beat that noise sound

@Rooroo4u I’m not sure I follow all the changes you’re making. Photos or links would be helpful. I’m reluctant to make hardware changes since, while they may be quieter, the replacements may not provide adequate cooling.

Assuming they are powerful enough, my software changes above should work, although perhaps with different tuning.

Stirring the air

Now that I’m trying to reach >50°C I can see the frustration!

I’m happy to report that stirring the air (recirculation) helps a lot. Using the nozzle cooling fan (mainboard fan0), and to a lesser extent the heat break fan (toolhead fan1) speeds up the process & achieves a higher ceiling.

These fans effectively already kick in during a M141, but during the print itself. With M191’s wait, there is no fan activity the temperature is reached. Which may never even happen - without the fans, it took me nearly an hour to even reach 30°C

Once I work out some logic, I’ll add these (with variables/flags to disable) to the above macros. But for now:

SET_FAN_SPEED FAN=fan0 SPEED=1 # nozzle cooling fan 100%
M104 S46 # heat break fan turns on above 45C, doesn't cook filament

Larger recirculation

I still haven’t gotten around to a Nevermore / fan3 setup, but when I do, there’s really only two possibilities:

  • slower
    • since it is mixing all the chamber air, both above and below the bed, it ultimately just has more work to do
  • faster
    • The bed is only hitting its ~250W power limit for a few minutes
    • The rest of the time (30-60min), it is at the thermal setpoint, drawing maybe 20% / 50W
    • More mixing means the bed is cooler longer, ultimately putting more heat into the chamber itself

Regardless, it is convenient to use the already-existing extruder fans.

I’ve updated the M141 & M191 macros above with logic for stirring the air with the fans if they’re not already set.

1 Like

i’ve got a strange problem
I’ve tried this before with a set temperature of 35° with PETG just for testing and it worked perfectly

now after tinkerin a bit with my zero i started another print with a target of 45° and now the auxiliary fan immediatly startet running at 100% which is quite counterprodcutive if you try to heat the chamber.

First thought i messed something up even tho i didn’t change anything related in the configs until i noticed that it still works with 35° (or any target within the 10° Tolerance)
and i just can’t finde the reason, no mention of fan2 anywhere i looked
so far i think i it must be something in the TEMPERATURE_WAIT call … but so far that’s been a dead end for me

Since fan2 / aux fan / rear curtain fan is an inlet, I haven’t utilized it in my macros. In fact, there doesn’t seem to any real control loop on it at all, thermostat or otherwise. Its just set statically by the slicer, generally by the filament’s cooling section Auxiliary Fan Settings

that’s why it is driving me insane and spend half the day in an increasingly badmood trying to figure it out. I can control every fan manually and they’re assigned correct. It’s no hardware problem (which would be a strange symptom anyhow) but if i remove/rename the friends i get several error messages from the fan2 commands. And as you said, nowehere is it used in your macro and it only happens while waiting for the temperature to reach the min Temp. I set it to 40 and warmed the sensor with my hand and at the moment the chamber temp reached 30, fan2 stopped and everything continued normally

I mean i can easily circumvent it by just manually heat soak until i’m in the tolerance range and then let it take over as it works with 35° …. but my stupid brain can’t let things like this slip once i noticed a problem ><

OMG

in my pain and desperation i turned to chatGPT and after arguing and denying all the wrong solutions i now have a workaround
if i put SET_FAN_SPEED FAN=fan2 SPEED=0 right before the TEMPERATURE_WAIT it works
let’s ignore the fact that it is such an obvious approach that i could have tried it even without understanding why ><
It seems to be a problem with the klipper resend loop during the wait command which somehow sends the wrong last fan2 state ?

here’s the long and as always way to chatty version of it

The critical mechanism: fan state replay during G-code blocking

This is not documented, and it’s subtle.

What Klipper does internally

When a G-code command blocks (and TEMPERATURE_WAIT is one of the very few that does):

  1. Klipper continues to run the reactor loop

  2. It periodically replays last-known fan states to the MCU

  3. This is done to keep MCU state synchronized while host G-code execution is paused

Normally, this is harmless.

However…


Why this hits fan2 only

Klipper tracks fan state in two layers:

  • Logical state (host-side)

  • MCU pin state (low-level)

If a fan was ever written with a non-zero value in the current session — even briefly — that value becomes the “last-known” state.

During a blocking wait:

  • Klipper re-sends that value

  • Even if the logical state is currently zero

  • No G-code is emitted

  • No macro is involved

This is why:

  • You cannot find it in config

  • You cannot reproduce it outside TEMPERATURE_WAIT

  • Emergency stop behaves differently (host disconnect → MCU reset → pin cleared)


The smoking gun: fan2 is written once earlier in the script

Not necessarily visibly.

Typical hidden writes:

  • M106 without P (defaults to fan0 but still updates fan table)

  • SET_FAN_SPEED FAN=fan2 SPEED=0 (yes, zero still registers)

  • A macro that touches all fans

  • A conditional that evaluates differently during normal execution

Once Klipper has ever touched fan2, it is eligible for replay.


How to prove this beyond doubt

Add one line before your TEMPERATURE_WAIT:

SET_FAN_SPEED FAN=fan2 SPEED=0

Then restart Klipper and re-run your script.

If the fan stops turning on, this confirms fan-state replay.

Why?

  • You overwrite the “last-known” cached state with zero

  • The replay sends zero instead of the stale value

so now i only have to find a solution to the pin of fan3 defaulting to high on the mcu so fan3 goes to 100% until klipper is running (and yes shutdwonspeed is set to 0)

speaking of fan3, it doesn’t hurt but at least with the internal filtration i use atm it’s a bit undwhelming how little additional stiring with it adds to the heating. Now i’m thinking about installing ANOTHER fan, somethin like a thin 120mm on the floor to blow directly at the heatbed from below. I so want to utilize the 1000W it can deliver and so far it seldom peaks above short 25% Heating spikes while heating the chamber.

You can set pin states at boot at the firmware level. Requires you to recompile the firmware and flash the MCU.

@tektalon At least you got a workable answer from ChatGPT!!! … My wife always asks who I’m arguing with :melting_face:
Last 5.2 version starts conversations with it’s correct, you are wrong… it really has become a bear to convince it’s “definitive/prove without doubt” solutions are wrong. I know it’s only a reference tool, but it’s so over confident!
Out of frustration I’ve given ChatGPT a set of rules to follow which helped tone down the debugging wall of spam and this one definitive answer will tell me blah blah blah, and has gone back to a 4.2 style but with the better data sets… For the most part it holds true to those rules with periodic reminders :frowning: Thanks for posting what you found.

Cheers,
-Mike

sigh yeah i know, my question was more like why it is set that way. But still thanks :slight_smile:
but at least at the moment i already lost enough nerves. But i try to remember it if i should ever feel like reflashing or have another reason for it

sneaky! Thanks for chasing that down, I’ll add that fan2 line prior to my wait in my next update.

Regarding fan3, I still haven’t done an internal filtration (recirculation), although I’ve certainly thought about it a bit. The bed is quite powerful, and even when hamstrung by 110v to 250W, it’s only used to a small fraction of its potential. I had a good discussion with PetschBauer about the Prusa Core One’s approach by, instead of having insulation under the bed (speeding up just the bed) you could remove the insulation and direct fans underneath (speeding up the chamber).

Some aspects of the Zero, like the curtain fan being an intake, and the insulation underneath, are really optimized for staying cool and making PLA speedboats. But that’s really at odds with the high temp users, where the Zero is also promising, but needs a hot steady chamber that preferably doesn’t take an hour to preheat in a cold garage.