Air Quality Dashboard Intermediate

Monitor indoor air quality with the SGP30 sensor and display on a web dashboard

🔗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:

ComponentQtyNotesBuy
ESP32 dev board1AliExpress | Amazon.de .co.uk .com
SGP30 air quality sensor module1Breakout board with I2C interfaceAliExpress | Amazon.de .co.uk .com
Breadboard1AliExpress | Amazon.de .co.uk .com
Jumper wires4AliExpress | 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):

LibraryAuthorPurpose
Adafruit SGP30 SensorAdafruitDriver for the SGP30
Adafruit BusIOAdafruitI2C abstraction (dependency)

🔗Tutorial

🔗Step 1: Wiring

The SGP30 communicates over I2C, so it only needs four wires:

SGP30 PinESP32 PinNotes
VCC (VIN)3.3VSome modules accept 3.3V–5V; check yours
GNDGND
SDAGPIO 21Default I2C data line
SCLGPIO 22Default 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

  1. Upload the sketch and open the Serial Monitor at 115200 baud.
  2. Wait for the ESP32 to connect to WiFi. It will print something like:
    Connected! IP address: 192.168.1.42
  3. 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).
  4. 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 ColorAir QualityAction
< 1000GreenGoodNormal indoor air
1000 – 2000YellowModerateConsider opening a window
> 2000RedPoorVentilate 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

ProblemCauseFix
"SGP30 not found" error on bootWiring issue or wrong I2C addressCheck 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 ppmSensor still in baseline calibrationLet it run for 12+ hours continuously. This is normal startup behavior
Readings seem inaccurateBaseline not established or sensor near heat sourceAllow full 12-hour calibration. Keep sensor away from direct heat or exhaust
Cannot access web pageESP32 not on same network, or IP changedCheck Serial Monitor for current IP. Ensure your device is on the same WiFi network
Page loads but shows old dataBrowser cachingHard-refresh the page (Ctrl+Shift+R) or clear cache
WiFi keeps disconnectingWeak signal or power supply issueMove ESP32 closer to router. Use a quality USB cable and power source
TVOC always reads 0Clean air or sensor warming upThis is normal in well-ventilated rooms. Try breathing near the sensor briefly to verify it responds