Most cheap “CO2” monitors never measure carbon dioxide. They carry a metal-oxide gas sensor that reacts to VOCs and hydrogen, then print an estimate called eCO2 that follows cooking smells better than it follows a stale bedroom. So the best air quality monitor for Home Assistant has to carry a real CO2 sensor, ideally NDIR or photoacoustic, and it has to deliver its readings over a local path.
We ranked six monitors by the CO2 sensor inside and by how each one reaches Home Assistant: ESPHome and the AirGradient local API over Wi-Fi, Matter over Thread, and Bluetooth. We did not have the hardware on a bench, so sensor claims come from maker datasheets, the integration manifests and live US listings. The integration behaviour described here was checked against Home Assistant 2026.10.4 in October 2026 on an Ubuntu 26.04 lab VM. An ESPHome node fed it CO2 values so we could watch a ventilation automation fire.
Which air quality monitor fits Home Assistant best
Read the CO2 column before the price, because it matters most. Five of the six measure CO2 optically, the method you want behind a fan automation; the cheap IKEA sensor has a real CO2 sensor whose principle nobody has published. The other columns tell you what else it watches and which radio Home Assistant needs to hear it. Prices are US bands from late October 2026 and they move, so check the live price before you order.
| Job | Monitor | CO2 sensor | Also measures | Path into Home Assistant | Price band |
|---|---|---|---|---|---|
| Best overall | AirGradient ONE | SenseAir S8-series NDIR | PM2.5, VOC and NOx index, temp, RH | Wi-Fi, local API (polling) | about $230 direct, about $295 on Amazon |
| Best for ESPHome | Apollo AIR-1 | Sensirion SCD40 (optional) | PM1 to PM10, VOC and NOx index, pressure | Wi-Fi, ESPHome (push) | about $110 to $200 direct |
| Cheapest CO2 on Matter | IKEA ALPSTUGA | Sensirion SEN63C (teardown), principle not stated | PM2.5, temp, RH | Matter over Thread (push) | about $30 at IKEA |
| Portable CO2 reference | Aranet4 HOME | NDIR, ±(30 ppm + 3%) | temp, RH, pressure | Bluetooth (push) | about $190 |
| Budget NDIR | SwitchBot Meter Pro CO2 | NDIR, ±(50 ppm + 5%) | temp, RH | Bluetooth (push) | about $60 |
| Radon plus CO2 | Airthings Wave Plus | NDIR, ±(30 ppm + 3%) | radon, VOC, temp, RH, pressure | Bluetooth (polling) | about $250 |
Why eCO2 is not a CO2 reading
A metal-oxide (MOX) sensor such as the Sensirion SGP30 measures how much reducing gas lands on a heated film. The firmware then converts that signal into a CO2 equivalent, on the assumption that people are the main source of both. When you are alone in a closed room that assumption roughly holds, but spray a cleaner or fry some onions and the eCO2 figure climbs while the real CO2 hasn’t moved. The reverse fails too: people asleep in a closed bedroom raise CO2 steadily all night while giving off little VOC, so the MOX sensor has little to react to.
An optical sensor counts the molecules directly, and it works in order:
- Light source: an infrared emitter shines through a small chamber at a band of infrared light that CO2 absorbs strongly.
- Absorption: the more CO2 in the chamber, the less light reaches the detector, so the drop maps to a concentration in ppm.
- Photoacoustic variant: the Sensirion SCD4x pulses the light and listens for the pressure wave the absorbing gas makes, which lets the module shrink to a few millimetres. Sensirion’s SCD4x datasheet calls it photoacoustic NDIR.
- Baseline correction: the firmware assumes the lowest reading it sees over a week or so is outdoor air and pins it to about 400 ppm. That step is where most bad readings come from, which the last section explains.
Five of the six picks use one of these optical methods, and the ALPSTUGA section explains the exception. A product page that lists “eCO2”, “CO2 equivalent” or a CCS811, ENS160 or SGP30 sensor without an NDIR part is a VOC monitor with a CO2-shaped number. It does not belong behind a ventilation automation.
How a reading reaches Home Assistant
Each monitor reaches Home Assistant through one of three radios, and the radio decides what else you need in the house. Wi-Fi monitors talk straight to the server. Matter over Thread monitors need a Thread border router to bridge them onto the LAN. Bluetooth monitors need an adapter on the Home Assistant host or an ESPHome Bluetooth proxy within a room or two of the sensor, because BLE range through walls is short.

