ESP32 CO₂-Controlled Ventilation with ESPHome and Home Assistant: SCD41, Hysteresis and Fail-Safes

Build ESP32 CO2-controlled ventilation with ESPHome, SCD40/SCD41 and Home Assistant. Includes wiring, high/low thresholds, fan hysteresis, offline safety and restart recovery.

A reliable CO₂-controlled ventilation system does more than switch a fan on at 1,000 ppm. It needs an actual CO₂ sensor, a sensible control interval, separate start and stop thresholds, a defined response when measurements disappear, and a fan or ventilation unit that is safe to automate. ESP32 and ESPHome can measure the room; Home Assistant can decide when to boost ventilation; the ventilation equipment must retain its own electrical and safety protections.

This is a dedicated control and commissioning guide, not another general-purpose indoor-air-quality station. If you need the complete multi-sensor monitor first, see our ESP32 indoor air-quality station. Here we focus on making a ventilation decision from a trustworthy CO₂ measurement without fan chatter, stale data or confusing CO₂ with particles and VOCs.

What CO₂-Controlled Ventilation Can and Cannot Do

Indoor CO₂ often rises as people occupy an enclosed room, making it a useful signal for ventilation demand. However, a low CO₂ reading does not prove that the room is free of smoke, cleaning chemicals, combustion products or fine particles. ASHRAE cautions against treating one universal CO₂ figure as a complete indoor-air-quality verdict; occupancy, outdoor CO₂, activity and ventilation design matter.

SignalWhat it tells youLikely response
True CO₂ (ppm)Build-up of occupant-generated CO₂ relative to outdoor airIncrease outdoor-air ventilation where appropriate
PM2.5 (µg/m³)Airborne fine particles from cooking, smoke or outdoor ingressRemove source; filter/extract as appropriate
VOC Index (unitless)Change in gas-sensor response against a learned baselineInvestigate source; ventilate if outdoor air is suitable
Humidity (%)Moisture and condensation riskMoisture extraction/dehumidification

A recirculating HEPA purifier can lower particulate readings without lowering CO₂. Conversely, opening a window can lower CO₂ while allowing high outdoor particulate pollution inside. Keep these control goals separate.

Choose a Real CO₂ Sensor

For this build, use an SCD40 or SCD41: both are actual photoacoustic CO₂ sensors with temperature and humidity readings over I²C at address 0x62. They are not VOC-derived “eCO₂” estimates. SCD41 has the wider specified CO₂ range and additional single-shot mode; ordinary mains-powered room control can use either.

An existing MH-Z19-family NDIR module can also feed Home Assistant, but it uses UART rather than the I²C wiring below. For a new compact ESPHome board, an SCD4x breakout simplifies integration. Our SCD30 vs SCD40 vs SCD41 comparison covers those sensor differences in more depth.

Parts and Electrical Architecture

  • ESP32 development board with stable USB power and dependable 2.4 GHz network access.
  • SCD40 or SCD41 breakout with a clearly documented supply-voltage and I²C logic-voltage specification.
  • Home Assistant with the ESPHome integration and a controllable ventilation fan. entity or compatible smart ventilation controller.
  • A ventilated enclosure that separates the sensor from ESP32 and regulator heat.
  • Optional independent minimum-ventilation setting in the ventilation unit, maintained even if Home Assistant or Wi-Fi fails.

For an existing mains-powered extractor or HVAC unit, use its approved control interface or a correctly rated and professionally installed controller. Never wire a mains fan directly to an ESP32 pin or defeat interlocks, run-on timers, fire controls or manufacturer safeguards. The sample Home Assistant automation below assumes a working fan. entity already exists; it is not a mains-wiring tutorial.

ESP32 to SCD41 Wiring

SCD4x breakoutClassic ESP32 DevKitImportant detail
VCC/VIN3.3 V if the breakout supports itCheck exact module supply rating; bare-sensor and breakout limits differ
GNDGNDShared electrical reference
SDAGPIO213.3 V I²C logic; check pull-up rail
SCLGPIO223.3 V I²C logic; check pull-up rail

Many commercial breakouts accept either 3.3 or 5 V input because they contain support circuitry. That does not make the ESP32 GPIO 5 V tolerant. If the board has 5 V pull-ups or 5 V signal levels, use a suitable level shifter or a different breakout. Keep I²C leads short and connect SDA to SDA and SCL to SCL.

ESPHome Sensor Configuration

The following is a complete starting point for an ordinary ESP32 DevKit. Replace the secret names with entries in your ESPHome secrets.yaml. Each device must have its own Native API encryption key.

esphome:
  name: bedroom-co2-ventilation
  friendly_name: Bedroom CO2 Ventilation

esp32:
  board: esp32dev
  framework:
    type: esp-idf

