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
- Remove the entire [temperature_fan exhaust_fan section]
- 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:
- run CH_STATUS_LOOP in the console
- Use Kp to adjust your gain until you reach an oscillation, then halve it (rule of thumb)
- This will likely undershoot & that’s OK, let Ki fix the remainder
- Slowly increase Ki to reduce error
- 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

