// ESP32 Air Quality Demo for Wokwi
// DHT22 + SSD1306 OLED + NeoPixel + Buzzer + ThingSpeak HTTP upload
// PM2.5 is simulated via Serial: PM=<value> (e.g., PM=160)
//
// Wiring (Wokwi labels):
// DHT22: SDA->GPIO19 (esp:19), VCC->3V3, GND->GND
// OLED : SDA->GPIO21, SCL->GPIO22, VCC->3V3, GND->GND
// NeoPx : DIN->GPIO4, VDD->5V, VSS->GND
// Buzzer: +->GPIO15, - ->GND
//
// ThingSpeak fields: field1=PM2.5, field2=AQI, field3=Temp, field4=Humidity
#include <WiFi.h>
#include <HTTPClient.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>
#include "DHT.h"
#include <Wire.h>
// ---------------- Pins ----------------
#define PIN_DHT 19 // moved off I2C to avoid conflicts
#define PIN_SDA 21
#define PIN_SCL 22
#define PIN_NEOPX 4
#define PIN_BUZZER 15
#define DHTTYPE DHT22
// ---------------- WiFi & ThingSpeak ----------------
const char* WIFI_SSID = "Wokwi-GUEST";
const char* WIFI_PASS = "";
const char* TS_WRITE_KEY = "DQ2AKDGKWOK18H25";
// ---------------- Objects ----------------
Adafruit_SSD1306 display(128, 64, &Wire, -1);
Adafruit_NeoPixel pixels(1, PIN_NEOPX, NEO_GRB + NEO_KHZ800);
DHT dht(PIN_DHT, DHTTYPE);
// ---------------- State ----------------
float pm25 = 12.0; // simulated PM2.5 (µg/m³)
const unsigned long UPLOAD_INTERVAL_MS = 30000; // 30s
// ---------------- Helpers ----------------
int computeAQI_PM25(float c) {
if (isnan(c)) return -1;
struct Brk { float Cl; float Ch; int Il; int Ih; };
Brk bk[] = {
{0.0, 12.0, 0, 50}, {12.1, 35.4, 51, 100}, {35.5, 55.4, 101,150},
{55.5,150.4,151,200}, {150.5,250.4,201,300}, {250.5,350.4,301,400},
{350.5,500.4,401,500}
};
for (auto &b : bk) {
if (c >= b.Cl && c <= b.Ch) {
return (int)round((b.Ih - b.Il) * (c - b.Cl) / (b.Ch - b.Cl) + b.Il);
}
}
return 500;
}
String aqiCategory(int aqi) {
if (aqi <= 50) return "Tot";
if (aqi <= 100) return "Trung binh";
if (aqi <= 150) return "Nhay cam";
if (aqi <= 200) return "Xau";
if (aqi <= 300) return "Rat xau";
return "Nguy hiem";
}
// Detailed advice by AQI band + humidity tweak
String adviceForAsthmaDetailed(int aqi, float T, float H) {
String msg;
if (aqi <= 50) {
msg = "Khong khi tot.";
} else if (aqi <= 100) {
msg = "Co the hoat dong binh thuong.";
} else if (aqi <= 150) {
msg = "Nhom nhay cam: han che hoat dong ngoai troi.";
} else if (aqi <= 200) {
msg = "XAU: Han che ra ngoai; deo N95 khi can; bat may loc.";
} else if (aqi <= 300) {
msg = "RAT XAU: O trong nha; dong cua; deo N95 neu buoc ra ngoai; theo doi trieu chung.";
} else {
msg = "NGUY HIEM: Tranh ra ngoai; deo N95 bat buoc; dung thuoc cap cuu neu kho tho; lien he y te neu nang.";
}
if (!isnan(H)) {
if (H < 40) msg += " Do am <40% co the kich ung.";
else if (H > 70) msg += " Do am >70% de kho tho.";
}
return msg;
}
// tiny warning icon (triangle + "!") for OLED (monochrome)
void drawWarningIcon(int x, int y) {
// Triangle outline
display.drawTriangle(x, y+20, x+10, y, x+20, y+20, SSD1306_WHITE);
// Exclamation mark
display.fillRect(x+9, y+6, 2, 8, SSD1306_WHITE);
display.fillRect(x+9, y+16, 2, 2, SSD1306_WHITE);
}
void setPixel(uint8_t r, uint8_t g, uint8_t b, uint8_t brightness=40) {
pixels.setBrightness(brightness);
pixels.setPixelColor(0, pixels.Color(r,g,b));
pixels.show();
}
// Buzzer using tone()
void beep(int freq, int ms){
tone(PIN_BUZZER, freq, ms);
delay(ms + 10);
noTone(PIN_BUZZER);
}
void handleSerialPM() {
static String buf;
while (Serial.available()) {
char c = Serial.read();
if (c=='\n' || c=='\r') {
if (buf.startsWith("PM=")) {
float v = buf.substring(3).toFloat();
if (v>=0 && v<=1000) {
pm25 = v;
Serial.printf("[OK] PM2.5 set to %.1f ug/m3\n", pm25);
} else {
Serial.println("[ERR] PM must be 0..1000");
}
} else if (buf=="HELP") {
Serial.println("Commands:");
Serial.println(" PM=<value> set PM2.5 in ug/m3 (0..1000)");
}
buf = "";
} else {
buf += c;
}
}
}
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
Serial.printf("Connecting to WiFi: %s\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
delay(500);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nWiFi connected. IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\nWiFi connect failed.");
}
}
void uploadThingSpeak(float pm25, int aqi, float t, float h) {
if (WiFi.status() != WL_CONNECTED) {
connectWiFi();
if (WiFi.status() != WL_CONNECTED) return;
}
HTTPClient http;
String url = String("https://api.thingspeak.com/update?api_key=") + TS_WRITE_KEY +
"&field1=" + String(pm25,1) +
"&field2=" + String(aqi) +
"&field3=" + String(t,1) +
"&field4=" + String(h,1);
http.begin(url);
int code = http.GET();
if (code > 0) {
String payload = http.getString();
Serial.printf("[ThingSpeak] HTTP %d, response: %s\n", code, payload.c_str());
} else {
Serial.printf("[ThingSpeak] HTTP error: %d\n", code);
}
http.end();
}
void setup() {
Serial.begin(115200);
Serial.println("ESP32 – Air Quality + ThingSpeak Demo");
Serial.println("Type PM=<value> to simulate PM2.5 (e.g., PM=160).");
Wire.begin(PIN_SDA, PIN_SCL);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("[WARN] SSD1306 not found.");
} else {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
}
dht.begin();
pixels.begin();
setPixel(0,0,0,0);
pinMode(PIN_BUZZER, OUTPUT);
digitalWrite(PIN_BUZZER, LOW);
connectWiFi();
}
void loop() {
handleSerialPM();
float temp = dht.readTemperature();
float humid = dht.readHumidity();
if (isnan(temp)) temp = 0;
if (isnan(humid)) humid = 0;
int aqi = computeAQI_PM25(pm25);
// OLED with detailed advice + icon
if (display.width() > 0) {
String cat = aqiCategory(aqi);
String adv = adviceForAsthmaDetailed(aqi, temp, humid);
display.clearDisplay();
display.setCursor(0,0);
display.setTextSize(1);
display.println("Air Quality Monitor");
display.print("PM2.5: "); display.print(pm25,1); display.println(" ug/m3");
display.print("AQI : "); display.print(aqi); display.print(" ("); display.print(cat); display.println(")");
display.print("T: "); display.print(temp,1); display.print("C H: "); display.print(humid,0); display.println("%");
if (aqi >= 151) {
display.setTextSize(2);
display.println("CANH BAO");
display.setTextSize(1);
drawWarningIcon(104, 0); // top-right corner
} else {
display.println("Trang thai on dinh.");
}
// Wrap advice into 2 lines (~21 chars/line)
for (int i = 0; i < adv.length() && i < 42; i += 21) {
int end = i + 21; if (end > adv.length()) end = adv.length();
display.println(adv.substring(i, end));
}
display.display();
}
// LED color by AQI
if (aqi <= 50) setPixel(0, 180, 0);
else if (aqi <= 100) setPixel(200, 120, 0);
else if (aqi <= 150) setPixel(255, 80, 0);
else if (aqi <= 300) setPixel(200, 0, 0); // red for unhealthy & very unhealthy
else setPixel(200, 0, 200); // magenta for hazardous
// Beep pattern by severity every 10s
static unsigned long lastBeep = 0;
unsigned long now = millis();
if (aqi >= 151 && now - lastBeep > 10000) {
int n = (aqi > 300) ? 4 : (aqi > 200) ? 3 : 2;
for (int i=0; i<n; ++i) { beep(2000, 150); delay(120); }
lastBeep = now;
}
// Upload to ThingSpeak every 30 seconds
static unsigned long lastUpload = 0;
if (now - lastUpload >= UPLOAD_INTERVAL_MS) {
uploadThingSpeak(pm25, aqi, temp, humid);
lastUpload = now;
}
delay(500);
}