logger:

api:
  encryption:
    key: !secret bedroom_api_key

ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

i2c:
  sda: GPIO21
  scl: GPIO22
  scan: true

sensor:
  - platform: scd4x
    id: bedroom_scd4x
    address: 0x62
    measurement_mode: periodic
    automatic_self_calibration: true
    co2:
      name: "Bedroom CO2"
      id: bedroom_co2
    temperature:
      name: "Bedroom CO2 Sensor Temperature"
    humidity:
      name: "Bedroom CO2 Sensor Humidity"
    update_interval: 30s

  - platform: wifi_signal
    name: "Bedroom Sensor WiFi Signal"
    update_interval: 60s
    entity_category: diagnostic

  - platform: uptime
    name: "Bedroom Sensor Uptime"
    update_interval: 60s
    entity_category: diagnostic

ESPHome currently documents periodic SCD4x measurement at approximately five-second sampling, while update_interval controls how often ESPHome checks and publishes values. Publishing every 30 seconds does not turn periodic measurement into single-shot mode. For a mains-powered room sensor, regular periodic operation is straightforward. SCD41-only single-shot mode is better suited to carefully designed low-power projects, not necessarily to always-on fan control.

Verify the I²C scan finds 0x62, confirm that CO₂ updates are plausible and only then build the automation. A 0 ppm or unavailable reading should never be interpreted as excellent indoor air.

Place the CO₂ Sensor in Representative Room Air

The sensor should measure the room, not the inside of the ESP32 enclosure or a concentrated breath plume. Place it where room air circulates, away from the ventilation outlet, open windows, direct sunlight, a heater, damp surfaces and the heat from the ESP32 regulator. Avoid mounting it directly in an occupant’s breathing zone or immediately above a desk where exhaled air can dominate the result.

The SCD4x temperature value can be influenced by its own heat and by nearby electronics. ESPHome exposes a temperature_offset option, but leave it unchanged until the complete installation has been compared with a trustworthy room reference. A correct CO₂ reading does not guarantee the module’s integrated temperature is the best thermostat input.

Understand SCD4x Self-Calibration Before Enabling Control

ESPHome defaults automatic_self_calibration to true. Automatic self-calibration is useful only when the sensor’s environment regularly reaches a representative low-CO₂ reference consistent with the manufacturer’s assumptions. An occupied room that stays elevated for extended periods may not provide a suitable baseline.

For a bedroom that is often ventilated and unoccupied, automatic calibration may be reasonable. For a continuously occupied, sealed or specialty room, consult the Sensirion guidance and consider an appropriate calibration plan rather than blindly forcing a fresh-air calibration at an assumed concentration. A one-time outdoor reading is not necessarily a valid calibration reference unless the outdoor value and stabilization conditions are known.

Choose Separate Fan ON and OFF Thresholds

Do not control the fan with a single threshold such as “ON above 1,000 ppm; OFF below 1,000 ppm.” Sensor variation and airflow changes would make the output switch repeatedly around that value. Use hysteresis and a sustained-duration check instead.

StateExample control ruleWhy
Boost ventilationCO₂ above 1,100 ppm for 5 minutesRespond to sustained occupancy rather than one transient reading
Return to baselineCO₂ below 800 ppm for 15 minutesPrevent fan hunting and allow time for the room to clear
Between 800 and 1,100 ppmRetain existing boost stateHysteresis band
Sensor unavailableAlert and apply defined equipment policyDo not treat missing data as low CO₂

These are illustrative automation settings, not statutory exposure limits or a universal air-quality grading system. Choose them based on the room, the outdoor-air supply, occupancy, fan capacity and acceptable noise/energy trade-offs. In a crowded space, a smaller room or a building with a designed minimum-airflow requirement, control should be commissioned against that requirement rather than copying hobby thresholds.

Home Assistant Automation: Boost When CO₂ Stays High

First, use Home Assistant to confirm the actual entity IDs created by the ESPHome integration. Replace sensor.bedroom_co2 and fan.bedroom_ventilation with your own entities. The latter must be an existing properly integrated ventilation system.

alias: Bedroom ventilation - boost on sustained CO2
triggers:
  - trigger: numeric_state
    entity_id: sensor.bedroom_co2
    above: 1100
    for: "00:05:00"
conditions: []
actions:
  - action: fan.turn_on
    target:
      entity_id: fan.bedroom_ventilation
mode: single

Current Home Assistant numeric-state triggers fire when the sensor crosses the threshold and then stays beyond it for the requested time. They do not repeatedly re-trigger while the reading remains high. That distinction matters after a Home Assistant restart or automation reload; a separate startup reconciliation or scheduled policy check is needed if the fan must be brought immediately into the correct state at boot.