Home Assistant labels each integration with an IoT class that says whether data stays local and whether the device pushes or gets polled. We read the labels straight from the integration manifests inside the 2026.10.4 container in our lab:
airgradient iot_class= local_polling | requirements= ['airgradient==0.10.0']
esphome iot_class= local_push | requirements= ['aioesphomeapi==46.6.0', 'esphome-dashboard-api==1.4.0', 'bleak-esphome==4.0.0']
zha iot_class= local_polling | requirements= ['zha==2.3.1', 'zha-quirks==2.3.0']
matter iot_class= local_push | requirements= ['matter-python-client==1.4.0', 'matter-ble-proxy==0.7.1']
mqtt iot_class= local_push | requirements= ['paho-mqtt==2.1.0']
aranet iot_class= local_push | requirements= ['aranet4==2.6.0']
airthings_ble iot_class= local_polling | requirements= ['airthings-ble==1.3.1']
airthings iot_class= cloud_polling | requirements= ['airthings-cloud==0.2.0']
switchbot iot_class= local_push | requirements= ['PySwitchbot==2.9.0']
awair iot_class= local_polling | requirements= ['python-awair==0.2.5']
qingping iot_class= local_push | requirements= ['qingping-ble==1.1.6']
sensirion_ble iot_class= local_push | requirements= ['sensirion-ble==0.1.1']
Two lines decide a purchase here. airthings_ble is local, but airthings is the cloud integration, which is why the Wi-Fi Airthings View Plus is left out of this list and the Bluetooth Wave Plus is in it. The airgradient integration polls the local HTTP API of the monitor. ESPHome, Matter and the Bluetooth integrations get a new value pushed whenever the device sends one. Polling adds a short lag, which does not matter for CO2 because it changes over minutes.
How we picked and what we tested
A monitor made this list only if it measures real CO2 with a dedicated CO2 sensor, optical on five of the six, and Home Assistant can read it without a vendor cloud in the path. That ruled out eCO2 and VOC-only devices and the Wi-Fi models that only speak to their maker’s servers. For each pick we checked the sensor part and its accuracy against the spec page or datasheet of the maker. We checked the integration and its IoT class against the Home Assistant docs and the manifest in our container, and price, seller and stock against the live US listing.
Our hands-on part ran on Home Assistant 2026.10.4 in Docker 29.1.3 on Ubuntu 26.04. An ESPHome 2026.10.1 node built for the host platform stood in for a CO2 monitor. It pushed CO2, PM2.5 and VOC values over the same native API an Apollo AIR-1 uses, so we could time the automation that reacts to them. We also validated the AIR-1 firmware YAML that Apollo publishes with esphome config to read how it drives its sensors. None of this tests the accuracy of a real sensor, and the article says so wherever it matters.
Why the AirGradient ONE is the best overall
The AirGradient ONE is the monitor we would put in a living room first, because it measures everything that matters on one board and gives Home Assistant a documented local API. Inside are a SenseAir S8-series NDIR CO2 sensor (AirGradient lists the S8 or the S88), a Plantower PMS5003 for particles and a Sensirion SGP41 for the VOC and NOx index. A Sensirion SHT40 handles temperature and humidity, all on an ESP32-C3 with an OLED and an 11-LED bar on the front.

Home Assistant finds it on the network and the AirGradient integration polls its local API, so firmware 3.1.1 or newer is the only prerequisite. You can switch off the AirGradient cloud connection in the local settings and the readings keep flowing. The firmware is open source on GitHub if you want to see exactly what it sends. AirGradient states the S8’s automatic baseline correction runs every seven days and can be changed, which matters for a bedroom.
- Who it’s for: anyone who wants CO2, PM2.5 and VOC from one box with a screen.
- Skip it if: you need battery power. It needs a USB-C supply (not included) and 2.4 GHz Wi-Fi.
- Price note: about $230 assembled or $140 as a kit from AirGradient, about $295 from a third-party seller on Amazon.
What the Apollo AIR-1 does for ESPHome users
The Apollo AIR-1 is the pick if your house already runs on ESPHome, because it ships with ESPHome firmware and arrives in Home Assistant as a native-API device that pushes values. The base board carries a Sensirion SEN55 for PM1 to PM10, VOC index, NOx index, temperature and humidity, plus a DPS310 pressure sensor. CO2 is an option: order the variant with the Sensirion SCD40, or you’ll get an excellent particle monitor that can’t tell you when to open a window.

