Siddhant Kumar
Project 008 · Smart Home

Indoor Air Quality Monitor.

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.

Intermediate 8–12 hours 41 min read SensorsAQIDashboard
Jump to source Bill of materials
Indoor Air Quality Monitor — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Intermediate
Build time
8–12 hours
Indicative cost
₹6,200 – ₹7,600
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
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

SettingHow it is used
Bedroom ventilationCO₂ in a closed bedroom routinely reaches 2000–3000 ppm overnight. Seeing that number is usually enough to change behaviour.
Classrooms and meeting roomsCO₂ is the standard proxy for ventilation adequacy and is now used in many school ventilation guidelines.
Cooking and indoor PMFrying produces PM2.5 concentrations that would be a public-health emergency outdoors. Extractor fans are usually not run long enough.
Wildfire and outdoor pollution episodesTells you whether keeping windows shut is actually working.
Workshop and 3D-printer roomsResin printers and soldering both produce measurable VOC and particulate loads.
Rental and property disputesA 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

AttributeValue
Difficulty levelIntermediate
Estimated completion time8–12 hours
Indicative build cost₹6,200 – ₹7,600
Primary disciplineSmart Home
Reference platformESP32 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.

ComponentKey specificationQtyApprox. 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 DAC1₹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 output1₹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 stabilisation1₹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 Hz1₹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²C1₹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 % efficiency1₹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 protection1₹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 grid1₹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 lugs1₹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 airflow1₹120
Ventilation mesh and grommetsStainless mesh, 1 mm1₹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

PartSpecificationSupplyInterfaceReference
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 DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
MH-Z19B NDIR CO₂ sensor0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output4.5–5.5 VUART 9600 8N1, PWMDatasheet
Plantower PMS5003 laser particulate sensorPM1.0 / PM2.5 / PM10, 0–500 µg/m³, ±10 %, laser scattering, 30 s stabilisation5 VUART 9600Datasheet
BME280 pressure/humidity/temperature sensor300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz1.7–3.6 V (module has 3.3 V LDO)I²C (0x76/0x77) or SPIDatasheet
0.96″ SSD1306 OLED display128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C3.3–5 VI²C (0x3C)Datasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
MH-Z19B NDIR CO₂ sensor4.5–5.5 V60Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm.
Plantower PMS5003 laser particulate sensor5 V100The fan is a wear item — run it in duty cycles, not continuously.
BME280 pressure/humidity/temperature sensor1.7–3.6 V (module has 3.3 V LDO)0.4Self-heating skews temperature by ~1 °C — read in forced mode, not continuous.
0.96″ SSD1306 OLED display3.3–5 V20Static images burn in — invert or scroll the screen periodically.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
5 V 3 A regulated SMPS adapter5 V3000Measure 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
Adafruit BME280 2.2.xCompensation maths for the Bosch pressure/humidity/temperature sensor.Library Manager → "Adafruit BME280 Library"
Adafruit Unified Sensor 1.1.xCommon sensor event abstraction; a dependency of most Adafruit drivers.Library Manager → "Adafruit Unified Sensor"
Adafruit SSD1306 + GFX 2.5.xFramebuffer and text/graphics primitives for the OLED.Library Manager → "Adafruit SSD1306"
Preferences (NVS) bundledWear-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.

Indoor Air Quality Monitor — system block diagramFunctional block diagram of the Indoor Air Quality Monitor system. SenseMH-Z19BNDIR CO₂PMS5003laser PMBME280T / RH / PAcquireESP32schedule + parseDuty cyclerfan lifeAssessThreshold logicper pollutantACH estimatordecay fitReportOLED + RGBlocalMQTT → HAhistoryUART / I²Craw valuesassessed state
Indoor Air Quality Monitor — system block diagram

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.

Indoor Air Quality Monitor — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBMH-Z19B CO₂GPIO 16 / 17UART2, 9600 8N1PMS5003 particulateGPIO 25 / 26UART1, 9600 8N1PMS5003GPIO 27Low = sleep (fanoff)BME280GPIO 21 / 22I²C at 0x76SSD1306 OLEDGPIO 21 / 22Shared I²C, 0x3CStatus LED (RGB)GPIO 12 / 13 / 14Threshold indicationBuzzerGPIO 15Threshold alert
Indoor Air Quality Monitor — wiring schematic
PeripheralPeripheral pinController pinSignal
MH-Z19B CO₂TX / RXGPIO 16 / 17UART2, 9600 8N1
PMS5003 particulateTX / RXGPIO 25 / 26UART1, 9600 8N1
PMS5003SETGPIO 27Low = sleep (fan off)
BME280SDA / SCLGPIO 21 / 22I²C at 0x76
SSD1306 OLEDSDA / SCLGPIO 21 / 22Shared I²C, 0x3C
Status LED (RGB)R / G / BGPIO 12 / 13 / 14Threshold indication
Buzzer+GPIO 15Threshold 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.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board, which supplies the three hardware UARTs this build needs. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

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.