Home Assistant Automation: Restore Baseline When CO₂ Falls

alias: Bedroom ventilation - return to baseline
triggers:
  - trigger: numeric_state
    entity_id: sensor.bedroom_co2
    below: 800
    for: "00:15:00"
conditions: []
actions:
  - action: fan.turn_off
    target:
      entity_id: fan.bedroom_ventilation
mode: single

Use fan.turn_off only when the fan entity represents an optional boost fan that may legitimately stop. For a whole-house ventilation system with a required base airflow, replace it with the manufacturer-approved normal-speed or base-mode action. Never stop a required ventilation unit merely because a room CO₂ sensor reports a low value.

What Happens When the CO₂ Sensor Goes Unavailable?

A missing measurement is not a measurement of zero. The ESP32 may lose Wi-Fi, Home Assistant may restart, the I²C cable may fail, or the SCD4x can stop reporting. The important design decision is what the ventilation equipment does without a valid control signal.

  • For ordinary room comfort automation, issue a clear sensor-offline alert and revert to a defined normal ventilation schedule.
  • For continuous minimum-airflow requirements, maintain the manufacturer-approved baseline independently of the ESP32 and Home Assistant.
  • For equipment with safety or regulatory functions, rely on dedicated control and protection systems rather than a hobby wireless sensor.
  • Avoid a rule that turns the only ventilation off whenever Home Assistant sees unavailable.

A simple availability notification can use a separate Home Assistant automation. Adapt the notification service to your own phone or alert system:

alias: Bedroom CO2 sensor offline alert
triggers:
  - trigger: state
    entity_id: sensor.bedroom_co2
    to: "unavailable"
    for: "00:05:00"
  - trigger: state
    entity_id: sensor.bedroom_co2
    to: "unknown"
    for: "00:05:00"
conditions: []
actions:
  - action: persistent_notification.create
    data:
      title: "CO2 ventilation sensor unavailable"
      message: "Check the bedroom ESP32, I2C wiring and network."
mode: single

A network-availability alert alone cannot catch a sensor stuck permanently at a plausible number. Add an independent routine check for implausibly flat data or a second sensor where the consequence of wrong control is significant.

How to Handle Restarts and Threshold-Crossing Gaps

A surprisingly common fault is that Home Assistant restarts with the CO₂ already at 1,400 ppm. A numeric-state automation may never see the crossing from below to above 1,100 ppm, so it can remain idle even though the room is above the requested boost threshold.

For higher reliability, add a periodic reconciliation automation with a numeric_state condition, not just a numeric-state trigger. This checks the present value even if the threshold crossing was missed. The example below immediately asserts boost when the current measurement is high; if you require five minutes of persistence across restarts, implement a suitable timestamp/history helper rather than treating the periodic condition as equivalent to the five-minute crossing trigger.

alias: Bedroom ventilation - reconcile high CO2
triggers:
  - trigger: time_pattern
    minutes: "/5"
conditions:
  - condition: numeric_state
    entity_id: sensor.bedroom_co2
    above: 1100
actions:
  - action: fan.turn_on
    target:
      entity_id: fan.bedroom_ventilation
mode: single

Do not add a similarly immediate periodic OFF rule unless you intentionally want to bypass the fifteen-minute recovery delay. Keep manual override, occupied/unoccupied modes and any thermostat interlocks visible in your final control policy.

Optional ESPHome Local Fan Control: Where It Makes Sense

Some projects use a low-voltage DC fan or a purpose-built isolated control input on a ventilation controller. In that case, the ESP32 can execute local logic even when Home Assistant is offline. That is attractive for reliability, but it also makes the ESP32 firmware responsible for timer and fault behaviour. Use it only when you can validate those operating modes.

The following snippet is a logic-level output example for a correctly designed external fan-control interface. GPIO23 must not be wired straight to a motor, relay coil or mains conductor. Choose the pin and inverted setting to match your exact controller; a few relay boards are active-low.

switch:
  - platform: gpio
    pin: GPIO23
    id: ventilation_boost_request
    name: "Ventilation Boost Request"
    restore_mode: ALWAYS_OFF

A fail-off boost output may be reasonable if independent baseline ventilation remains active, but it is not automatically suitable for a fan whose running state is necessary for safe operation. Decide the output’s power-up state according to the equipment’s actual control requirements.

Check Whether Outdoor Air Is Actually Better

Opening a window or increasing outdoor-air intake usually reduces occupied-room CO₂ if outdoor CO₂ is lower. It does not necessarily improve all contaminants at once. During severe outdoor smoke, dust or traffic events, bringing in outdoor air can raise indoor PM2.5. Likewise, a recirculating air purifier cannot remove CO₂ unless it contains a specifically engineered CO₂-removal process, which ordinary domestic HEPA purifiers do not.

