Contents — 26 sections
Project Overview
A monitor that measures the three indoor pollutants that actually matter — CO₂, PM2.5 and VOCs — with real NDIR and laser sensors rather than the cheap resistive parts that produce confident, meaningless numbers.
Almost every "air quality" project on the internet uses an MQ-135 and reports a CO₂ figure in parts per million. That number is fiction. The MQ-135 is a tin-dioxide resistive sensor with broad, overlapping sensitivity to alcohols, ammonia, benzene and CO₂, no selectivity between them, and a response that drifts with humidity and temperature. It is a useful "something changed" detector and a useless quantitative instrument.
This build uses sensors that measure what they claim to. An MH-Z19B uses non-dispersive infrared absorption to measure CO₂ specifically, at ±(50 ppm + 5 %). A PMS5003 uses laser scattering with a fan and a counting chamber to size and count particles, giving genuine PM1.0, PM2.5 and PM10 mass concentrations. A BME280 supplies temperature, humidity and pressure — which matter both directly for comfort and as compensation inputs.
CO₂ is the most actionable of the three, and the least intuitive. It is not itself very harmful at indoor concentrations, but it is an excellent proxy for ventilation rate: humans exhale it continuously, so if CO₂ is rising, the air you are breathing is increasingly air someone else has already breathed. That matters for cognitive performance — there is reasonable evidence of measurable decision-making decline above roughly 1000 ppm — and, since 2020, it has become the standard proxy for airborne-disease transmission risk in a shared room.
The system reports each pollutant separately rather than collapsing them into a single "AQI" number. That is deliberate: the actions are different. High CO₂ means open a window. High PM2.5 means close the window and run a filter. A single index that averages them can point you in exactly the wrong direction.
What this project does
- Measures CO₂ by NDIR, PM1.0/PM2.5/PM10 by laser scattering, and temperature, humidity and pressure.
- Displays live values with colour-coded thresholds on an OLED.
- Publishes everything over MQTT with Home Assistant discovery for each measurement.
- Duty-cycles the particulate sensor fan to extend its service life from months to years.
- Applies the correct disable of the CO₂ sensor's automatic baseline calibration for continuously occupied rooms.
- Estimates the room's air-change rate from the CO₂ decay curve after occupants leave.
- Alerts when CO₂ exceeds a ventilation threshold or PM2.5 exceeds a health threshold.
Real-World Applications
| Setting | How it is used |
|---|---|
| Bedroom ventilation | CO₂ in a closed bedroom routinely reaches 2000–3000 ppm overnight. Seeing that number is usually enough to change behaviour. |
| Classrooms and meeting rooms | CO₂ is the standard proxy for ventilation adequacy and is now used in many school ventilation guidelines. |
| Cooking and indoor PM | Frying produces PM2.5 concentrations that would be a public-health emergency outdoors. Extractor fans are usually not run long enough. |
| Wildfire and outdoor pollution episodes | Tells you whether keeping windows shut is actually working. |
| Workshop and 3D-printer rooms | Resin printers and soldering both produce measurable VOC and particulate loads. |
| Rental and property disputes | A logged record of damp-driving humidity and inadequate ventilation is far more persuasive than an opinion. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- True NDIR CO₂ rather than a resistive proxy — the single most important choice in the build.
- Laser particle counting with mass concentrations for three size fractions.
- Fan duty cycling on the PMS5003: 30 s of measurement every 5 minutes, roughly a tenfold life extension.
- ABC disable for the MH-Z19B, with a documented manual calibration procedure instead.
- Air-change-rate estimation from the exponential CO₂ decay after a room empties.
- Per-pollutant thresholds based on published guidance rather than an invented composite index.
- Home Assistant discovery for six separate sensor entities.
- Local historical buffer so a broker outage does not lose the trend.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 8–12 hours |
| Indicative build cost | ₹6,200 – ₹7,600 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- UART communication with two devices, or one UART plus software serial
- I²C sensor reading
- Arduino C++ with non-blocking scheduling
- Understanding of what a sensor specification actually promises
- MQTT and Home Assistant discovery
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 1 | ₹450 |
| MH-Z19B NDIR CO₂ sensor Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm. | 0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output | 1 | ₹2,600 |
| Plantower PMS5003 laser particulate sensor The fan is a wear item — run it in duty cycles, not continuously. | PM1.0 / PM2.5 / PM10, 0–500 µg/m³, ±10 %, laser scattering, 30 s stabilisation | 1 | ₹1,900 |
| BME280 pressure/humidity/temperature sensor Self-heating skews temperature by ~1 °C — read in forced mode, not continuous. | 300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz | 1 | ₹420 |
| 0.96″ SSD1306 OLED display Static images burn in — invert or scroll the screen periodically. | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 1 | ₹250 |
| LM2596 adjustable buck converter module Set the output voltage with no load connected before wiring the board. | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 1 | ₹90 |
| 5 V 3 A regulated SMPS adapter Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 1 | ₹350 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| IP65 ABS junction enclosure 158 × 90 × 60 mm Fit cable glands, not drilled holes, or the IP rating means nothing. | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | 1 | ₹260 |
| Small 40 mm fan (optional) Only needed if the enclosure is tight — the PMS5003 has its own fan. | 5 V, 0.1 A, for enclosure airflow | 1 | ₹120 |
| Ventilation mesh and grommets | Stainless mesh, 1 mm | 1 | ₹80 |
Estimated total: ₹6,580, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| MH-Z19B NDIR CO₂ sensor | 0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output | 4.5–5.5 V | UART 9600 8N1, PWM | Datasheet |
| Plantower PMS5003 laser particulate sensor | PM1.0 / PM2.5 / PM10, 0–500 µg/m³, ±10 %, laser scattering, 30 s stabilisation | 5 V | UART 9600 | Datasheet |
| BME280 pressure/humidity/temperature sensor | 300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz | 1.7–3.6 V (module has 3.3 V LDO) | I²C (0x76/0x77) or SPI | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| MH-Z19B NDIR CO₂ sensor | 4.5–5.5 V | 60 | Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm. |
| Plantower PMS5003 laser particulate sensor | 5 V | 100 | The fan is a wear item — run it in duty cycles, not continuously. |
| BME280 pressure/humidity/temperature sensor | 1.7–3.6 V (module has 3.3 V LDO) | 0.4 | Self-heating skews temperature by ~1 °C — read in forced mode, not continuous. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. |
Summed typical draw is 3348.4 mA. With a 1.5× design margin the supply should deliver at least 5100 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools → Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
- Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Adafruit BME280 2.2.x | Compensation maths for the Bosch pressure/humidity/temperature sensor. | Library Manager → "Adafruit BME280 Library" |
| Adafruit Unified Sensor 1.1.x | Common sensor event abstraction; a dependency of most Adafruit drivers. | Library Manager → "Adafruit Unified Sensor" |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| MH-Z19B CO₂ | TX / RX | GPIO 16 / 17 | UART2, 9600 8N1 |
| PMS5003 particulate | TX / RX | GPIO 25 / 26 | UART1, 9600 8N1 |
| PMS5003 | SET | GPIO 27 | Low = sleep (fan off) |
| BME280 | SDA / SCL | GPIO 21 / 22 | I²C at 0x76 |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | Shared I²C, 0x3C |
| Status LED (RGB) | R / G / B | GPIO 12 / 13 / 14 | Threshold indication |
| Buzzer | + | GPIO 15 | Threshold alert |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- Both gas sensors want a solid 5 V supply. The MH-Z19B draws about 60 mA average with peaks over 150 mA during its IR lamp pulse; the PMS5003 draws about 100 mA with the fan running. Do not power either from the ESP32 3V3 rail.
- Both sensors output 3.3 V logic on their TX lines, so they connect directly to ESP32 RX pins with no level shifting. Their RX inputs tolerate 3.3 V from the ESP32.
- The ESP32 has three hardware UARTs. Use UART1 and UART2 for the two sensors and leave UART0 for the USB serial monitor — software serial on an ESP32 is unnecessary and unreliable at these rates.
- The PMS5003 SET pin is the sleep control. Driving it low stops the fan and the laser; this is what makes duty cycling possible and it is the difference between a sensor that lasts eight months and one that lasts five years.
- Mount the PMS5003 with its inlet and outlet unobstructed and at least 20 mm from any wall. Its measurement depends on a defined airflow through the chamber, and blocking either port silently biases the readings.
- Keep the BME280 away from the MH-Z19B — the CO₂ sensor's lamp makes it a real heat source and will bias the temperature reading upward by two or three degrees.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
NDIR CO₂ measurement exploits the fact that CO₂ absorbs infrared strongly at 4.26 µm and almost nothing else in indoor air does. The sensor contains an IR lamp, a gas chamber of known path length, an optical filter centred at that wavelength, and a detector. More CO₂ in the chamber means less IR reaches the detector, following the Beer-Lambert law. Because the absorption band is narrow and specific, the measurement is genuinely selective — that specificity is the entire reason NDIR costs ₹2,600 and an MQ-135 costs ₹180.
NDIR sensors drift, mostly because the lamp ages. Manufacturers compensate with automatic baseline correction: the sensor assumes that over any two-week window the lowest reading it saw corresponds to outdoor air at about 400 ppm, and it rescales accordingly. In an office that empties every night this works beautifully. In a bedroom that never drops to outdoor levels, or a greenhouse, or a continuously occupied space, it is actively harmful — the sensor will drag its baseline down and under-report by hundreds of ppm. Disabling ABC and calibrating manually once a year is the correct choice for most home installs.
Laser scattering in the PMS5003 works differently. A fan draws a controlled airflow past a laser beam; particles crossing the beam scatter light onto a photodiode, and the amplitude of each scattering pulse relates to particle size while the pulse rate gives count. The sensor bins particles into size classes and then converts counts to mass concentration using an assumed particle density and shape. That conversion is where the uncertainty lives: the ±10 % specification assumes typical urban aerosol, and readings for an unusual aerosol such as cooking oil smoke can be systematically off. It remains far more trustworthy than any resistive alternative.
The fan is a mechanical wear item with a rated life around 8000 hours — under a year of continuous running. Duty cycling is therefore not an optimisation but a requirement for a device meant to last. The sensor needs roughly 30 seconds of running airflow to give a stable reading, so a 30-second window every five minutes gives a tenth of the running hours and loses essentially nothing, because indoor particulate levels do not change meaningfully in five minutes.
The air-change rate estimate falls out of the CO₂ data for free. When people leave a room, CO₂ decays exponentially towards the outdoor concentration with a time constant set by the ventilation rate. Fitting ln(C − C_out) against time during a decay period gives air changes per hour directly. A bedroom at 0.3 ACH is badly ventilated; 3 ACH is well ventilated. This is a genuinely useful number that almost no commercial monitor reports.
The maths behind it
Beer-Lambert absorption
I = I₀ · e^(−ε · c · L)
I₀ = source intensity, I = detected intensity
ε = molar absorptivity of CO₂ at 4.26 µm
c = concentration, L = optical path length
Rearranged for the sensor:
c ∝ −ln(I / I₀) / (ε · L)
Longer L gives more sensitivity, which is why
high-accuracy NDIR sensors are physically larger.
Air-change rate from CO₂ decay
C(t) = C_out + (C₀ − C_out) · e^(−ACH · t)
ln(C(t) − C_out) = ln(C₀ − C_out) − ACH · t
Worked example — bedroom after occupants leave:
t = 0 min : 1850 ppm
t = 30 min: 1180 ppm
C_out : 420 ppm
ACH = −ln((1180−420)/(1850−420)) / 0.5 h
= −ln(0.531) / 0.5 = 1.27 air changes/hour
Under 0.5 ACH is poor; 1–2 is typical; ASHRAE 62.1
residential guidance is around 0.35 ACH minimum.
CO₂ generation and steady state
One adult at rest produces ≈ 0.005 L/s of CO₂
Steady-state concentration:
C_ss = C_out + (G / Q)
G = generation rate, Q = ventilation flow rate
Bedroom, 30 m³, 2 people, 0.5 ACH:
Q = 30 × 0.5 / 3600 = 0.00417 m³/s
G = 2 × 0.005 L/s = 0.00001 m³/s
C_ss = 420 + (0.00001 / 0.00417) × 10⁶
= 420 + 2400 = 2820 ppm
Which is exactly what closed bedrooms measure.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
/* ═══════════════════════════════════════════════════════════════
Indoor Air Quality Monitor — ESP32 + MH-Z19B + PMS5003 + BME280
Real NDIR CO2 and laser particulate measurement, with the
particulate fan duty-cycled for service life and the CO2 sensor's
automatic baseline correction deliberately disabled.
══════════════════════════════════════════════════════════════════ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
#include <math.h>
#define WIFI_SSID "YOUR_WIFI"
#define WIFI_PASS "YOUR_PASSWORD"
#define MQTT_HOST "192.168.1.50"
#define DEVICE_ID "air-bedroom"
#define PIN_PMS_SET 27
#define PIN_LED_R 12
#define PIN_LED_G 13
#define PIN_LED_B 14
#define PIN_BUZZER 15
#define CO2_WARN 1000 // ppm — ventilation guidance threshold
#define CO2_ALERT 1500
#define PM25_WARN 15 // ug/m3 — WHO 2021 24-h guideline
#define PM25_ALERT 35
#define PM_PERIOD_MS 300000UL // measure every 5 minutes
#define PM_WARMUP_MS 30000UL // fan must run 30 s before a valid read
HardwareSerial co2Serial(2); // GPIO 16/17
HardwareSerial pmsSerial(1); // GPIO 25/26
Adafruit_BME280 bme;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient net;
PubSubClient mqtt(net);
int co2 = 0, pm1 = 0, pm25 = 0, pm10 = 0;
float tempC = 0, rh = 0, hPa = 0;
bool pmValid = false;
uint32_t pmWindowStart = 0;
bool pmFanOn = false;
/* CO2 decay history for the air-change-rate estimate */
struct Sample { uint32_t t; int ppm; };
Sample history[60]; // 10 minutes at 10 s intervals
uint8_t histHead = 0;
float achEstimate = 0;
float co2Outdoor = 420;
/* ── MH-Z19B ────────────────────────────────────────────────── */
uint8_t mhzChecksum(const uint8_t *p) {
uint8_t s = 0;
for (int i = 1; i < 8; i++) s += p[i];
return 0xFF - s + 1;
}
void mhzSend(uint8_t cmd, uint8_t b3 = 0, uint8_t b4 = 0) {
uint8_t f[9] = { 0xFF, 0x01, cmd, b3, b4, 0, 0, 0, 0 };
f[8] = mhzChecksum(f);
co2Serial.write(f, 9);
}
void mhzDisableABC() {
// 0x79 0x00 = ABC off. Essential for rooms that never reach
// outdoor CO2 levels, such as an occupied bedroom.
mhzSend(0x79, 0x00);
delay(50);
}
void mhzCalibrateZero() {
// Only valid after 20+ minutes in genuine outdoor air (~400 ppm).
mhzSend(0x87);
delay(50);
}
int mhzRead() {
while (co2Serial.available()) co2Serial.read(); // flush stale bytes
mhzSend(0x86);
uint8_t r[9];
uint32_t t0 = millis();
int got = 0;
while (got < 9 && millis() - t0 < 300)
if (co2Serial.available()) r[got++] = co2Serial.read();
if (got < 9 || r[0] != 0xFF || r[1] != 0x86) return -1;
if (r[8] != mhzChecksum(r)) return -1;
return r[2] * 256 + r[3];
}
/* ── PMS5003 ────────────────────────────────────────────────── */
void pmsSleep(bool sleep) {
digitalWrite(PIN_PMS_SET, sleep ? LOW : HIGH);
pmFanOn = !sleep;
}
bool pmsRead() {
// Frames are 32 bytes starting 0x42 0x4D.
uint32_t t0 = millis();
while (millis() - t0 < 2000) {
if (pmsSerial.available() < 32) { delay(10); continue; }
if (pmsSerial.read() != 0x42) continue;
if (pmsSerial.read() != 0x4D) continue;
uint8_t b[30];
for (int i = 0; i < 30; i++) b[i] = pmsSerial.read();
uint16_t sum = 0x42 + 0x4D;
for (int i = 0; i < 28; i++) sum += b[i];
uint16_t given = (b[28] << 8) | b[29];
if (sum != given) continue; // corrupt frame
// Atmospheric-environment values start at offset 8.
pm1 = (b[8] << 8) | b[9];
pm25 = (b[10] << 8) | b[11];
pm10 = (b[12] << 8) | b[13];
return true;
}
return false;
}
void pmService() {
uint32_t now = millis();
if (!pmFanOn && now - pmWindowStart >= PM_PERIOD_MS) {
pmsSleep(false);
pmWindowStart = now;
return;
}
if (pmFanOn && now - pmWindowStart >= PM_WARMUP_MS) {
pmValid = pmsRead();
pmsSleep(true);
pmWindowStart = now - PM_WARMUP_MS; // next window a full period later
}
}
/* ── air change rate from CO2 decay ─────────────────────────── */
void achUpdate() {
history[histHead] = { millis(), co2 };
histHead = (histHead + 1) % 60;
// Find the oldest and newest samples; only fit a genuine decay.
Sample oldest = history[histHead];
Sample newest = history[(histHead + 59) % 60];
if (!oldest.t || newest.t <= oldest.t) return;
float dropPpm = oldest.ppm - newest.ppm;
if (dropPpm < 100) return; // not decaying meaningfully
if (oldest.ppm - co2Outdoor < 200) return; // too close to outdoor
float hours = (newest.t - oldest.t) / 3600000.0f;
float ratio = (newest.ppm - co2Outdoor) / (float)(oldest.ppm - co2Outdoor);
if (ratio <= 0.01f || ratio >= 1.0f) return;
float ach = -logf(ratio) / hours;
if (ach > 0.05f && ach < 20.0f)
achEstimate = 0.8f * achEstimate + 0.2f * ach; // smooth
}
/* ── presentation ───────────────────────────────────────────── */
void setLed(uint8_t r, uint8_t g, uint8_t b) {
digitalWrite(PIN_LED_R, r); digitalWrite(PIN_LED_G, g); digitalWrite(PIN_LED_B, b);
}
void assess() {
bool alert = co2 >= CO2_ALERT || (pmValid && pm25 >= PM25_ALERT);
bool warn = co2 >= CO2_WARN || (pmValid && pm25 >= PM25_WARN);
if (alert) setLed(1, 0, 0);
else if (warn) setLed(1, 1, 0);
else setLed(0, 1, 0);
static bool wasAlert = false;
if (alert && !wasAlert) { tone(PIN_BUZZER, 2000, 400); }
wasAlert = alert;
}
void draw() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2); oled.setCursor(0, 0);
oled.printf("%d", co2);
oled.setTextSize(1); oled.setCursor(58, 8); oled.print("ppm CO2");
oled.setCursor(0, 22);
if (pmValid) oled.printf("PM2.5 %d PM10 %d ug", pm25, pm10);
else oled.print("PM measuring...");
oled.setCursor(0, 34); oled.printf("%.1fC %.0f%%RH %.0fhPa", tempC, rh, hPa);
oled.setCursor(0, 46); oled.printf("ACH %.2f fan %s", achEstimate, pmFanOn ? "on" : "off");
oled.setCursor(0, 56);
oled.print(co2 >= CO2_ALERT ? "VENTILATE NOW"
: co2 >= CO2_WARN ? "open a window"
: "air is fine");
oled.display();
}
/* ── MQTT ───────────────────────────────────────────────────── */
void publishDiscovery() {
struct { const char *id, *name, *unit, *cls, *field; } S[] = {
{ "co2", "CO2", "ppm", "carbon_dioxide", "co2" },
{ "pm25", "PM2.5", "µg/m³", "pm25", "pm25" },
{ "pm10", "PM10", "µg/m³", "pm10", "pm10" },
{ "temp", "Temperature", "°C", "temperature", "temp" },
{ "hum", "Humidity", "%", "humidity", "rh" },
{ "pres", "Pressure", "hPa", "pressure", "hpa" },
};
for (auto &s : S) {
JsonDocument d;
d["name"] = s.name;
d["unique_id"] = String(DEVICE_ID) + "_" + s.id;
d["state_topic"] = "home/air/" DEVICE_ID "/state";
d["unit_of_measurement"] = s.unit;
d["device_class"] = s.cls;
d["state_class"] = "measurement";
d["value_template"] = String("{{ value_json.") + s.field + " }}";
char buf[420]; size_t n = serializeJson(d, buf, sizeof(buf));
mqtt.publish((String("homeassistant/sensor/") + DEVICE_ID + "_" + s.id + "/config").c_str(),
(uint8_t *)buf, n, true);
}
}
void publishState() {
JsonDocument d;
d["co2"] = co2;
d["pm1"] = pm1; d["pm25"] = pm25; d["pm10"] = pm10;
d["pm_valid"] = pmValid;
d["temp"] = roundf(tempC * 10) / 10.0f;
d["rh"] = roundf(rh);
d["hpa"] = roundf(hPa);
d["ach"] = roundf(achEstimate * 100) / 100.0f;
char buf[256]; size_t n = serializeJson(d, buf, sizeof(buf));
mqtt.publish("home/air/" DEVICE_ID "/state", (uint8_t *)buf, n, true);
}
/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
Serial.begin(115200);
pinMode(PIN_PMS_SET, OUTPUT);
pinMode(PIN_LED_R, OUTPUT); pinMode(PIN_LED_G, OUTPUT); pinMode(PIN_LED_B, OUTPUT);
co2Serial.begin(9600, SERIAL_8N1, 16, 17);
pmsSerial.begin(9600, SERIAL_8N1, 25, 26);
Wire.begin(21, 22);
bme.begin(0x76);
bme.setSampling(Adafruit_BME280::MODE_FORCED, Adafruit_BME280::SAMPLING_X1,
Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1,
Adafruit_BME280::FILTER_OFF);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
pmsSleep(true);
delay(3000); // MH-Z19B needs a moment before commands
mhzDisableABC();
Serial.println("ABC disabled — calibrate manually once a year");
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
mqtt.setServer(MQTT_HOST, 1883);
mqtt.setBufferSize(768);
Serial.println("Warming up — CO2 readings valid after ~3 minutes");
}
void loop() {
if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
if (mqtt.connect(DEVICE_ID)) publishDiscovery();
}
mqtt.loop();
pmService();
static uint32_t last = 0;
if (millis() - last >= 10000) {
last = millis();
int c = mhzRead();
if (c > 300 && c < 10000) co2 = c; // reject obvious garbage
bme.takeForcedMeasurement();
tempC = bme.readTemperature();
rh = bme.readHumidity();
hPa = bme.readPressure() / 100.0f;
achUpdate();
assess();
draw();
publishState();
Serial.printf("CO2 %d ppm PM2.5 %d T %.1f RH %.0f ACH %.2f\n",
co2, pm25, tempC, rh, achEstimate);
}
}
Configuration & Calibration
Configuration steps
- Disable ABC (as the sketch does) for bedrooms, greenhouses and any continuously occupied space. Leave it enabled only for an office that genuinely empties overnight.
- Set
co2Outdoorto your local background — roughly 420 ppm globally, but 450–500 ppm in a dense city. The ACH calculation is sensitive to this. - Adjust
PM_PERIOD_MS. Five minutes suits a home; during a cooking event or a wildfire episode you may want one minute, at the cost of fan life. - Thresholds here follow WHO 2021 guidance for PM2.5 and common ventilation guidance for CO₂. Adjust deliberately, and record why.
- Allow a three-minute warm-up before trusting CO₂ readings, and thirty seconds of fan before trusting PM readings. Both are in the datasheets and both are routinely ignored.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Manual CO₂ zero calibration (once a year)
Take the unit outdoors, away from roads and people, and leave it running for at least 20 minutes. Then trigger the zero calibration command (
mhzCalibrateZero()), which tells the sensor that what it is currently seeing is 400 ppm. Do this in genuine outdoor air only — running it indoors permanently miscalibrates the sensor and there is no undo.Sanity-check against a second source
If you can borrow another CO₂ meter, compare in the same room. Agreement within about 100 ppm is expected. A large offset that does not respond to zero calibration means the sensor is at end of life.
Verify the PM sensor responds
Light a match near (not at) the inlet. PM2.5 should rise into the hundreds within a minute and decay over ten to twenty minutes. No response means the fan is not running or the SET pin logic is inverted.
Check the temperature offset
Compare the BME280 against a reference thermometer. If it reads consistently high, the MH-Z19B or the regulator is heating it — move it before applying a software offset.
Network Architecture & Connectivity
| Topic / endpoint | Direction | Payload |
|---|---|---|
home/air/air-bedroom/state | device → broker (retained) | JSON: co2, pm1, pm25, pm10, pm_valid, temp, rh, hpa, ach |
homeassistant/sensor/air-bedroom_*/config | device → broker (retained) | Discovery documents, one per measurement |
Message contract between the device and the broker.
Dashboard setup
The panel that changes behaviour is an overnight CO₂ chart with the bedroom door state overlaid. Watching CO₂ climb from 500 to 2800 ppm between 23:00 and 06:00 with the door closed, and stay under 900 with it ajar, is more persuasive than any amount of explanation.
A second useful panel plots PM2.5 with cooking times marked. Most people substantially underestimate how long indoor particulates persist after frying — typically 45–90 minutes without extraction.
Security considerations
- These are environmental readings rather than personal data, but CO₂ is a very good occupancy signal — it reveals when a house is empty. Keep it on your own broker rather than a public cloud service.
- Use broker authentication so a neighbour cannot infer your schedule from your ventilation data.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Power on and wait three minutes | CO₂ settles to a plausible indoor value (450–900 ppm in a ventilated room), not 400 or 5000. |
| Breathe gently towards the CO₂ inlet from 20 cm | A rise into the thousands within 30 s, decaying back over a minute or two. |
| Watch a full PM duty cycle | Fan audibly starts, runs 30 s, PM values update, fan stops. Repeats five minutes later. |
| Light a match near the PM inlet | PM2.5 rises sharply and decays over 10–20 minutes. |
| Close a bedroom door overnight with the monitor inside | CO₂ climbing steadily to 1500–3000 ppm by morning — the reading that makes the project worthwhile. |
| Open the door and window in the morning | Exponential decay, and an ACH estimate appearing after about ten minutes of decline. |
| Check Home Assistant | Six separate sensor entities appear automatically with correct units and device classes. |
| Compare BME280 temperature to a reference | Within about 1 °C, with no upward drift after an hour. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Duty cycle the particulate fan. It is the only mechanical part and its life dominates the device's service interval.
- Read the CO₂ sensor no more than once every ten seconds. Its internal update rate is around 5 s and polling faster adds UART traffic for no new information.
- Keep the two sensors on separate hardware UARTs rather than software serial — a dropped byte on a shared or bit-banged port shows up as a wild reading.
- Replace every
delay()with amillis()comparison — blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- This is a monitor, not a life-safety device. It does not detect carbon monoxide, which is the one that kills — fit a separate certified CO alarm.
- Do not use CO₂ readings to justify sealing a room. High CO₂ means more ventilation is needed, never less.
- The PMS5003 contains a class-1 laser inside a sealed chamber. Do not disassemble it.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Zero-calibrate the CO₂ sensor outdoors once a year with ABC disabled.
- Blow out the PM sensor inlet with clean dry air every six months; do not use a brush inside it.
- Replace the PMS5003 after about five years of duty-cycled use — the fan and the laser both degrade.
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Clean the sensing element on a schedule. Optical and electrochemical sensors foul, and a fouled sensor reports plausible nonsense rather than failing outright.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add an SGP41 or BME688 for a genuine VOC index. Unlike an MQ-135 these are calibrated, temperature-compensated and give a meaningful relative index.
- Add automatic ventilation control — an ERV or an extractor fan driven by the CO₂ threshold closes the loop.
- Add radon measurement with an RD200M, which is the one indoor pollutant with a clear long-term mortality link and no perceptible signature.
- Log PM2.5 indoors and outdoors simultaneously and compute the infiltration ratio — that number tells you exactly how much a filter or better sealing would achieve.
- Add a formaldehyde sensor if you have new furniture or flooring; it is a common and long-lasting indoor pollutant that none of these sensors detect.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- MH-Z19B intelligent infrared CO₂ module — user manualWinsen
- Plantower PMS5003 digital universal particle concentration sensor — manualPlantower / South Coast AQMD
- WHO global air quality guidelines 2021 — PM2.5, PM10, NO₂, O₃World Health Organization
- Allen et al., "Associations of Cognitive Function Scores with Carbon Dioxide, Ventilation, and VOC Exposures"Environmental Health Perspectives, 2016
- ASHRAE Standard 62.1 — ventilation for acceptable indoor air qualityASHRAE
- Using CO₂ as a ventilation and infection-risk proxyUS CDC / NIOSH
- Beer-Lambert law and NDIR gas measurementWikipedia