We validated Apollo’s published firmware from the AIR-1 repository with ESPHome 2026.10.1 and read how it configures the CO2 sensor. The SCD40 reads every 60 seconds with self-calibration on and takes pressure compensation from the DPS310, which is better practice than a fixed altitude:
- platform: scd4x
id: scd40
co2:
name: "CO2"
id: "co2"
on_value:
- if:
condition:
lambda: 'return id(air_quality_led_source).current_option() == "CO2";'
then:
- script.execute: update_air_quality_led
automatic_self_calibration: true
update_interval: 60s
measurement_mode: "periodic"
i2c_id: bus_a
ambient_pressure_compensation_source: dps310pressure
In the same file, a calibrate_co2_value action runs a forced calibration of the SCD40 with a ppm value you pass in. Forced calibration is a one-time correction, though, and self-calibration keeps running afterwards. For a room that never airs out, call the action outdoors and also turn off the firmware’s “CO2 Auto Calibration” config switch, which writes the setting to the SCD40. Apollo also ships a temperature offset of a few degrees because the ESP32-C3 warms the case, and you will need to tune that against a reference thermometer.
- Who it’s for: ESPHome users who want a small sensor with full particle data.
- Skip it if: you want a screen. It has no display and sells only from the Apollo store.
- Price note: about $130 with CO2, about $200 with the extra gas sensor.
Why the IKEA ALPSTUGA is the cheapest Matter CO2 sensor
The IKEA ALPSTUGA brings CO2 and PM2.5 to Matter over Thread for about $30, which is a fraction of anything else on this list. IKEA’s product page lists CO2, PM2.5, temperature and humidity on a dot-matrix display that doubles as a clock, and Sensirion’s press release confirms a Sensirion sensor inside. A published teardown identifies it as a SEN63C, though IKEA itself doesn’t name the part.

In Home Assistant, the Matter integration maps the Matter CO2, PM2.5 and PM10 concentration clusters to sensor entities, which we confirmed in matter/sensor.py in the 2026.10.4 container. The ALPSTUGA therefore lands as plain CO2 and PM2.5 entities with no custom code. The catch is the radio: it needs a Thread border router, and if you don’t have one, the cheap sensor brings a second purchase with it.
It is also the one pick whose CO2 method is not confirmed as optical. Neither IKEA nor Sensirion states how the SEN63C senses CO2; Sensirion’s SEN63C page lists ±100 ppm with no principle. The ±(100 ppm + 10%) figure the teardown quotes is the same accuracy Sensirion publishes for its STCC4, a thermal-conductivity CO2 sensor rather than an NDIR one. That makes it a real CO2 reading, not eCO2, but a looser one than the Aranet’s, and buyers also report the temperature reads high. Treat it as a cheap per-room trend sensor and keep an NDIR monitor as your reference.
- Who it’s for: households already on Thread that want a cheap CO2 reading per bedroom.
- Skip it if: you have no border router or you need NDIR-grade CO2 accuracy.
- Price note: about $30 at IKEA, USB-C power supply not included. The US page blocked our checks, so confirm stock at IKEA US.
What the Aranet4 HOME gets right
The Aranet4 HOME shares the tightest CO2 specification on the list with the Wave Plus: ±(30 ppm + 3%) from a true NDIR sensor. Aranet rates its battery for up to four years with the e-ink screen on. That makes it the monitor to carry between rooms, leave in a child’s bedroom without a cable, or use as the reference you check the cheaper sensors against.