If you have an outdoor PM2.5 sensor or trusted local outdoor-air data, show that alongside indoor CO₂ on the dashboard. A good user interface should say “ventilation requested for CO₂”, not “air is safe”. Avoid combining ppm, µg/m³ and VOC Index into one unexplained percentage.

Sensor Placement and Fan Feedback

The best control point is a representative location in the occupied zone, not next to the fresh-air supply. If you put the sensor directly beside the vent, the fan can immediately feed it clean incoming air and make it report 650 ppm while the rest of the room remains poorly mixed. Place the sensor where circulation produces a representative reading and verify the result during a normal occupancy cycle.

Plot CO₂ and fan state on the same Home Assistant graph. A useful commissioning test is to observe the rise while the room is occupied, the start of boost, and the subsequent decay. Check that the fan actually changes airflow: a smart-plug state or controller command alone does not prove the impeller is moving or that a duct is unobstructed.

How to Evaluate the Response Time

Expect a lag between commanding ventilation and seeing CO₂ fall. It depends on room volume, occupancy, fresh-air flow, sensor position and mixing. Too-aggressive control reacts to every breath or short visit; too-slow control allows sustained high concentrations. Start with the illustrative thresholds and dwell times, observe a few normal days, then adjust to achieve a stable fan cycle without suppressing required minimum ventilation.

In a room that stays above your boost threshold despite the fan running, investigate outdoor-air supply, blocked filters, closed dampers, insufficient fan capacity and measurement placement before lowering the threshold. A persistent high reading is often a real capacity or occupancy issue, not a reason to “calibrate” the sensor down.

Do Not Use WHO PM2.5 Limits as Instant CO₂ Thresholds

CO₂ and PM2.5 are different physical measurements and should not share a generic “air-quality” threshold. The WHO 2021 guideline for PM2.5 includes an annual mean of 5 µg/m³ and a 24-hour guideline of 15 µg/m³. Those averaging periods cannot be directly used as a one-minute purifier-on trigger, and they say nothing about choosing a CO₂ ventilation setpoint.

If adding a PMS5003 or SEN55 to the same room, use it to control particle filtration separately. See our published PMS5003 with ESPHome guide. For VOC trends, our SGP40 ESPHome guide explains why VOC Index is neither CO₂ nor a direct concentration reading.

Troubleshooting CO₂ Ventilation Automations

SymptomLikely reasonFirst check
Fan never starts though CO₂ is highThreshold was already exceeded at HA startAdd periodic reconciliation or state-based recovery
Fan rapidly starts and stopsNo hysteresis, sensor close to inletSeparate ON/OFF levels; relocate sensor
Fan runs but room CO₂ stays highInsufficient fresh-air flow or poor room mixingMeasure airflow; check filters, ducts and sensor placement
CO₂ values drift over daysCalibration assumptions not metReview automatic calibration and true fresh-air exposure
Sensor unavailable during fan changesPower/noise or Wi-Fi problemCheck ESP32 supply, cable and uptime
Temperature looks highESP32/enclosure self-heatingSeparate SCD4x from regulator and compare with reference
Fan turns off while room occupiedOnly a boost entity was expected but base fan was controlledRestore designed minimum ventilation independently
High particulate levels despite low CO₂Ventilation and filtration measure different contaminantsAdd dedicated PM2.5 monitoring; investigate outside sources

A Practical Commissioning Checklist

  • Confirm the device I²C scan sees the SCD4x at 0x62 and that readings update reliably.
  • Compare sensor placement against an occupied-room reference location; separate it from supply-air jets and heat sources.
  • Check that the ventilation device has an approved control interface and an independent safe baseline.
  • Confirm the correct Home Assistant entity IDs and test the ON and OFF actions manually.
  • Test both threshold crossings with sustained durations, then test a Home Assistant restart while CO₂ is already high.
  • Unplug the sensor or simulate unavailable and verify your offline alert and safe equipment response.
  • Plot CO₂ and fan state together for several occupancy cycles and tune the hysteresis/dwell times.
  • Keep PM2.5 and VOC responses separate from CO₂ ventilation logic.

The Bottom Line

Use true CO₂ as a ventilation-demand signal, not as a universal indoor-air-quality score. An ESP32 and SCD40/SCD41 can produce a useful local reading through ESPHome, while Home Assistant handles sustained thresholds, hysteresis, manual override and dashboards. The engineering work is in sensor placement, valid calibration assumptions, actual ventilation capacity and defined behaviour when Wi-Fi or the sensor disappears.

For a broader sensor build, start with our multi-sensor indoor-air-quality station. This dedicated control guide then supplies the practical next step: decide when to request fresh air and verify that the room actually responds.

Official References and Further Reading

Share your love