// ESP32 Air Quality Demo for Wokwi
// DHT22 + SSD1306 OLED + NeoPixel + Buzzer + ThingSpeak HTTP upload
// PM2.5 simulated via Serial: PM=<value> (e.g., PM=160)
//
// Wiring (Wokwi labels):
// DHT22 : DATA->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
//
// This build focuses on the mini chart + EMA smoothing only.
// Removed: MQTT, Telegram (kept quiet-hours, adaptive upload, profile advice).
//
// Libraries: Adafruit GFX, Adafruit SSD1306, Adafruit NeoPixel, DHT sensor library
// Timezone: Asia/Ho_Chi_Minh (UTC+7) via NTP.
#include <WiFi.h>
#include <HTTPClient.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>
#include "DHT.h"
#include <Wire.h>
#include <Preferences.h>
#include <time.h>
// ---------------- Pins ----------------
#define PIN_DHT 19
#define PIN_SDA 21
#define PIN_SCL 22
#define PIN_NEOPX 4
#define PIN_BUZZER 15
#define DHTTYPE DHT22
// ---------------- Defaults ----------------
#define DEFAULT_PROFILE 1 // 0=Adult, 1=Child, 2=Elderly
#define DEFAULT_QH_START 22 // 22:00
#define DEFAULT_QH_END 6 // 06:00
#define DEFAULT_QH_ENABLE 1
#define DEFAULT_BUZZ_EN 1
#define DEFAULT_UPLOAD_S 30 // 30s base
#define DEFAULT_UPLOADMAX_S 300 // 5min cap
// ---------------- WiFi & ThingSpeak ----------------
const char* WIFI_SSID = "Wokwi-GUEST";
const char* WIFI_PASS = "";
const char* TS_WRITE_KEY = "PUT_YOUR_TS_WRITE_KEY_HERE";
// ---------------- Objects ----------------
Adafruit_SSD1306 display(128, 64, &Wire, -1);
Adafruit_NeoPixel pixels(1, PIN_NEOPX, NEO_GRB + NEO_KHZ800);
DHT dht(PIN_DHT, DHTTYPE);
Preferences prefs;
// ---------------- State ----------------
float pm25 = 12.0; // simulated PM2.5 (µg/m³)
int patientProfile;
int quietStart, quietEnd;
bool quietEnabled;
bool buzzerEnabled;
unsigned long baseUploadMs, maxUploadMs;
float tempOverride = NAN;
float humOverride = NAN;
int failStreak = 0;
float aqiEma = NAN;
const float EMA_ALPHA = 0.3f;
unsigned long lastUpload = 0;
int lastUploadedAQI = -999;
float lastUploadedPM = -999;
// Mini chart buffer (≈100 seconds history)
const int CHART_W = 128;
const int CHART_H = 20;
const int CHART_N = 100;
int chartBuf[CHART_N];
int chartIdx = 0;
bool chartFilled = false;
// ---------------- 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";
}
String profileLabel(int p) {
if (p == 1) return "Tre em";
if (p == 2) return "Nguoi gia";
return "Nguoi lon";
}
// Advice by AQI band + profile + humidity tweak
String adviceForProfile(int aqi, float T, float H, int p) {
String msg;
if (p == 1) { // Child
if (aqi <= 100) msg = "Tre em: choi nhe trong nha.";
else if (aqi <= 150) msg = "Giam hoat dong ngoai troi; tranh chay nhay; deo N95 khi can.";
else if (aqi <= 200) msg = "O trong nha; bat may loc; N95 neu ra ngoai; theo doi ho/kho tho.";
else if (aqi <= 300) msg = "O trong nha; dong cua; N95 bat buoc neu buoc ra ngoai; theo doi sat.";
else msg = "Tranh ra ngoai; dung thuoc cap cuu neu kho tho; lien he y te neu nang.";
} else if (p == 2) { // Elderly
if (aqi <= 100) msg = "Nguoi gia: hoat dong nhe trong nha.";
else if (aqi <= 150) msg = "Giam van dong ngoai troi; deo N95 khi can; uong du nuoc.";
else if (aqi <= 200) msg = "O trong nha; N95 neu ra ngoai; tranh mang vat nang, leo cau thang.";
else if (aqi <= 300) msg = "O trong nha; dong cua; N95 bat buoc; theo doi nhip tho, dau nguc.";
else msg = "Tranh di chuyen; goi nguoi ho tro; lien he y te neu kho tho.";
} else { // Adult
if (aqi <= 100) msg = "Co the hoat dong binh thuong.";
else if (aqi <= 150) msg = "Nhom nhay cam: han che ra ngoai.";
else if (aqi <= 200) msg = "Deo N95; bat may loc; han che van dong manh.";
else if (aqi <= 300) msg = "O trong nha; dong cua; N95 neu buoc ra ngoai.";
else msg = "Tranh ra ngoai; N95 bat buoc; lien he y te neu kho tho.";
}
if (!isnan(H)) {
if (H < 40) msg += " Do am <40% de 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) {
display.drawTriangle(x, y+20, x+10, y, x+20, y+20, SSD1306_WHITE);
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();
}
// Quiet hours helpers
bool isQuietNow() {
if (!quietEnabled) return false;
struct tm ti;
if (getLocalTime(&ti, 100)) {
int h = ti.tm_hour;
bool q = (quietStart <= quietEnd) ? (h >= quietStart && h < quietEnd)
: (h >= quietStart || h < quietEnd);
return q;
}
return false;
}
// Buzzer
void beep(int freq, int ms){
if (!buzzerEnabled) return;
if (isQuietNow()) return;
tone(PIN_BUZZER, freq, ms);
delay(ms + 10);
noTone(PIN_BUZZER);
}
// ---------- NVS ----------
void saveSettings() {
prefs.begin("aqmon", false);
prefs.putInt("profile", patientProfile);
prefs.putInt("qStart", quietStart);
prefs.putInt("qEnd", quietEnd);
prefs.putBool("qEn", quietEnabled);
prefs.putBool("bzEn", buzzerEnabled);
prefs.putULong("upBase", baseUploadMs);
prefs.putULong("upMax", maxUploadMs);
prefs.end();
}
void loadSettings() {
prefs.begin("aqmon", true);
patientProfile = prefs.getInt("profile", DEFAULT_PROFILE);
quietStart = prefs.getInt("qStart", DEFAULT_QH_START);
quietEnd = prefs.getInt("qEnd", DEFAULT_QH_END);
quietEnabled = prefs.getBool("qEn", DEFAULT_QH_ENABLE);
buzzerEnabled = prefs.getBool("bzEn", DEFAULT_BUZZ_EN);
baseUploadMs = prefs.getULong("upBase", DEFAULT_UPLOAD_S * 1000UL);
maxUploadMs = prefs.getULong("upMax", DEFAULT_UPLOADMAX_S * 1000UL);
prefs.end();
}
// ---------- Serial ----------
void printHelp() {
Serial.println("Commands:");
Serial.println(" PM=<value> set PM2.5 in ug/m3 (0..1000)");
Serial.println(" TEMP=<val|CLR> override temperature (-40..85 C) or CLR");
Serial.println(" HUM=<val|CLR> override humidity (0..100 %) or CLR");
Serial.println(" MODE=ADULT|CHILD|ELDERLY switch patient profile (saved)");
Serial.println(" QH=HH-HH | QH=OFF quiet hours start-end (24h) or disable (saved)");
Serial.println(" BUZZ=ON|OFF enable/disable buzzer (saved)");
Serial.println(" UPLOAD=<sec> base upload interval seconds (saved)");
Serial.println(" UPLOADMAX=<sec> max upload/backoff cap seconds (saved)");
Serial.println(" PUSH force immediate ThingSpeak upload");
Serial.println(" TIME? show local time");
Serial.println(" STATUS print current state");
Serial.println(" HELP show this help");
}
void handleSerial() {
static String buf;
while (Serial.available()) {
char c = Serial.read();
if (c=='\n' || c=='\r') {
buf.trim();
if (buf.length() == 0) { buf=""; continue; }
String up = buf; up.toUpperCase();
if (up.startsWith("PM=")) {
float v = up.substring(3).toFloat();
if (v>=0 && v<=1000) { pm25 = v; Serial.printf("[OK] PM2.5=%.1f\n", pm25); }
else Serial.println("[ERR] PM must be 0..1000");
} else if (up.startsWith("TEMP=")) {
String arg = up.substring(5); arg.trim();
if (arg == "CLR") { tempOverride = NAN; Serial.println("[OK] TEMP override cleared"); }
else { float v = arg.toFloat(); if (v>=-40 && v<=85) { tempOverride=v; Serial.printf("[OK] TEMP=%.1f\n", v);} else Serial.println("[ERR] TEMP -40..85"); }
} else if (up.startsWith("HUM=")) {
String arg = up.substring(4); arg.trim();
if (arg == "CLR") { humOverride = NAN; Serial.println("[OK] HUM override cleared"); }
else { float v = arg.toFloat(); if (v>=0 && v<=100) { humOverride=v; Serial.printf("[OK] HUM=%.1f\n", v);} else Serial.println("[ERR] HUM 0..100"); }
} else if (up.startsWith("MODE=")) {
String m = up.substring(5); m.trim(); m.toUpperCase();
if (m=="ADULT"||m=="NGUOILON") patientProfile=0;
else if (m=="CHILD"||m=="TREEM"||m=="TRE") patientProfile=1;
else if (m=="ELDERLY"||m=="OLD"||m=="NGUOIGIA") patientProfile=2;
else { Serial.println("[ERR] MODE must be ADULT/CHILD/ELDERLY"); buf=""; continue; }
saveSettings(); Serial.printf("[OK] Profile=%s\n", profileLabel(patientProfile).c_str());
} else if (up.startsWith("QH=")) {
String arg = up.substring(3); arg.trim(); arg.toUpperCase();
if (arg=="OFF") { quietEnabled=false; saveSettings(); Serial.println("[OK] Quiet hours OFF"); }
else {
int dash = arg.indexOf('-');
if (dash>0) {
int s = arg.substring(0,dash).toInt();
int e = arg.substring(dash+1).toInt();
if (s>=0&&s<=23&&e>=0&&e<=23) { quietStart=s; quietEnd=e; quietEnabled=true; saveSettings(); Serial.printf("[OK] QH %02d-%02d\n", s,e); }
else Serial.println("[ERR] QH HH-HH 0..23");
} else Serial.println("[ERR] QH HH-HH or OFF");
}
} else if (up.startsWith("BUZZ=")) {
String arg = up.substring(5); arg.trim(); arg.toUpperCase();
if (arg=="ON") { buzzerEnabled=true; saveSettings(); Serial.println("[OK] Buzzer ON"); }
else if (arg=="OFF") { buzzerEnabled=false; saveSettings(); Serial.println("[OK] Buzzer OFF"); }
else Serial.println("[ERR] BUZZ=ON|OFF");
} else if (up.startsWith("UPLOADMAX=")) {
unsigned long s = up.substring(10).toInt(); if (s>=15&&s<=3600){maxUploadMs=s*1000UL; saveSettings(); Serial.printf("[OK] UploadMax=%lus\n", s);} else Serial.println("[ERR] 15..3600");
} else if (up.startsWith("UPLOAD=")) {
unsigned long s = up.substring(7).toInt(); if (s>=15&&s<=3600){baseUploadMs=s*1000UL; saveSettings(); Serial.printf("[OK] UploadBase=%lus\n", s);} else Serial.println("[ERR] 15..3600");
} else if (up=="PUSH") { lastUpload=0; Serial.println("[OK] Will upload soon"); }
else if (up=="TIME?") { struct tm ti; if (getLocalTime(&ti, 1000)){ char b[32]; strftime(b,sizeof(b),"%Y-%m-%d %H:%M:%S",&ti); Serial.printf("Local: %s\n", b);} else Serial.println("Time not synced."); }
else if (up=="STATUS") {
Serial.printf("Profile:%s | QH:%s %02d-%02d | Buzzer:%s\n", profileLabel(patientProfile).c_str(), quietEnabled?"ON":"OFF", quietStart, quietEnd, buzzerEnabled?"ON":"OFF");
Serial.printf("Upload base=%lus, max=%lus, failStreak=%d\n", baseUploadMs/1000UL, maxUploadMs/1000UL, failStreak);
Serial.printf("PM2.5:%.1f AQI(EMA):%.0f\n", pm25, aqiEma);
} else if (up=="HELP") printHelp();
else Serial.println("[ERR] Unknown. Type HELP");
buf = "";
} else {
buf += c;
}
}
}
// ---------- Connectivity & uploads ----------
void ensureWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
Serial.printf("Connecting 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 OK, IP: %s\n", WiFi.localIP().toString().c_str());
else Serial.println("\nWiFi failed.");
}
bool uploadThingSpeak(float pm25, int aqi, float t, float h) {
ensureWiFi();
if (WiFi.status() != WL_CONNECTED) return false;
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);
http.setTimeout(10000);
http.setUserAgent("ESP32-AQMon");
int code = http.GET();
bool ok = false;
if (code > 0) {
String payload = http.getString();
Serial.printf("[ThingSpeak] HTTP %d, response: %s\n", code, payload.c_str());
ok = (code == 200 && payload.toInt() > 0);
} else {
Serial.printf("[ThingSpeak] HTTP error: %d\n", code);
}
http.end();
return ok;
}
// ---------- Mini Chart ----------
void chartPush(int aqi) {
chartBuf[chartIdx] = constrain(aqi, 0, 500);
chartIdx = (chartIdx + 1) % CHART_N;
if (chartIdx == 0) chartFilled = true;
}
int mapAQIToChartY(int aqi) {
// scale AQI 0..500 to chart height (1..CHART_H-2) with top origin
int v = map(aqi, 0, 500, CHART_H-2, 1);
if (v < 1) v=1; if (v>CHART_H-2) v=CHART_H-2;
return v;
}
void drawChart(int x, int y) {
// frame
display.drawRect(x, y, CHART_W, CHART_H, SSD1306_WHITE);
int count = chartFilled ? CHART_N : chartIdx;
if (count < 2) return;
// guide lines at ~AQI 150 and 300
int y150 = y + mapAQIToChartY(150);
int y300 = y + mapAQIToChartY(300);
display.drawFastHLine(x+1, y150, CHART_W-2, SSD1306_WHITE);
display.drawFastHLine(x+1, y300, CHART_W-2, SSD1306_WHITE);
// draw line right-to-left to fit window width
int w = CHART_W - 2;
int step = max(1, count > w ? (count / w) : 1);
int xi = CHART_W - 2;
int idx = (chartIdx - 1 + CHART_N) % CHART_N;
int prevY = mapAQIToChartY(chartBuf[idx]);
for (int i = 1; i < count; i += step) {
idx = (chartIdx - 1 - i + CHART_N) % CHART_N;
int yy = mapAQIToChartY(chartBuf[idx]);
// points inside chart box: offset by +1/+1
display.drawLine(x+1+xi, y+prevY, x+1+xi-1, y+yy, SSD1306_WHITE);
prevY = yy;
xi -= 1;
if (xi <= 0) break;
}
}
// ---------- Setup & Loop ----------
void setup() {
Serial.begin(115200);
Serial.println("ESP32 – Air Quality + Mini Chart + ThingSpeak");
Serial.println("Type HELP for commands.");
// Load persisted settings
loadSettings();
Serial.printf("Loaded: Profile=%s, QH:%s %02d-%02d, Buzzer:%s, UpBase=%lus, UpMax=%lus\n",
profileLabel(patientProfile).c_str(), quietEnabled? "ON":"OFF", quietStart, quietEnd,
buzzerEnabled? "ON":"OFF", baseUploadMs/1000UL, maxUploadMs/1000UL);
// Timezone UTC+7
configTime(7*3600, 0, "pool.ntp.org", "time.nist.gov");
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);
ensureWiFi();
// randomize first upload slightly
lastUpload = millis() + random(0, 5000);
}
void loop() {
handleSerial();
// Read sensors (or overrides)
float temp = dht.readTemperature();
float humid = dht.readHumidity();
if (!isnan(tempOverride)) temp = tempOverride;
if (!isnan(humOverride)) humid = humOverride;
if (isnan(temp)) temp = 0;
if (isnan(humid)) humid = 0;
// AQI + smoothing (EMA)
int aqi = computeAQI_PM25(pm25);
if (isnan(aqiEma)) aqiEma = aqi;
else aqiEma = EMA_ALPHA*aqi + (1-EMA_ALPHA)*aqiEma;
int aqiDisp = (int)round(aqiEma);
// push AQI to chart buffer every ~1s
static unsigned long lastChartPush = 0;
if (millis() - lastChartPush > 1000) {
chartPush(aqiDisp);
lastChartPush = millis();
}
// OLED UI
if (display.width() > 0) {
// dim during quiet hours
display.dim(isQuietNow());
display.clearDisplay();
display.setCursor(0,0);
display.setTextSize(1);
display.print("AQ Monitor | P: "); display.println(profileLabel(patientProfile));
display.print("PM2.5: "); display.print(pm25,1); display.println(" ug/m3");
display.print("AQI : "); display.print(aqiDisp); display.print(" ("); display.print(aqiCategory(aqiDisp)); display.println(")");
display.print("T: "); display.print(temp,1); display.print("C H: "); display.print(humid,0); display.println("%");
if (aqiDisp >= 151) {
display.setTextSize(2);
display.println("CANH BAO");
display.setTextSize(1);
drawWarningIcon(104, 0);
} else {
display.println("Trang thai on dinh.");
}
// Advice (2 lines)
String adv = adviceForProfile(aqiDisp, temp, humid, patientProfile);
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));
}
// Mini chart at bottom (x=0,y=44 height=20)
drawChart(0, 44);
display.display();
}
// LED color by AQI EMA
if (aqiDisp <= 50) setPixel(0, 180, 0);
else if (aqiDisp <= 100) setPixel(200, 120, 0);
else if (aqiDisp <= 150) setPixel(255, 80, 0);
else if (aqiDisp <= 300) setPixel(200, 0, 0);
else setPixel(200, 0, 200);
// Beep pattern by severity every 10s
static unsigned long lastBeep = 0;
if (aqiDisp >= 151 && millis() - lastBeep > 10000) {
int n = (aqiDisp > 300) ? 4 : (aqiDisp > 200) ? 3 : 2;
for (int i=0; i<n; ++i) { beep(2000, 150); delay(120); }
lastBeep = millis();
}
// Adaptive ThingSpeak upload
unsigned long now = millis();
unsigned long interval = baseUploadMs;
if (failStreak > 0) {
unsigned long back = baseUploadMs << (failStreak > 4 ? 4 : failStreak);
if (back > maxUploadMs) back = maxUploadMs;
interval = back;
}
if (abs(aqiDisp - lastUploadedAQI) < 5 && fabs(pm25 - lastUploadedPM) < 3.0) {
if (interval < 120000UL) interval = 120000UL;
}
if (now - lastUpload >= interval) {
bool ok = uploadThingSpeak(pm25, aqiDisp, temp, humid);
if (ok) {
failStreak = 0; lastUploadedAQI = aqiDisp; lastUploadedPM = pm25; lastUpload = now + random(0,3000);
} else {
failStreak++; lastUpload = now + 5000UL;
}
}
delay(200);
}