Stable climate control with finer control steps. This ESPHome-based Maxxfan controller on ESP32-S3 translates speed and direction into IR commands, enabling full intake/exhaust control in 10% increments. It runs day to day in my own camper; I wrote a practical guide to ventilating a camper (in Dutch).
Lab & hardware
Maxxfan Deluxe IR automation
A roof fan that follows CO₂ without waking you up
± 7 min read
The problem
My campervan Bassie is a small volume in which I spend the night breathing. Without fresh air the CO₂ does not creep up, it shoots up: with one person it passes 1000 ppm within minutes, with two it easily goes past 2000. Outside air is around 430 ppm.
The van has a Maxxfan Deluxe in the roof: ten speeds, intake or exhaust, and a lid that opens and closes. Running it flat out all night solves the CO₂, but it is loud and in winter it throws away the heat the diesel heater just produced. So the goal is to ventilate just enough, and to do it quietly. A fan that keeps starting and stopping wakes you up far more than a steady, soft hum.
Architecture
Everything runs on the same ESP32-S3 that controls the diesel heater.
- Sensor. A Sensirion SCD41 measures CO₂, temperature and humidity in the living space.
- IR driver (open source). It sends the same infrared codes as the remote and exposes the Maxxfan as an ordinary ESPHome fan: speed 1 to 10, direction (intake or exhaust) and the lid. Speed 1 only opens the lid; 2 to 9 run the fan at 10 to 80 %, and 10 at full power. A minimum and maximum speed clamp everything that comes in.
- CO₂ brains. Every 5 seconds a small control loop turns the CO₂ reading into a fan speed. It only acts while the fan is switched on, so turning it off by hand always wins, and it waits 60 seconds after boot so the sensor can settle.
Home Assistant sets the two CO₂ thresholds and the speed limits, and reads the data. If it goes down, the chip keeps ventilating on its own.
v1: proportional
The first version was plain proportional control between two thresholds: a healthy level H where the fan starts, and an unhealthy level U where it runs at full power. By default they are 800 and 1200 ppm; both can be changed from Home Assistant.
P = 100 · clamp((CO₂ − H) / (U − H), 0, 1)
float factor = (co2 - healthy) / (unhealthy - healthy);
factor = std::max(0.0f, std::min(factor, 1.0f));
return factor * 100.0f; // desired power, 0-100 % On paper that is all you need. In the van it was restless near the lower threshold. When the CO₂ dipped just below H, the fan stopped completely. The air in the van then went stale again within minutes and the fan started up, only to stop again once the reading dropped. Every start and stop is audible, and all that cycling costs the motor more than running gently.
v2: a slow floor
v2 keeps the proportional part, but adds a floor. The chip keeps a moving average of the desired power over the last 30 minutes (360 samples at 5 seconds), and the fan gets whichever is higher:
Pfan = max(P, P̄30 min)
filters:
- sliding_window_moving_average:
window_size: 360 # one sample every 5 s: 30 minutes
send_every: 1
...
float power = std::max(desired_power, avg_power); The fan may go up as fast as the CO₂ demands, but after a peak it does not drop back to zero. It follows the slowly falling average down instead. A short peak, like cooking or a second person coming in, turns into a long, quiet run-on rather than a burst followed by silence.
From power to a speed
The Maxxfan only knows ten speeds, so the power is rounded to the nearest one and clamped to the minimum and maximum set from Home Assistant:
int min_speed = std::max(1, int(id(fan_min_speed).state));
int max_speed = std::max(min_speed, int(id(fan_max_speed).state));
int speed = int(round(power / 10.0f));
return std::max(min_speed, std::min(max_speed, speed)); Because speed 1 is the lid without the fan, the fan itself runs one step lower than the power suggests: 50 % power is speed 5, which is 40 % fan. Below the healthy threshold the result is speed 1: lid open, fan off. The van then still breathes through the open lid and a cracked window, without any noise.
At night Home Assistant narrows the range to speed 2 to 6. The fan never stops while I sleep, so there is no start-up to wake me, and it never runs above 50 %. During the day the full range applies again.
Intake, not exhaust
The usual advice is to exhaust: stale air is warmer and collects at the top. In my van the window and the roof fan are not on either side of the bed, so exhausting creates a shortcut from window to fan and the bed is left out. Blowing in with the window only cracked mixes the whole van instead, and the bed gets fresh air too. The slight overpressure also keeps mosquitoes out through the gap.
The overpressure has a second job. The diesel heater hangs under the van, and with the wind from the wrong side its exhaust drifts back under the floor. A Home Assistant automation therefore sets the fan to intake with the lid open and a minimum of speed 2 for as long as the heater is starting, burning or shutting down. Speed 1 would only open the lid, so 2 is the floor. When the heater stops, the minimum falls back to what it was. The rule is skipped while driving, when the alarm is armed in bypass mode, and on frost guard (a heater target of 5 °C or lower): then nobody is home and the lid stays closed to keep the heat in. The exception is a CO₂ level 200 ppm above the healthy threshold: then someone is home after all.
The heater side of this is in the Vevor project.
What the sensor does not see
A controller can only be as good as the air it measures. The sensor sits in the living space, not at my pillow, and warm exhaled air does not mix easily with the cooler air coming in. A low reading at the sensor does not guarantee fresh air at the bed. That is why the direction and the floor matter as much as the formula: both keep the air moving through the whole van, not just past the sensor.
Code
The IR driver is open source: esphome-maxxfan-ir. The CO₂ control and the Home Assistant automations live in my own configuration; the formulas and fragments above show how they work.
Looking for the practical side? On CZYK. Off-grid I wrote, in Dutch, a guide to ventilating a camper and the build log of fitting the Maxxfan.
Curious how to implement this yourself? Feel free to send me a message or reach out on LinkedIn.