How to Build a CO2 Room Monitor with ESP32
Measure true room CO2 with an SCD41 and show ventilation warnings on an OLED
Updated

What you'll build
This guide takes you through building a small room CO2 monitor with an ESP32, an Adafruit SCD41 true CO2 sensor, and a compact SSD1306 OLED display. The SCD41 measures carbon dioxide directly instead of estimating it from VOCs, then the OLED shows live CO2 in ppm alongside temperature, humidity, and a plain ventilation warning when the room starts getting stale. Both modules share the same I2C bus on GPIO21 and GPIO22, so the wiring stays simple: power, ground, SDA, and SCL for each board.
The firmware reads the SCD41 every five seconds and uses two thresholds to make the display useful without turning the build into a full dashboard. Below 1000 ppm, it reports that the air looks good. From 1000 ppm, it says the air is getting stale. From 1500 ppm, it switches to a stronger ventilation prompt. Those values are defined as CO2_WARN_PPM and CO2_ALERT_PPM, so you can tune them once you understand your own room. The SCD41 breakout can run from 3.3V to 5V, but this ESP32 build uses the 3V3 rail and short wiring because quiet power matters for stable readings.
By the end of the build you will have a desk-sized indoor air monitor that reacts visibly when CO2 rises, then settles again after fresh air reaches the sensor. If you want a simpler environmental starter first, try the ESP32 air quality monitor; if you want an outdoor-data version, the ESP32 weather station is the natural companion.
What you are building
This build has a specific scope. Knowing what is and is not included helps you decide whether to extend it later.
- Read CO2, temperature, and humidity from the SCD41 over I2C every five seconds.
- Show a countdown warm-up screen on the OLED for roughly two minutes while the SCD41 reaches thermal equilibrium.
- Display live CO2 ppm, temperature, and humidity on the SSD1306 OLED once warm-up completes.
- Apply two configurable thresholds (
CO2_WARN_PPMandCO2_ALERT_PPM) to show a plain-text ventilation status line on the display. - Print the same readings to Serial Monitor at 115200 baud for debugging and logging.
Out of scope: Wi-Fi, cloud logging, alerts to a phone, data history, calibration offsets, and battery power management. These are reasonable extensions but are not part of this guide.
Upload and calibrate
Install libraries in the Arduino IDE Library Manager before opening the sketch:
Sensirion I2C SCD4x(by Sensirion; install command:sensirion/Sensirion I2C SCD4x)Adafruit SSD1306Adafruit GFX Library
Pin and threshold constants are near the top of the sketch:
#define SDA_PIN 21
#define SCL_PIN 22
#define CO2_WARN_PPM 1000
#define CO2_ALERT_PPM 1500
#define WARMUP_MS 120000 // ~2 minutes
Change CO2_WARN_PPM and CO2_ALERT_PPM to suit your space before uploading. Offices and bedrooms often warrant a lower warn threshold than the defaults.
Upload: select your ESP32 board and port, then click Upload. Open the Serial Monitor at 115200 baud.
Expected Serial Monitor output:
SCD41 started. Warming up (~2 min)...
CO2: 412 ppm Temp: 22.3 C Hum: 48.1 %
CO2: 415 ppm Temp: 22.3 C Hum: 48.0 %
During warm-up the OLED shows a countdown screen rather than live readings. Once WARMUP_MS elapses, readings begin appearing on the display and in Serial Monitor every five seconds. Fresh outdoor air is roughly 420 ppm; a well-ventilated room typically sits between 600 and 900 ppm. If your first readings are wildly high (above 2000 ppm with the window open), check that you have not swapped SDA and SCL.
Troubleshooting
- OLED stays blank after upload. Check VCC is connected to 3V3 and that SDA/SCL are on GPIO 21 and GPIO 22. Use an I2C scanner sketch to confirm the OLED responds at address 0x3C.
- Serial Monitor shows garbled characters. The baud rate is not set to 115200. Change it in the Serial Monitor dropdown and press the reset button on the ESP32.
- CO2 readings never appear; Serial shows "SCD41 error" or similar. The SCD41 was not found at 0x62. Verify VIN is on 3V3 (not 5V), and check that SDA and SCL wires are not swapped or loose. Re-run an I2C scanner to confirm the address.
- CO2 value is stuck or jumps erratically. Marginal power or a long I2C wire is the usual cause. Shorten the wires and move the SCD41 further from any heat source on the board, which can bias the on-chip temperature compensation.
- Display shows "Stale air" immediately after warm-up. The SCD41 has a factory calibration offset and may read high for the first few minutes in a new environment. Let it run for ten minutes in a ventilated space; readings normally settle. If high readings persist in fresh air, the sensor may need a forced recalibration — see the Sensirion application note for the
performForcedRecalibrationcommand. - WARMUP_MS countdown looks wrong on the OLED. If the displayed countdown and the actual elapsed time differ noticeably, the ESP32 clock source may be misconfigured for your board variant. Check that the correct board is selected in Tools → Board.
Going further
Once the basic build is running, the most useful extension is adding Wi-Fi logging. The ESP32's built-in Wi-Fi lets you push readings to a home assistant instance, an InfluxDB container, or a simple HTTP endpoint every few seconds without changing the sensor or display code. Because the readings already come as plain numeric variables, wrapping them in an HTTP POST takes about twenty extra lines.
A second direction is improving the SCD41's accuracy in your specific environment. The sensor supports automatic self-calibration (ASC), which works by assuming the lowest CO2 reading over a two-week period represents fresh outdoor air. If your ESP32 is always indoors, ASC may drift high over time; in that case, a periodic forced recalibration against a known-good outdoor reading gives steadier long-term results. The Sensirion SCD4x datasheet covers both modes in detail.
Wiring diagram
Components needed
| Component | Type | Qty | Buy |
|---|---|---|---|
| Adafruit SCD-41 True CO2, Temperature & Humidity Sensor | sensor | 1 | $49.95 |
| Grove OLED Display 0.66" (SSD1306) | display | 1 | $5.50 |
Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.
Assembly
Place the components on a breadboard
Place the ESP32 in the centre of a half-size breadboard, then seat the SCD41 and OLED on either side so their header pins reach the breadboard rails. Keep the SCD41 away from the ESP32's onboard voltage regulator — waste heat from the chip can skew CO2 readings over time.
- Shorter breadboard wiring means less chance of I2C noise. Aim for direct pin-to-pin jumper runs, not long loops.
Wire the shared I2C bus
Connect SCD41 SDA and OLED SDA together, then run a single jumper from that junction to GPIO21 on the ESP32. Do the same for SCL: SCD41 SCL and OLED SCL to GPIO22. Both devices share the bus because their I2C addresses are different — the SCD41 answers at 0x62 and the OLED at 0x3C.
- Keep SDA and SCL wires roughly the same length and away from power wires to reduce crosstalk.
Wire power and ground
Connect the SCD41 VIN and the OLED VCC to the ESP32 3V3 pin. Connect both GND pins to ESP32 GND. The SCD41 breakout accepts 3.3–5 V, so 3V3 is safe here — just keep the wiring short so the supply stays clean.
- A 10 µF capacitor across the 3V3 and GND rails near the SCD41 can improve reading stability if you notice erratic values.
- Do not power the SCD41 from the 5 V VBUS pin on the ESP32 if you are sharing the I2C bus with the 3.3 V OLED — they are level-compatible but uneven supply can cause resets.
Upload the firmware
Open the project in your IDE, confirm the board is set to ESP32 Dev Module, and upload. Open the serial monitor at 115 200 baud. You should see "SCD41 started. Warming up (~2 min)" followed by CO2 readings every five seconds. If the serial output shows "SCD41 not found", re-check the SDA/SCL wires on GPIO21 and GPIO22.
- The firmware uses the Sensirion I2C SCD4x library — install it from the PlatformIO registry (sensirion/Sensirion I2C SCD4x) or the Arduino Library Manager before building.
Wait for the warm-up period and test
The OLED shows a countdown while the SCD41 stabilises. After roughly two minutes, live CO2, temperature, and humidity readings appear. To confirm the sensor responds, exhale gently near the SCD41 — the ppm value should climb within two to three readings, then the status line will change from "Air is fresh" to "Getting stale - crack a window" once it crosses 1000 ppm. Open a window to watch the value drop back down.
- CO2_WARN_PPM (1000) and CO2_ALERT_PPM (1500) are defined at the top of the sketch — edit them to match your room's normal baseline before deploying.
- Outdoor air is roughly 420 ppm. A freshly ventilated room below 600 ppm is very good; most offices hit 800–1200 ppm by mid-afternoon.
- Do not breathe directly onto the SCD41 during calibration — moisture from breath can temporarily saturate the humidity sensor and skew the first few CO2 readings.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | scd41-1 VIN | POWER |
| GND | scd41-1 GND | GROUND |
| GPIO 21 | scd41-1 SDA | I2C |
| GPIO 22 | scd41-1 SCL | I2C |
| 3V3 | oled-1 VCC | POWER |
| GND | oled-1 GND | GROUND |
| GPIO 21 | oled-1 SDA | I2C |
| GPIO 22 | oled-1 SCL | I2C |
Code
/*
* CO2 Room Monitor
* ESP32 + SCD41 (Sensirion I2C SCD4x driver) + SSD1306 OLED
* Shared I2C bus on GPIO21 (SDA) / GPIO22 (SCL)
*
* Reads CO2, temperature, and humidity every 5 seconds.
* Three status levels:
* < CO2_WARN_PPM → "Air looks good"
* >= CO2_WARN_PPM → "Air getting stale"
* >= CO2_ALERT_PPM → "OPEN A WINDOW" (inverted highlight)
*
* SCD41 needs ~2 minutes to stabilise after power-on;
* a warm-up screen is shown until valid data arrives.
*/
#include <Wire.h>
#include <SensirionI2cScd4x.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// ── Pin definitions (must match pin_definitions.json) ──────────────────────
#define SDA_PIN 21
#define SCL_PIN 22
// ── Display ────────────────────────────────────────────────────────────────
#define OLED_ADDRESS 0x3C
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
// ── CO2 thresholds (ppm) — edit these for your space ──────────────────────
#define CO2_WARN_PPM 1000 // "stale" warning starts here
#define CO2_ALERT_PPM 1500 // stronger ventilation prompt
// ── Timing ─────────────────────────────────────────────────────────────────
#define READ_INTERVAL_MS 5000UL // poll every 5 s (SCD41 cycle is 5 s)
#define WARMUP_MS 120000UL // 2-minute sensor warm-up period
// ── Objects ────────────────────────────────────────────────────────────────
SensirionI2cScd4x sensor;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// ── State ──────────────────────────────────────────────────────────────────
uint16_t co2 = 0;
float tempC = 0.0f;
float humidity = 0.0f;
bool haveReading = false;
unsigned long lastReadMs = 0;
// ── Forward declarations ───────────────────────────────────────────────────
void drawWarmup();
void drawMonitorScreen();
void haltWithMessage(const char* serial_msg, const char* line1, const char* line2);
// ───────────────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(100);
Serial.println("CO2 Room Monitor starting...");
Wire.begin(SDA_PIN, SCL_PIN);
// ── Init OLED ─────────────────────────────────────────────────────────
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
// Can't show anything — just report on Serial and halt
Serial.println("SSD1306 not found. Check OLED wiring and I2C address 0x3C.");
while (true) delay(1000);
}
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.println("Starting CO2 sensor...");
display.display();
// ── Init SCD41 ────────────────────────────────────────────────────────
sensor.begin(Wire, SCD41_I2C_ADDR_62);
// Stop any in-progress measurement before reconfiguring
uint16_t err = sensor.stopPeriodicMeasurement();
if (err) {
// Non-fatal: sensor may have just powered on with no active measurement
Serial.print("stopPeriodicMeasurement warning: ");
Serial.println(err);
}
delay(500); // brief settle after stop
err = sensor.startPeriodicMeasurement();
if (err) {
char msg[64];
snprintf(msg, sizeof(msg),
"SCD41 not found — check wiring on GPIO%d/%d (err %u)",
SDA_PIN, SCL_PIN, err);
haltWithMessage(msg, "SCD41 not found", "Check I2C wiring");
}
Serial.println("SCD41 started. Warming up (~2 min)...");
drawWarmup();
}
void loop() {
unsigned long now = millis();
// Show warm-up screen until the 2-minute stabilisation period is over
// and we have at least one valid reading.
if (!haveReading && now < WARMUP_MS) {
drawWarmup();
delay(1000);
return;
}
// Poll on 5-second interval
if (now - lastReadMs < READ_INTERVAL_MS) {
return;
}
lastReadMs = now;
// Check whether a new measurement is ready
bool dataReady = false;
uint16_t err = sensor.getDataReadyStatus(dataReady);
if (err) {
Serial.print("getDataReadyStatus error: ");
Serial.println(err);
return;
}
if (!dataReady) {
return; // not ready yet — wait for the next poll
}
uint16_t newCo2 = 0;
float newTemp = 0.0f;
float newHumid = 0.0f;
err = sensor.readMeasurement(newCo2, newTemp, newHumid);
if (err) {
Serial.print("readMeasurement error: ");
Serial.println(err);
return;
}
// SCD41 returns 0 ppm if calibration hasn't finished; skip it
if (newCo2 == 0) {
return;
}
co2 = newCo2;
tempC = newTemp;
humidity = newHumid;
haveReading = true;
Serial.print("CO2: ");
Serial.print(co2);
Serial.print(" ppm Temp: ");
Serial.print(tempC, 1);
Serial.print(" C Humidity: ");
Serial.print(humidity, 0);
Serial.println(" %RH");
drawMonitorScreen();
}
// ── Display helpers ─────────────────────────────────────────────────────────
void drawWarmup() {
unsigned long elapsed = millis() / 1000;
int remaining = max(0L, (long)(WARMUP_MS / 1000) - (long)elapsed);
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.println("Room CO2 monitor");
display.drawLine(0, 11, 127, 11, SSD1306_WHITE);
display.setCursor(0, 18);
display.println("Sensor warming up...");
display.setCursor(0, 34);
display.print("Ready in ~");
display.print(remaining);
display.println(" s");
display.setCursor(0, 50);
display.setTextSize(1);
display.println("Keep room aired out");
display.display();
}
void drawMonitorScreen() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// ── Header ──────────────────────────────────────────────────────────
display.setTextSize(1);
display.setCursor(0, 0);
display.println("Room CO2 monitor");
display.drawLine(0, 11, 127, 11, SSD1306_WHITE);
// ── CO2 reading (large) ─────────────────────────────────────────────
display.setTextSize(2);
display.setCursor(0, 14);
display.print(co2);
display.println(" ppm");
// ── Temp + humidity (small) ──────────────────────────────────────────
display.setTextSize(1);
display.setCursor(0, 38);
display.print(tempC, 1);
display.print((char)247); // degree symbol
display.print("C ");
display.print(humidity, 0);
display.println("%RH");
// ── Status line ──────────────────────────────────────────────────────
display.setCursor(0, 54);
if (co2 >= CO2_ALERT_PPM) {
// Inverted highlight to grab attention
display.fillRect(0, 52, 128, 12, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
display.setCursor(4, 54);
display.print("Open a window now!");
display.setTextColor(SSD1306_WHITE);
} else if (co2 >= CO2_WARN_PPM) {
display.print("Getting stale - crack a window");
} else {
display.print("Air is fresh");
}
display.display();
}
/*
* Show an error on both Serial and the OLED, then halt.
* line1 / line2: short OLED strings (<=21 chars each).
*/
void haltWithMessage(const char* serial_msg,
const char* line1,
const char* line2) {
Serial.println(serial_msg);
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.println(line1);
display.setCursor(0, 14);
display.println(line2);
display.display();
while (true) delay(1000);
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the CO2 Room Monitor.
Open in Schematik →