IoT Automation

Build an Indoor Air Quality Monitor with CO2 and PM2.5 Sensors and MQTT

Wire an SCD41 CO2 sensor and a PMS5003 particulate sensor to an ESP32, publish readings as JSON over MQTT, and set ventilation alerts when the air gets stale or dusty.

Small indoor air quality monitor with CO2 and PM2.5 sensors on an ESP32 board sending data over Wi-Fi
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

An indoor air quality monitor that measures CO2 and fine particles tells you when a room needs fresh air or filtration long before people feel tired or start coughing. In this tutorial you will build one with an ESP32, a Sensirion SCD41 CO2 sensor and a Plantower PMS5003 particulate sensor, publish the readings as JSON over MQTT, and set ventilation alerts on a ConnectMQ broker.

What the Sensors Measure

  • CO2 (ppm) is the best indicator of ventilation in occupied rooms. Outdoor air is roughly 400 to 450 ppm. Building ventilation guidance often uses values around 800 to 1,000 ppm as a sign that fresh air supply is insufficient for the number of people in the room. The SCD41 is a true photoacoustic NDIR sensor, not an estimate from VOC readings.
  • PM2.5 (micrograms per cubic meter) measures fine particles from cooking, candles, outdoor pollution or construction dust. The PMS5003 uses laser scattering and reports PM1.0, PM2.5 and PM10.
  • Temperature and humidity come free with the SCD41 and give context for comfort.

VOC sensing is a useful extension. A Sensirion SGP41 or a Bosch BME680 adds a VOC index that responds to cleaning products and off-gassing. It is left out of the main build to keep the code short.

Parts and Wiring

Part Pin ESP32 pin Notes
SCD41 VDD 3V3 Breakout boards usually include pull-ups
SCD41 GND GND
SCD41 SDA GPIO 21 I2C data
SCD41 SCL GPIO 22 I2C clock
PMS5003 VCC 5V (VIN) The fan needs 5 V
PMS5003 GND GND
PMS5003 TX GPIO 16 (RX2) Sensor data logic level is 3.3 V
PMS5003 RX GPIO 17 (TX2) Optional, only for commands

Use the Arduino IDE 2.x with the esp32 board package (core 3.x), and install Sensirion I2C SCD4x (version 1.x) and PubSubClient from the Library Manager. The PMS5003 frame is parsed directly in the sketch, so no extra library is needed.

Place the monitor at breathing height, away from windows, doors and supply diffusers, and keep the PMS5003 inlet unobstructed. A sensor next to an open window measures the outdoors, not the room.

The Complete Sketch

#include <WiFi.h>
#include <Wire.h>
#include <PubSubClient.h>
#include <SensirionI2cScd4x.h>

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";

// Public test broker. For production use 8883 + WiFiClientSecure and portal credentials.
const char* BROKER = "broker.connectmq.com";
const int   PORT   = 1883;
const char* TOPIC  = "connectmq/demo/iaq-3hv9/office-201/air";

const unsigned long PUBLISH_MS = 30000;

WiFiClient net;
PubSubClient mqtt(net);
SensirionI2cScd4x scd4x;
HardwareSerial pmsSerial(2);

uint16_t co2 = 0;
float tempC = NAN, rh = NAN;
uint16_t pm1 = 0, pm25 = 0, pm10 = 0;
bool pmValid = false;
unsigned long lastPublish = 0;

// Read one 32-byte PMS5003 frame: 0x42 0x4D, length, 13 data words, checksum
bool readPms() {
  static uint8_t buf[32];
  while (pmsSerial.available() >= 32) {
    if (pmsSerial.peek() != 0x42) { pmsSerial.read(); continue; }
    pmsSerial.readBytes(buf, 32);
    if (buf[1] != 0x4D) continue;
    uint16_t sum = 0;
    for (int i = 0; i < 30; i++) sum += buf[i];
    uint16_t check = (buf[30] << 8) | buf[31];
    if (sum != check) continue;
    // "atmospheric environment" values
    pm1  = (buf[10] << 8) | buf[11];
    pm25 = (buf[12] << 8) | buf[13];
    pm10 = (buf[14] << 8) | buf[15];
    return true;
  }
  return false;
}

void connectWifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(250);
}

void connectMqtt() {
  while (!mqtt.connected()) {
    String id = "iaq-" + String((uint32_t)ESP.getEfuseMac(), HEX);
    if (!mqtt.connect(id.c_str())) delay(3000);
  }
}

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  pmsSerial.begin(9600, SERIAL_8N1, 16, 17);

  scd4x.begin(Wire, SCD41_I2C_ADDR_62);
  scd4x.stopPeriodicMeasurement();      // in case it was running after a soft reset
  delay(500);
  if (scd4x.startPeriodicMeasurement() != 0) {
    Serial.println("SCD41 not found, check wiring");
  }

  connectWifi();
  mqtt.setServer(BROKER, PORT);
  mqtt.setBufferSize(512);
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) connectWifi();
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();

  if (readPms()) pmValid = true;

  bool ready = false;
  if (scd4x.getDataReadyStatus(ready) == 0 && ready) {
    uint16_t c; float t, h;
    if (scd4x.readMeasurement(c, t, h) == 0 && c != 0) {
      co2 = c; tempC = t; rh = h;
    }
  }

  if (millis() - lastPublish >= PUBLISH_MS && co2 > 0) {
    lastPublish = millis();
    char msg[256];
    snprintf(msg, sizeof(msg),
      "{\"co2_ppm\":%u,\"temp_c\":%.1f,\"rh\":%.1f,\"pm1\":%u,\"pm25\":%u,\"pm10\":%u,\"pm_valid\":%s,\"rssi\":%d}",
      co2, tempC, rh, pm1, pm25, pm10, pmValid ? "true" : "false", WiFi.RSSI());
    mqtt.publish(TOPIC, msg);
    Serial.println(msg);
  }
}

The SCD41 produces a new measurement every five seconds in periodic mode, and the PMS5003 streams frames roughly once per second. The sketch keeps the latest values and publishes every 30 seconds, which is more than enough for room air quality.

Watch the Data Arrive

Subscribe from a terminal:

mosquitto_sub -h broker.connectmq.com -p 1883 -i iaq-view-3hv9 -t "connectmq/demo/iaq-3hv9/#" -v

You should see messages like:

connectmq/demo/iaq-3hv9/office-201/air {"co2_ppm":742,"temp_c":23.4,"rh":41.8,"pm1":3,"pm25":6,"pm10":7,"pm_valid":true,"rssi":-58}

Breathe gently near the SCD41 and the CO2 value climbs within a minute. Light a match nearby and blow it out, and PM2.5 spikes. Both are quick sanity checks that the sensors and the parser work. The free public MQTT broker is ideal for this stage because you need no account.

Moving to a Private Broker over TLS

Room air data reveals when people are present, so production monitors should use a private broker. Create a broker in the ConnectMQ portal and change the connection code:

#include <WiFiClientSecure.h>

WiFiClientSecure net;              // replaces WiFiClient
const int PORT = 8883;

// in setup(), before mqtt.setServer():
// Load the ISRG Root X1 (Let's Encrypt) certificate for full validation:
// net.setCACert(ISRG_ROOT_X1_PEM);

// in connectMqtt():
// mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD");

Use the client ID exactly as issued, because ConnectMQ binds client IDs to credentials. Our ESP32 MQTT tutorial explains certificate handling on the ESP32 in more detail.

Ventilation Alerts

Set threshold alert rules in the ConnectMQ portal on the co2_ppm and pm25 fields. Suggested starting points, which you should adapt to local guidance and the room's use:

Level CO2 PM2.5 Suggested action
Good below 800 ppm below 12 Nothing to do
Moderate 800 to 1,200 ppm 12 to 35 Increase ventilation, check filter
Poor above 1,200 ppm above 35 Open windows or boost ventilation, find the particle source