Home Assistant’s Aranet integration picks up its Bluetooth broadcasts and is local_push, but it needs firmware 1.2.0 or newer and the “Smart Home integration” switch turned on in the Aranet Home app. Leave that switch off and Home Assistant finds nothing, which is the setup step most people miss. Put the monitor within range of the host’s Bluetooth adapter or an ESPHome Bluetooth proxy, or the readings will drop out.
- Who it’s for: trustworthy CO2 without wiring, or a reference unit for the house.
- Skip it if: you care about particles or VOCs. It measures nothing else.
- Price note: about $190 on Amazon, sold by Aranet’s own US store.
Why the SwitchBot Meter Pro CO2 is the budget NDIR pick
The SwitchBot Meter Pro (CO2 Monitor) puts a real NDIR sensor behind a large LCD for about $60, which is where true CO2 measurement starts on price. SwitchBot specifies ±(50 ppm + 5%) between 400 and 9,999 ppm, and it runs on two AA cells for about a year or from USB-C, which also speeds its refresh up.
Home Assistant reads it over Bluetooth through the SwitchBot integration, with no SwitchBot hub or account and creates temperature, humidity, CO2 and battery entities, plus a button that syncs the on-screen clock. That last one exists for a reason: the clock drifts, and an air quality monitor with the wrong time on its face looks broken even when the CO2 figure is fine. Buy the plain listing; the Alexa-enabled sibling costs about $20 more and adds nothing for Home Assistant.
- Who it’s for: a first CO2 monitor, or one per bedroom on a budget.
- Skip it if: you want particles or VOC, or you would rather not depend on Bluetooth range.
- Price note: about $60 on Amazon, sold by SwitchBot.
When the Airthings Wave Plus is worth the premium
The Airthings Wave Plus is the only pick that measures radon, and that is the reason to pay about $250 for it. Radon is a radioactive gas that seeps up from the ground and collects in basements and ground floors, and no other sensor here can detect it. Alongside radon it carries an NDIR CO2 sensor rated ±(30 ppm + 3%), plus VOC, temperature, humidity and pressure, on two AA cells for about a year and a half.
Home Assistant reads it through Airthings BLE, which polls the device on an interval rather than receiving pushes. If you already paired it to an Airthings hub with SmartLink, switch it back to Bluetooth mode first, or Home Assistant will not see it. There is no PM2.5 sensor and no screen, so this is a monitor for the floor where radon collects, not a living-room dashboard.
- Who it’s for: basements and ground-floor bedrooms in a radon area.
- Skip it if: radon is not a concern. The AirGradient ONE or Aranet4 covers CO2 for less.
- Price note: about $250 on Amazon, sold by Amazon.
What VINDSTYRKA and the Aqara TVOC monitor actually measure
Neither of the two most common Zigbee air monitors measures CO2, and that is why neither is a pick. IKEA’s VINDSTYRKA reports PM2.5, temperature, humidity and a VOC index; the Zigbee2MQTT device page lists exactly those four. The Aqara TVOC Air Quality Monitor reports TVOC, temperature and humidity, and we couldn’t find it sold new on Amazon US. Both pair fine with a Zigbee coordinator on ZHA or Zigbee2MQTT, and our ZHA vs Zigbee2MQTT comparison covers which stack to run.
Inside the 2026.10.4 container, ZHA handles both through quirks. The VINDSTYRKA quirk replaces IKEA’s manufacturer cluster 0xFC7E with a VOC index cluster, and ZHA exposes it as a VOC index sensor. These are the matching lines of zhaquirks/ikea/vindstyrka.py:
/usr/local/lib/python3.14/site-packages/zhaquirks/ikea/vindstyrka.py
1:"""IKEA VINDSTYRKA device."""
14:from zhaquirks.clusters import CustomCluster
18:class VOCIndex(CustomCluster):
19: """IKEA VOC index cluster."""
21: cluster_id: t.uint16_t = 0xFC7E
22: name: str = "IKEA VOC Index"
23: ep_attribute: str = "voc_index"
25: class AttributeDefs(BaseAttributeDefs):
29: id=0x0000, type=t.Single, access="rp", is_manufacturer_specific=True
32: id=0x0001, type=t.Single, access="r", is_manufacturer_specific=True
35: id=0x0002, type=t.Single, access="r", is_manufacturer_specific=True
40: QuirkBuilder(IKEA, "VINDSTYRKA")
41: .replaces(VOCIndex)
43: VOCIndex.AttributeDefs.measured_value.name,
44: VOCIndex.cluster_id,
50: translation_key="voc_index",
51: fallback_name="VOC index",
The Aqara monitor gets a quirk of its own, zhaquirks/xiaomi/aqara/tvoc.py, which relays its readings into a VOC level cluster.
A VOC index is not a concentration. The Sensirion algorithm reports how the current VOC level compares with what the sensor has learned is normal for that room, centred on 100. The AIR-1 firmware, for example, sets index_offset: 100 with a 72-hour learning time. A fresh coat of paint reads high, and the same paint a week later reads normal. That is useful for a range-hood or purifier automation, but it can’t tell you when a room needs fresh air, and that’s the job CO2 does. If you already own a VINDSTYRKA, keep it for particles and add an NDIR monitor beside it.
How the CO2 automation behaves in Home Assistant
A CO2 automation should switch ventilation on when the level stays high, not on every brief spike, so the trigger needs a duration. We built the test around an ESPHome node on the host platform, with a template number standing in for the CO2 reading. That gave us the same native API an Apollo AIR-1 uses, without the sensor. This is the node’s config, with the encryption key removed:
esphome:
name: air-lab
host:
logger:
api:
encryption:
key: "REDACTED-base64-32-byte-key"
number:
- platform: template
name: "CO2 Simulated"
id: co2_sim
min_value: 400
max_value: 3000
step: 10
initial_value: 600
optimistic: true
mode: box
sensor:
- platform: template
name: "CO2"
id: co2
unit_of_measurement: "ppm"
device_class: carbon_dioxide
state_class: measurement
accuracy_decimals: 0
update_interval: 10s
lambda: |-
return id(co2_sim).state;
- platform: template
name: "PM2.5"
unit_of_measurement: "µg/m³"
device_class: pm25
state_class: measurement
accuracy_decimals: 1
update_interval: 10s
lambda: |-
return 8.0 + (random_uint32() % 30) / 10.0;
- platform: template
name: "VOC Index"
state_class: measurement
accuracy_decimals: 0
update_interval: 10s
lambda: |-
return 100 + (random_uint32() % 20);
Adding it through the ESPHome integration asked for the host, port 6053 and the Noise encryption key, then created these entities. The device_class and state_class values matter more than they look. carbon_dioxide with measurement is what makes Home Assistant keep long-term statistics. The VOC index arrives with no unit and no device class, because Home Assistant has no class for an index:
number.air_lab_co2_simulated 600.0 unit=None device_class=None state_class=None
sensor.air_lab_co2 600.0 unit=ppm device_class=carbon_dioxide state_class=measurement
sensor.air_lab_pm2_5 10.1000003814697 unit=μg/m³ device_class=pm25 state_class=measurement
sensor.air_lab_voc_index 118.0 unit=None device_class=None state_class=measurement
On the device page the node looks like any ESPHome air sensor: the three readings under Sensors, the simulated CO2 control, and the two automations listed as related once they reference the CO2 entity.

