🔗Goal
Build an indoor air quality monitor that measures eCO2 (equivalent carbon dioxide) and TVOC (total volatile organic compounds) using the SGP30 sensor, then displays live readings on a color-coded web dashboard hosted by the ESP32. You can check air quality from any device on your WiFi network — phone, tablet, or computer.
Here is how the system works at a high level:
graph LR
A[SGP30 Sensor] -->|I2C| B[ESP32]
B -->|WiFi| C[Web Server :80]
C -->|HTTP| D[Browser / Phone]The SGP30 sensor communicates over I2C and returns two key measurements:
- eCO2 — equivalent CO2 concentration in parts per million (ppm). Normal outdoor air is around $400\,\text{ppm}$.
- TVOC — total volatile organic compounds in parts per billion (ppb). Clean air reads close to $0\,\text{ppb}$.
The ESP32 reads these values every 15 seconds, hosts a simple web page on port 80, and the page auto-refreshes to show the latest data with a color-coded background indicating air quality.
🔗Prerequisites
You will need the following components:
| Component | Qty | Notes | Buy |
|---|---|---|---|
| ESP32 dev board | 1 | AliExpress | Amazon.de .co.uk .com | |
| SGP30 air quality sensor module | 1 | Breakout board with I2C interface | AliExpress | Amazon.de .co.uk .com |
| Breadboard | 1 | AliExpress | Amazon.de .co.uk .com | |
| Jumper wires | 4 | AliExpress | Amazon.de .co.uk .com |
Links marked Amazon/AliExpress are affiliate links. We may earn a small commission at no extra cost to you.
Arduino IDE libraries (install via Library Manager):
| Library | Author | Purpose |
|---|---|---|
| Adafruit SGP30 Sensor | Adafruit | Driver for the SGP30 |
| Adafruit BusIO | Adafruit | I2C abstraction (dependency) |
🔗Tutorial
🔗Step 1: Wiring
The SGP30 communicates over I2C, so it only needs four wires:
| SGP30 Pin | ESP32 Pin | Notes |
|---|---|---|
| VCC (VIN) | 3.3V | Some modules accept 3.3V–5V; check yours |
| GND | GND | |
| SDA | GPIO 21 | Default I2C data line |
| SCL | GPIO 22 | Default I2C clock line |
Pin labels and GPIO numbers vary between ESP32 boards. Always check your board's pinout diagram. GPIO 21 and GPIO 22 are the default I2C pins on most ESP32-WROOM-32 DevKits.
🔗Step 2: Upload the code
This sketch connects to your WiFi network, initializes the SGP30 sensor, and serves a web dashboard with auto-refreshing readings.
Replace YOUR_SSID and YOUR_PASSWORD with your WiFi credentials before uploading.
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include "Adafruit_SGP30.h"
// ---- Configuration ----
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";
// Reading interval in milliseconds (15 seconds)
const unsigned long READ_INTERVAL = 15000;
// ---- Globals ----
Adafruit_SGP30 sgp;
WebServer server(80);
uint16_t eco2Value = 400;
uint16_t tvocValue = 0;
unsigned long lastReadTime = 0;
// Returns a CSS color based on eCO2 level
String getQualityColor(uint16_t eco2) {
if (eco2 < 1000) return "#4CAF50"; // Green — good
else if (eco2 < 2000) return "#FFC107"; // Yellow — moderate
else return "#F44336"; // Red — poor
}
// Returns a text label based on eCO2 level
String getQualityLabel(uint16_t eco2) {
if (eco2 < 1000) return "Good";
else if (eco2 < 2000) return "Moderate";
else return "Poor";
}
void handleRoot() {
String color = getQualityColor(eco2Value);
String label = getQualityLabel(eco2Value);
String html = "<!DOCTYPE html><html><head>";
html += "<meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1.0'>";
html += "<meta http-equiv='refresh' content='15'>";
html += "<title>Air Quality Dashboard</title>";
html += "<style>";
html += "body { font-family: Arial, sans-serif; margin: 0; padding: 20px; ";
html += "background-color: " + color + "; color: #fff; ";
html += "display: flex; flex-direction: column; align-items: center; ";
html += "justify-content: center; min-height: 100vh; box-sizing: border-box; }";
html += "h1 { font-size: 2em; margin-bottom: 10px; }";
html += ".status { font-size: 1.4em; margin-bottom: 30px; opacity: 0.9; }";
html += ".card { background: rgba(255,255,255,0.2); border-radius: 12px; ";
html += "padding: 20px 40px; margin: 10px; text-align: center; min-width: 200px; }";
html += ".value { font-size: 3em; font-weight: bold; }";
html += ".label { font-size: 1em; opacity: 0.8; margin-top: 5px; }";
html += ".cards { display: flex; flex-wrap: wrap; justify-content: center; }";
html += ".footer { margin-top: 30px; font-size: 0.85em; opacity: 0.7; }";
html += "</style></head><body>";
html += "<h1>Air Quality Dashboard</h1>";
html += "<div class='status'>Status: " + label + "</div>";
html += "<div class='cards'>";
html += "<div class='card'><div class='value'>" + String(eco2Value) + "</div>";
html += "<div class='label'>eCO2 (ppm)</div></div>";
html += "<div class='card'><div class='value'>" + String(tvocValue) + "</div>";
html += "<div class='label'>TVOC (ppb)</div></div>";
html += "</div>";
html += "<div class='footer'>Auto-refreshes every 15 seconds</div>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void setup() {
Serial.begin(115200);
delay(100);
// Initialize I2C and SGP30
Wire.begin();
if (!sgp.begin()) {
Serial.println("ERROR: SGP30 not found. Check wiring.");
while (1) { delay(1000); }
}
Serial.print("SGP30 serial #: ");
Serial.print(sgp.serialnumber[0], HEX);
Serial.print(sgp.serialnumber[1], HEX);
Serial.println(sgp.serialnumber[2], HEX);
// Connect to WiFi
Serial.print("Connecting to WiFi");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected! IP address: ");
Serial.println(WiFi.localIP());
// Start web server
server.on("/", handleRoot);
server.begin();
Serial.println("Web server started on port 80");
}
void loop() {
server.handleClient();
// Read sensor at the specified interval
if (millis() - lastReadTime >= READ_INTERVAL) {
lastReadTime = millis();
if (sgp.IAQmeasure()) {
eco2Value = sgp.eCO2;
tvocValue = sgp.TVOC;
Serial.print("eCO2: ");
Serial.print(eco2Value);
Serial.print(" ppm | TVOC: ");
Serial.print(tvocValue);
Serial.println(" ppb");
} else {
Serial.println("SGP30 measurement failed");
}
}
}🔗Step 3: Open the dashboard
- Upload the sketch and open the Serial Monitor at 115200 baud.
- Wait for the ESP32 to connect to WiFi. It will print something like:
Connected! IP address: 192.168.1.42 - Open a web browser on any device connected to the same WiFi network and navigate to that IP address (for example,
http://192.168.1.42). - You should see the air quality dashboard with live eCO2 and TVOC readings. The page auto-refreshes every 15 seconds.
To access the dashboard from your phone, make sure your phone is on the same WiFi network as the ESP32 and type the IP address into your phone's browser.
🔗Step 4: Understanding the readings
The SGP30 reports eCO2 and TVOC. Here is what the eCO2 ranges mean:
| eCO2 (ppm) | Dashboard Color | Air Quality | Action |
|---|---|---|---|
| < 1000 | Green | Good | Normal indoor air |
| 1000 – 2000 | Yellow | Moderate | Consider opening a window |
| > 2000 | Red | Poor | Ventilate the room |
For TVOC, values below $200\,\text{ppb}$ are generally considered acceptable for indoor environments. Elevated TVOC can come from cleaning products, paints, cooking, or poor ventilation.
🔗Step 5: Baseline calibration
The SGP30 uses an internal algorithm that needs about 12 hours of continuous operation to establish an accurate baseline. During this period, readings may be less accurate.
For best results, let the sensor run continuously for at least 12 hours in a well-ventilated room before trusting the readings. After that initial calibration, the sensor adjusts its baseline automatically.
The Adafruit SGP30 library provides getIAQBaseline() and setIAQBaseline() functions. For a more robust setup, you can save the baseline values to EEPROM or SPIFFS and restore them on startup to avoid repeating the 12-hour calibration after every reboot. This is optional but recommended for long-term use.
🔗Common Issues and Solutions
| Problem | Cause | Fix |
|---|---|---|
| "SGP30 not found" error on boot | Wiring issue or wrong I2C address | Check SDA→GPIO 21, SCL→GPIO 22. Ensure module is powered (3.3V). Run an I2C scanner sketch to verify the address (default 0x58) |
| eCO2 stuck at 400 ppm | Sensor still in baseline calibration | Let it run for 12+ hours continuously. This is normal startup behavior |
| Readings seem inaccurate | Baseline not established or sensor near heat source | Allow full 12-hour calibration. Keep sensor away from direct heat or exhaust |
| Cannot access web page | ESP32 not on same network, or IP changed | Check Serial Monitor for current IP. Ensure your device is on the same WiFi network |
| Page loads but shows old data | Browser caching | Hard-refresh the page (Ctrl+Shift+R) or clear cache |
| WiFi keeps disconnecting | Weak signal or power supply issue | Move ESP32 closer to router. Use a quality USB cable and power source |
| TVOC always reads 0 | Clean air or sensor warming up | This is normal in well-ventilated rooms. Try breathing near the sensor briefly to verify it responds |