📊 What You'll Build: A professional-grade portable air quality monitor that detects PM2.5, PM10, CO2, VOCs, temperature, and humidity — all for under $35!
📑 Table of Contents
- 1. Why Air Quality Monitoring Matters in 2026
- 2. Components & Cost Breakdown
- 3. Circuit Wiring Diagram
- 4. Arduino IDE Setup
- 5. Complete Code
- 6. Understanding AQI Levels
- 7. WiFi Data Logging
- 8. 3D Printed Enclosure
- 9. Calibration Tips
- 10. Troubleshooting
1. Why Air Quality Monitoring Matters in 2026
Indoor air quality has become a critical health concern. According to the WHO, indoor air can be up to 5 times more polluted than outdoor air. With rising concerns about:
- 🏭 Urban pollution and wildfire smoke
- 🦠 Post-pandemic ventilation awareness
- 🏠 VOC emissions from furniture and cleaning products
- 💨 CO2 buildup in poorly ventilated spaces
Having a personal air quality monitor is no longer a luxury — it's a health necessity.
⚠️ Health Alert: Long-term exposure to PM2.5 particles has been linked to respiratory diseases, cardiovascular problems, and cognitive decline. Monitoring is your first line of defense!
2. Components & Cost Breakdown
| Component | Model | Purpose | Price |
|---|---|---|---|
| Microcontroller | ESP32 DevKit V1 | Main processor with WiFi | $6 |
| Particle Sensor | PMS7003 | PM1.0, PM2.5, PM10 detection | $12 |
| Environmental Sensor | BME680 | CO2 (IAQ), VOC, Temp, Humidity | $9 |
| Display | 1.3" OLED SH1106 | Real-time data visualization | $4 |
| Battery | 18650 Li-ion + TP4056 | Portable power | $3 |
| Misc | Wires, switch, case | Assembly | $1 |
💰 Total Cost: ~$35 (vs. $200-500 for commercial equivalents like AirVisual Pro)
3. Circuit Wiring Diagram
PMS7003 Connection (UART):
- VCC → 5V (ESP32 VIN)
- GND → GND
- TX → GPIO16 (ESP32 RX2)
- RX → GPIO17 (ESP32 TX2)
- SET & RESET → 3.3V (always active)
BME680 Connection (I2C):
- VIN → 3.3V
- GND → GND
- SCL → GPIO22
- SDA → GPIO21
OLED Display (I2C):
- VCC → 3.3V
- GND → GND
- SCL → GPIO22 (shared with BME680)
- SDA → GPIO21 (shared with BME680)
⚡ Important: The PMS7003 requires 5V power but its logic is 3.3V tolerant. Never power it from the ESP32's 3.3V pin!
4. Arduino IDE Setup
1Install ESP32 Board Support
File → Preferences → Add this URL: https://dl.espressif.com/dl/package_esp32_index.json
Then: Tools → Board → Boards Manager → Search "ESP32" → Install
2Install Required Libraries
Sketch → Include Library → Manage Libraries:
- PMS Library by Mariusz Kacki
- Zanshin BME680
- Adafruit SH110X
- Adafruit GFX
- WiFi (built-in)
- ArduinoJson
5. Complete Code
#include <PMS.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <bme680.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
// WiFi credentials
const char* ssid = "YOUR_WIFI";
const char* password = "YOUR_PASSWORD";
// Display
Adafruit_SH1106G display = Adafruit_SH1106G(128, 64, &Wire, -1);
// Sensors
PMS pms(Serial2);
PMS::DATA pmsData;
struct bme680_dev* gas_sensor;
// Pin definitions
#define PMS_RX 16
#define PMS_TX 17
#define I2C_SDA 21
#define I2C_SCL 22
// AQI calculation
int calculateAQI(int pm25) {
if (pm25 <= 12) return map(pm25, 0, 12, 0, 50);
if (pm25 <= 35) return map(pm25, 13, 35, 51, 100);
if (pm25 <= 55) return map(pm25, 36, 55, 101, 150);
if (pm25 <= 150) return map(pm25, 56, 150, 151, 200);
if (pm25 <= 250) return map(pm25, 151, 250, 201, 300);
return 301;
}
String getAQILevel(int aqi) {
if (aqi <= 50) return "GOOD";
if (aqi <= 100) return "MODERATE";
if (aqi <= 150) return "UNHEALTHY-S";
if (aqi <= 200) return "UNHEALTHY";
if (aqi <= 300) return "V.UNHEALTHY";
return "HAZARDOUS";
}
void setupBME680() {
gas_sensor = (struct bme680_dev*)malloc(sizeof(struct bme680_dev));
gas_sensor->dev_id = BME680_I2C_ADDR_PRIMARY;
gas_sensor->intf = BME680_I2C_INTF;
gas_sensor->amb_temp = 25;
bme680_init(gas_sensor);
}
void setup() {
Serial.begin(115200);
Serial2.begin(9600, SERIAL_8N1, PMS_RX, PMS_TX);
Wire.begin(I2C_SDA, I2C_SCL);
display.begin(0x3C, true);
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.println("Air Quality Monitor");
display.println("Initializing...");
display.display();
setupBME680();
WiFi.begin(ssid, password);
display.println("Connecting WiFi...");
display.display();
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
display.clearDisplay();
display.println("Ready!");
display.print("IP: ");
display.println(WiFi.localIP());
display.display();
delay(2000);
}
void loop() {
// Read PMS7003
if (pms.readUntil(pmsData)) {
int pm25 = pmsData.PM_AE_UG_2_5;
int pm10 = pmsData.PM_AE_UG_10_0;
int aqi = calculateAQI(pm25);
// Read BME680
struct bme680_field_data data;
bme680_get_sensor_data(&data, gas_sensor);
// Display
display.clearDisplay();
display.setCursor(0, 0);
display.setTextSize(1);
display.print("PM2.5: ");
display.print(pm25);
display.println(" ug/m3");
display.print("PM10: ");
display.print(pm10);
display.println(" ug/m3");
display.print("AQI: ");
display.print(aqi);
display.print(" ");
display.println(getAQILevel(aqi));
display.print("Temp: ");
display.print(data.temperature / 100.0);
display.println(" C");
display.print("Hum: ");
display.print(data.humidity / 1000.0);
display.println(" %");
display.print("VOC: ");
display.print(data.gas_resistance / 1000.0);
display.println(" kOhm");
display.display();
// Send to cloud every 60 seconds
static unsigned long lastSend = 0;
if (millis() - lastSend > 60000) {
sendToCloud(pm25, pm10, aqi, data.temperature/100.0,
data.humidity/1000.0, data.gas_resistance/1000.0);
lastSend = millis();
}
}
delay(2000);
}
void sendToCloud(int pm25, int pm10, int aqi, float temp, float hum, float voc) {
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin("https://your-api-endpoint.com/airquality");
http.addHeader("Content-Type", "application/json");
StaticJsonDocument<256> doc;
doc["pm25"] = pm25;
doc["pm10"] = pm10;
doc["aqi"] = aqi;
doc["temp"] = temp;
doc["hum"] = hum;
doc["voc"] = voc;
doc["timestamp"] = millis();
String json;
serializeJson(doc, json);
http.POST(json);
http.end();
}
}
6. Understanding AQI Levels
🌈 Air Quality Index Scale
0-50: GOOD - Air quality is satisfactory
51-100: MODERATE - Acceptable quality
101-150: UNHEALTHY for sensitive groups
151-200: UNHEALTHY for everyone
201-300: VERY UNHEALTHY - Health alert
301+: HAZARDOUS - Emergency conditions
7. WiFi Data Logging
The code sends data to a cloud endpoint every 60 seconds. You can use:
- ThingSpeak (free tier available)
- Blynk for mobile dashboards
- InfluxDB + Grafana for advanced analytics
- Google Sheets via IFTTT
💡 Pro Tip: Use the ESP8266/ESP32 Internet Control Guide to set up your own web dashboard!
8. 3D Printed Enclosure
Design considerations:
- 📐 Air intake vents on bottom (for PMS7003)
- 📐 Small holes on sides (for BME680)
- 📐 Clear window for OLED display
- 📐 Battery compartment with USB-C charging port
- 📐 Power switch on top
9. Calibration Tips
1PMS7003: Let it run for 30 seconds before first reading to stabilize the fan.
2BME680: Requires 5-10 minutes warmup for accurate gas readings.
3Baseline: Take readings outdoors (away from pollution) to establish a clean baseline.
10. Troubleshooting
PMS7003 shows 0 values:
- Check UART wiring (TX/RX swap needed?)
- Verify 5V power supply
- Fan should be spinning visibly
BME680 not detected:
- Run I2C scanner to verify address (0x76 or 0x77)
- Check pull-up resistors on SDA/SCL (4.7kΩ)
OLED blank:
- Verify I2C address (usually 0x3C)
- Check 3.3V power
🎉 Congratulations! You've built a professional air quality monitor that rivals $500 commercial devices. Share your build with #ESPAirMonitor on social media!