The two automations use different thresholds on purpose. Ventilation turns on above 1,000 ppm, the level the UK Health and Safety Executive ties to roughly 10 litres per second of fresh air per person, and turns off below 800 ppm. That gap stops a reading that hovers near 1,000 from flicking the fan on and off every few minutes:
- id: ventilation_on_co2_high
alias: Ventilation on when CO2 above 1000 ppm
description: ""
triggers:
- trigger: numeric_state
entity_id: sensor.air_lab_co2
above: 1000
for: "00:02:00"
conditions: []
actions:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.ventilation
mode: single
- id: ventilation_off_co2_low
alias: Ventilation off when CO2 below 800 ppm
description: ""
triggers:
- trigger: numeric_state
entity_id: sensor.air_lab_co2
below: 800
for: "00:02:00"
conditions: []
actions:
- action: input_boolean.turn_off
target:
entity_id: input_boolean.ventilation
mode: single
In the lab the two-minute hold is short so the test finishes quickly; in a real room use ten or fifteen minutes. We set CO2 to 1,250 ppm, then to 650, then ran a spike that rose to 1,250 and dropped back to 950 after only 60 seconds. The poll log below is trimmed to the set commands and the polls where ventilation changed state (times UTC):
04:00:19 SET CO2 sim -> 1250
04:02:20 co2=1250.0 | ventilation=off 03:57:37
04:02:40 co2=1250.0 | ventilation=on 04:02:27
04:03:00 SET CO2 sim -> 650
04:05:01 co2=650.0 | ventilation=on 04:02:27
04:05:21 co2=650.0 | ventilation=off 04:05:07
04:05:41 SET CO2 sim -> 1250
04:06:41 SET CO2 sim -> 950
04:09:22 co2=950.0 | ventilation=off 04:05:07
Ventilation came on at 04:02:27, 2 minutes 8 seconds after the value changed, and that extra 8 seconds is the whole lesson. The for timer starts when Home Assistant receives the first reading above 1,000, and the node only reported every 10 seconds, so its first high value arrived at 04:00:27. A battery monitor that reports every few minutes adds that interval to every automation built on it. The 60-second spike never triggered anything: the for clause reset when CO2 fell to 950, which is exactly the behaviour you want from a door left open for a minute.