Rules fire on each value that crosses the threshold, and the alerts log keeps the history so you can see which rooms are regularly stuffy at certain times. To drive a fan or a damper automatically, subscribe to the air topic from a Node-RED flow or a Home Assistant automation and switch the actuator through its own MQTT command topic. The Home Assistant MQTT integration makes the monitor appear as sensor entities with a few lines of YAML.

Reading the Data Over Time

Single readings are less informative than daily patterns. In a typical meeting room, CO2 climbs steadily during each meeting and falls back once the room empties; how fast it falls tells you how well the room is ventilated. A room that is still above 1,000 ppm an hour after everyone left has a ventilation problem, not an occupancy problem. PM2.5, in contrast, usually stays low indoors and shows short spikes from cooking, printing, cleaning or outdoor events such as traffic peaks and smoke.

To keep this history, run a subscriber that writes each message to a time-series database, or let Telegraf subscribe to the air topics. Compare rooms side by side and look at the worst hour of each day rather than the average, because averages hide the moments when people actually feel the air is stale. Sharing a simple weekly chart with the facilities team is often enough to get a ventilation schedule changed.

If you run several monitors, name each topic after the room rather than the device, so a replaced board publishes to the same place and the history stays continuous.

Calibration and Accuracy Tips

  1. CO2 self-calibration. The SCD41 has automatic self-calibration enabled by default, which assumes the sensor sees fresh outdoor-level air regularly. In rooms that are never fully ventilated, disable it and perform a forced recalibration outdoors instead.
  2. Warm-up. Ignore the first few minutes of readings after power-up.
  3. Self-heating. Keep the ESP32 and any voltage regulator away from the SCD41, or the temperature reading will be high.
  4. Particle sensor lifetime. The PMS5003 fan and laser wear out. For long deployments, put it to sleep between readings using its SET pin.
  5. Humidity and PM. Optical particle sensors over-read in very humid air because water droplets scatter light. Treat PM readings above about 80 percent relative humidity with caution.

Scaling to a Whole Building

One monitor per meeting room or open-plan zone is a good density. Each device needs its own credentials and a topic under the building hierarchy, for example acme/ist01/hq/f03/meeting-301/air. A pilot of a dozen monitors plus a dashboard fits within the free Serverless plan's 25 connections. Combining air quality with office occupancy sensors and HVAC monitoring shows whether stale air comes from too many people or too little ventilation. A hosted MQTT broker removes the need to run any server in the building, and the IoT automation guide lists more projects to build on the same broker.

Get Started with ConnectMQ

Create a free Serverless broker at app.connectmq.com, no credit card required. Flash the sketch with your credentials, open the live topic explorer and set your first CO2 alert. ConnectMQ takes care of TLS, authentication and uptime.

Frequently Asked Questions

What CO2 level means a room needs more ventilation?

Many ventilation guidelines treat sustained values above roughly 1,000 ppm as a sign of insufficient fresh air for the occupancy. Use your local guidance and watch trends rather than single readings.

Can a VOC sensor replace a real CO2 sensor?

No. Sensors that report "equivalent CO2" estimate it from VOCs and can be far off. Use an NDIR or photoacoustic sensor like the SCD41 for real CO2.

How often should the indoor air quality monitor publish?

Every 30 to 60 seconds is plenty. Air quality changes over minutes, and less frequent publishing saves power and broker traffic.

Does the PMS5003 need 5 V?

Its fan needs 5 V supply, but its serial output is 3.3 V logic, so the TX pin can connect directly to an ESP32 input.

Related articles

Zigbee2MQTT Setup Guide: From Coordinator to Dashboard

Install Zigbee2MQTT with Docker, configure the coordinator and MQTT connection, pair and rename devices, control them over MQTT and view their states on a dashboard, testing first on a free public broker.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.