Indoor Air Quality Monitor — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · MH-Z19B NDIR CO₂ sensor · PlantowerPMS5003 laser particulate sensor · BME280 pressure/humidity/temperaturesensorDriver layerwifi · pubsub · arduinojson · bmeApplication logicsampling loop · filtering · thresholds · state machineTransport layerWi-Fi + MQTT · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Indoor Air Quality Monitor — architecture stack

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

plainBeer-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

plainAir-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

plainCO₂ 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.

Indoor Air Quality Monitor — firmware flowchartControl flow through the main program loop. Boot: init UARTs, disableMH-Z19B ABCRead CO₂ and BME280 every 10 sPM measurement window due(5 min)?wake fankeep fan asleepRun fan 30 s, then read PMframeEvaluate thresholds perpollutantAny threshold exceeded?LED + buzzer + alertnormal displayPublish all values over MQTTUpdate CO₂ decay history forACH
Indoor Air Quality Monitor — firmware flowchart

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.

cppair-quality-monitor.ino
/* ═══════════════════════════════════════════════════════════════
   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);
  }
}
mhzDisableABC()The most consequential line in the sketch. With ABC enabled in a bedroom that never reaches outdoor CO₂, the sensor progressively rescales its baseline downward and under-reports by hundreds of ppm — while looking perfectly plausible.
mhzChecksumThe MH-Z19B protocol has a simple additive checksum. Verifying it rejects the corrupted frames you get when the UART is shared or the supply dips, which otherwise appear as wild CO₂ spikes.
pmService() duty cycleThirty seconds of fan every five minutes. The PMS5003 fan is rated for around 8000 hours; this turns under a year of continuous life into roughly a decade.
Atmospheric values at offset 8The PMS5003 frame contains both "standard particle" (CF=1, factory calibration) values at offset 4 and "atmospheric environment" values at offset 8. For indoor air the atmospheric set is the correct one — mixing them up gives readings that are consistently wrong by a fixed ratio.
achUpdate() guardsThe air-change fit only runs during a genuine decay with enough amplitude. Fitting an exponential to noise produces confident nonsense, which is worse than reporting nothing.
if (c > 300 && c < 10000)Physical plausibility check. Outdoor air is about 420 ppm and nothing indoors reaches 10 000 ppm without an emergency, so anything outside that band is a communication error rather than a measurement.

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 co2Outdoor to 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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

Indoor Air Quality Monitor — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsBedroom monitorESP32Kitchen monitoroptional 2ndWi-Fi 2.4 GHzRouterIoT VLANMQTT 1883Mosquitto + InfluxDBlocal PiHome Assistantsix entitiesGrafanaovernight trends
Indoor Air Quality Monitor — network topology
Topic / endpointDirectionPayload
home/air/air-bedroom/statedevice → broker (retained)JSON: co2, pm1, pm25, pm10, pm_valid, temp, rh, hpa, ach
homeassistant/sensor/air-bedroom_*/configdevice → 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.

TestWhat you should see
Power on and wait three minutesCO₂ 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 cmA rise into the thousands within 30 s, decaying back over a minute or two.
Watch a full PM duty cycleFan audibly starts, runs 30 s, PM values update, fan stops. Repeats five minutes later.
Light a match near the PM inletPM2.5 rises sharply and decays over 10–20 minutes.
Close a bedroom door overnight with the monitor insideCO₂ climbing steadily to 1500–3000 ppm by morning — the reading that makes the project worthwhile.
Open the door and window in the morningExponential decay, and an ACH estimate appearing after about ten minutes of decline.
Check Home AssistantSix separate sensor entities appear automatically with correct units and device classes.
Compare BME280 temperature to a referenceWithin 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.

A Grafana time-series dashboard
A time-series dashboard. The overnight CO₂ chart is the single most behaviour-changing output of this project. Photograph sourced from Wikimedia Commons — Grafana dashboard.png. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

CO₂ always reads exactly 400 or 410 ppm indoors

Likely cause. The sensor is still in its warm-up period, or ABC has miscalibrated it after a bad zero calibration.