Because the CO2 sensor has state_class: measurement, the recorder also kept 5-minute statistics with mean, min and max. The more-info dialog draws them as a band, which is how you’ll spot a bedroom that climbs past 1,000 ppm every night without reading raw history.

The 04:00 bucket in that graph averaged 970 ppm with a 650 minimum and a 1,250 maximum, straight from the recorder’s 5-minute table. A statistics card on a dashboard reads the same table, so a week of bedroom data costs you nothing extra to keep.
What to check before you buy
Six specs separate a monitor that works with Home Assistant from one that only looks like it does. Read them in this order, because the first two decide whether the CO2 number is worth automating on at all:
- CO2 sensor type: look for NDIR or photoacoustic NDIR and a part number (SenseAir S8, Sensirion SCD4x). “eCO2” or a MOX part alone means an estimate.
- Calibration control: automatic baseline correction is fine in rooms that air out weekly. For a sealed bedroom, pick a monitor that lets you turn it off or force-calibrate outdoors.
- Radio you already run: Wi-Fi needs nothing extra, Thread needs a border router, and Bluetooth needs an adapter or proxy near the sensor.
- IoT class:
local_pushorlocal_pollingkeeps working with the internet down; anythingcloud_stops when your connection does. - Power: USB-powered monitors report more often; battery units report less often to save power, which delays automations.
- Particles and VOC: PM2.5 is a real concentration you can automate a purifier on. VOC from these sensors is an index, useful for trends and not for thresholds.
Run Home Assistant on something that stays up, because a CO2 automation is only as reliable as the box running it. Our guides to installing Home Assistant on Ubuntu 26.04 and running it in Docker cover the server side. The same CO2 entity pairs well with a local thermostat, or with an mmWave presence sensor so ventilation only runs when the room is occupied. If you would rather build your own ESPHome monitor, our ESP32 board guide covers the boards these sensors attach to.
Where a CO2 reading misleads you
A good optical sensor still gives you a wrong number under four conditions, and each one has a fix you can apply in Home Assistant or on the device.
A sealed bedroom drifts low
Self-calibration assumes the room reaches outdoor air at some point every week. Sensirion’s datasheet gives the SCD4x a 156-hour standard calibration period that needs that exposure, and AirGradient sets the S8’s correction to seven days. A bedroom with the window shut and someone in it every night never reaches outdoor air. The sensor slowly decides that its overnight low is the new baseline, and every reading after that comes out too low. Turn self-calibration off for that room and force-calibrate the monitor outdoors instead; on the AIR-1 that means the “CO2 Auto Calibration” switch plus the calibrate_co2_value action.
Outdoor air isn’t 400 ppm any more
NOAA’s Mauna Loa record put the September 2026 monthly mean at 426.07 ppm, and city air often runs higher. A sensor that pins outdoor air to 400 ppm reads about 25 ppm low across the whole range. That matters little at 1,000 ppm, but it explains why a freshly aired room never quite shows 400.
The case warms the sensor
Every mains-powered monitor here has a microcontroller in the same box as its temperature sensor, and it adds a few degrees. Apollo ships an adjustable offset for that reason, and buyers report the ALPSTUGA reads warm too. CO2 sensors compensate for temperature internally, so the CO2 figure is fine, but do not drive your heating from the temperature entity of an air quality monitor.
CO2 is not a safety alarm
None of these monitors detects carbon monoxide, the gas a faulty boiler or generator produces, and Apollo says plainly that the AIR-1 is not for safety monitoring. A CO2 reading tells you the room needs fresh air. Keep a certified CO alarm near fuel-burning appliances, separate from anything you automate.