Fix. Wait three minutes from power-on. If it persists, the sensor has been zero-calibrated in indoor air, which permanently offsets it. Take it genuinely outdoors for 20 minutes and re-run the zero calibration correctly.

CO₂ reads −1 or the value jumps wildly

Likely cause. Failed checksum or a partial UART frame.

Fix. The sketch already validates the checksum and returns −1. Flush the buffer before each request, confirm the sensor has a solid 5 V supply, and verify TX and RX are crossed — MH-Z19B TX goes to ESP32 RX.

PM values are always zero

Likely cause. The fan is not running, or the SET pin logic is inverted.

Fix. Listen for the fan. If silent, check that SET is HIGH for normal operation and that the sensor has 5 V. Some clone modules invert SET; try tying it to 5 V permanently as a test before adding the duty cycling back.

PM readings look plausible but are consistently double or half a reference meter

Likely cause. Reading the wrong set of values from the frame.

Fix. Use the atmospheric-environment values at byte offset 8, not the standard-particle values at offset 4. The two differ by a roughly constant factor, which makes this bug look like a calibration problem.

CO₂ readings drift downward over months

Likely cause. ABC is still enabled and the room never reaches outdoor levels, so the sensor keeps rescaling its baseline.

Fix. Verify the ABC-disable command is being sent after the sensor has finished booting — sending it in the first second is often ignored. Add a three-second delay before the command and confirm it takes effect by checking that readings stop drifting over a fortnight.

Temperature reads two or three degrees high

Likely cause. The MH-Z19B lamp or the buck converter is heating the BME280.

Fix. Physically separate them. The CO₂ sensor is a real heat source. Ventilation slots in the enclosure between the two also help; a software offset is the wrong fix because the error varies with duty cycle.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

An I²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an I²C scanner sketch first — it should print the device address. Most breakout boards include 4.7 kΩ pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

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 a millis() 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_t where 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

Can I use an MQ-135 instead of the MH-Z19B and save ₹2,400?

You can build something, but it will not measure CO₂. The MQ-135 responds to alcohols, ammonia, benzene, smoke and CO₂ with no way to distinguish them, and its output drifts with humidity and temperature. Every "MQ-135 CO₂ in ppm" tutorial applies a formula derived from a datasheet curve that does not represent CO₂ at all. It is a fine "something is different" sensor and a useless CO₂ meter.

What CO₂ level should I actually worry about?

Outdoor is about 420 ppm. Below 800 ppm indoors indicates good ventilation. Above 1000 ppm is the common trigger for "open a window", and there is reasonable evidence of measurable cognitive effects in that range and above. Above 2000 ppm people report stuffiness and headaches. It is not directly toxic until far higher — the number matters because of what it implies about ventilation, not because of the CO₂ itself.

Should I disable ABC or not?

Disable it for a bedroom, a greenhouse, or any space that does not empty regularly. Leave it enabled for an office or classroom that is genuinely unoccupied for several hours most days. With ABC off you must zero-calibrate manually about once a year, which takes twenty minutes outdoors.

Why not combine everything into one AQI number?

Because the correct responses are opposite. High CO₂ means open a window; high outdoor-origin PM2.5 means close it. A composite index that averages them can tell you to do the wrong thing, and it hides which pollutant is actually elevated. Report them separately and let the reader act.

How long does the particulate sensor last?

The fan is rated around 8000 hours, so continuous running gives under a year. With the 30-seconds-in-300 duty cycle used here that becomes roughly a decade of wall-clock time, though the laser diode and the optical chamber will also degrade — plan on about five years in practice.

Is the air-change-rate number trustworthy?

It is a good estimate under the right conditions: a genuine decay, at least 200 ppm above outdoor, and no one entering the room. The sketch checks all three before fitting. It assumes perfect mixing, which a real room does not have, so treat it as accurate to within about 30 % — which is still far more useful than no number at all.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. MH-Z19B intelligent infrared CO₂ module — user manualWinsen
  2. Plantower PMS5003 digital universal particle concentration sensor — manualPlantower / South Coast AQMD
  3. WHO global air quality guidelines 2021 — PM2.5, PM10, NO₂, O₃World Health Organization
  4. Allen et al., "Associations of Cognitive Function Scores with Carbon Dioxide, Ventilation, and VOC Exposures"Environmental Health Perspectives, 2016
  5. ASHRAE Standard 62.1 — ventilation for acceptable indoor air qualityASHRAE
  6. Using CO₂ as a ventilation and infection-risk proxyUS CDC / NIOSH
  7. Beer-Lambert law and NDIR gas measurementWikipedia