-----
The Air Quality Index (AQI) is an indicator as to how safe the air is to breath. There are many websites that provide this information, but we wanted a 24x7 visual readout in the house. Our rig does not measure proximity (in room) AQI. Instead it polls one of the many AQI websites every 15 minutes to get the reading for our local region.
-----
The hardware setup is as about as simple as it gets. Just get an ESP32 C3 (or pretty much any uC with WIFI) and connect the RGB LED to the pins programed in the software. We also 3D printed a fancy display box, but that is optional.
In addition to the Green/Yellow/Red LED indicator we also spin up a WebServer on the C3 that allows viewing AQI history from a browser.
-----
Thanks to the wonders of AI the program was super easy to write. We added a few debug statements and serial monitor outputs just to verify some things, but overall the AI did the heavy lifting. Here's the Arduino IDE script:
// Polls Open-Meteo Air Quality at set intervals
// and turns a RGB LED Green, Yellow, or Red based on the local AQI number.
// Blue is displayed if a valid AQI number not fetched (ie, no connection).
//
// Mostly written by AI, but see
// WhiskeyTangoHotel.Com for project details.
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WebServer.h>
#include <time.h>
// Wi-Fi Credentials
const char* ssid = "ur-wifi-ssid";
const char* password = "ur-wifi-password";
// NTP Server & Timezone configuration for Austin, TX (Central Time with DST)
const char* ntpServer = "pool.ntp.org";
const char* tzInfo = "CST6CDT,M3.2.0,M11.1.0"; // Automatically handles US Central Time & Daylight Saving
// Open-Meteo Air Quality API for Austin, TX (Coordinates: 30.2672, -97.7431)
const char* apiURL = "https://air-quality-api.open-meteo.com/v1/air-quality?latitude=30.2672&longitude=-97.7431¤t=us_aqi";
// RGB LED GPIO Pins on ESP32-C3
const int redPin = 4;
const int greenPin = 5;
const int bluePin = 6;
// Web Server on port 80
WebServer server(80);
// 24-Hour History Struct & Storage (96 slots: 4 times an hour * 24 hours)
struct AQIRecord {
String timeStr;
int aqi;
String color;
};
const int MAX_RECORDS = 96;
AQIRecord history[MAX_RECORDS];
int recordCount = 0;
// Timing & Trigger Variables
unsigned long lastBlinkTime = 0;
unsigned long lastWiFiCheckTime = 0;
const unsigned long wiFiCheckInterval = 10000; // Check Wi-Fi status every 10 seconds
bool blinkState = false;
int lastFetchedMinute = -1;
int currentAQI = -1; // -1 means no reading yet
String currentLedColor = "Off";
// Helper to set RGB pin states and track string color description
void setLED(bool red, bool green, bool blue, String colorName) {
digitalWrite(redPin, red ? HIGH : LOW);
digitalWrite(greenPin, green ? HIGH : LOW);
digitalWrite(bluePin, blue ? HIGH : LOW);
currentLedColor = colorName;
}
// Visual LED Self-Test Cycle (Red -> Yellow -> Green)
void runSelfTest() {
Serial.println("Running LED Self-Test...");
for (int i = 1; i <= 2; i++) {
setLED(true, false, false, "Self-Test: Red");
delay(300);
setLED(true, true, false, "Self-Test: Yellow");
delay(300);
setLED(false, true, false, "Self-Test: Green");
delay(300);
}
setLED(false, false, false, "Self-Test: OFF");
}
// Function to fetch AQI from Open-Meteo
int fetchAQI() {
if (WiFi.status() != WL_CONNECTED) return -1;
HTTPClient http;
http.begin(apiURL);
int httpCode = http.GET();
int parsedAQI = -1;
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
// Allocate a JSON document buffer
DynamicJsonDocument doc(1024);
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
// Extract the US AQI value from the JSON tree
parsedAQI = doc["current"]["us_aqi"];
} else {
Serial.println("Failed to parse JSON response");
}
} else {
Serial.printf("HTTP GET failed, error code: %d\n", httpCode);
}
http.end();
return parsedAQI;
}
// Add new record to the top of the history list
void addRecord(String timeStr, int aqi, String color) {
for (int i = MAX_RECORDS - 1; i > 0; i--) {
history[i] = history[i - 1];
}
history[0] = {timeStr, aqi, color};
if (recordCount < MAX_RECORDS) {
recordCount++;
}
}
// Web Server root page handler
void handleRoot() {
struct tm timeinfo;
String html = "<html lang='en'><head><meta charset='UTF-8'><title>Austin AQI Dashboard</title>";
html += "<meta http-equiv='refresh' content='30'>"; // Auto-refresh web page every 30 seconds
html += "<style>body{font-family:Arial,sans-serif; margin:20px; background:#f4f4f9; color:#333;}";
html += "table{border-collapse:collapse; width:100%; max-width:600px; background:#fff; margin-top:10px;}";
html += "th, td{border:1px solid #ddd; padding:10px; text-align:center;}";
html += "th{background-color:#2c3e50; color:white;}</style></head><body>";
html += "<h2>Austin, TX Air Quality Dashboard</h2>";
if (getLocalTime(&timeinfo)) {
char timeBuf[64];
strftime(timeBuf, sizeof(timeBuf), "%A, %B %d %Y - %I:%M:%S %p", &timeinfo);
html += "<p><strong>Current Local Time:</strong> " + String(timeBuf) + "</p>";
}
html += "<p><strong>Current AQI:</strong> " + String(currentAQI) + " (" + currentLedColor + ")</p>";
html += "<h3>Past 24 Hours History (Newest First)</h3>";
html += "<table><tr><th>Timestamp</th><th>AQI</th><th>Status Color</th></tr>";
if (recordCount == 0) {
html += "<tr><td colspan='3'>No history recorded yet.</td></tr>";
} else {
for (int i = 0; i < recordCount; i++) {
html += "<tr><td>" + history[i].timeStr + "</td><td>" + String(history[i].aqi) + "</td><td>" + history[i].color + "</td></tr>";
}
}
html += "</table></body></html>";
server.send(200, "text/html", html);
}
void printStatusToSerial() {
String statusColor = "";
if (currentAQI < 0) {
statusColor = "Blue (Error)";
} else if (currentAQI <= 50) {
statusColor = "Green";
} else if (currentAQI <= 100) {
statusColor = "Yellow";
} else {
statusColor = "Red (Flashing)";
}
Serial.println("----------------------------------------");
Serial.print("Device IP Address: http://");
Serial.println(WiFi.localIP());
Serial.print("Current AQI Number: ");
if (currentAQI < 0) {
Serial.println("Unavailable (Error)");
} else {
Serial.println(currentAQI);
}
Serial.print("Assigned LED Color: ");
Serial.println(statusColor);
Serial.println("----------------------------------------");
}
void setup() {
Serial.begin(115200);
pinMode(redPin, OUTPUT);
pinMode(greenPin, OUTPUT);
pinMode(bluePin, OUTPUT);
// Turn off LEDs initially
setLED(false, false, false, "Off");
Serial.print("Connecting to Wi-Fi");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Initialize NTP time sync for Austin (Central Time)
configTime(0, 0, ntpServer);
setenv("TZ", tzInfo, 1);
tzset();
// Start web server routes
server.on("/", handleRoot);
server.begin();
Serial.println("HTTP server started");
// Run initial self-test and fetch on boot
runSelfTest();
currentAQI = fetchAQI();
struct tm timeinfo;
String timeStr = "Startup";
if (getLocalTime(&timeinfo)) {
char timeBuf[32];
strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %I:%M %p", &timeinfo);
timeStr = String(timeBuf);
}
String initColor = (currentAQI < 0) ? "Blue (Error)" : (currentAQI <= 50 ? "Green" : (currentAQI <= 100 ? "Yellow" : "Red (Flashing)"));
addRecord(timeStr, currentAQI, initColor);
printStatusToSerial();
}
void loop() {
// Handle incoming client requests for the web server
server.handleClient();
unsigned long currentMillis = millis();
// Robust Wi-Fi Reconnection Check (Runs every 10 seconds)
if (currentMillis - lastWiFiCheckTime >= wiFiCheckInterval) {
lastWiFiCheckTime = currentMillis;
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi connection lost. Attempting to reconnect...");
WiFi.disconnect();
WiFi.begin(ssid, password);
}
}
struct tm timeinfo;
// Check if we can get local time and perform scheduled polling at :00, :15, :30, :45
if (getLocalTime(&timeinfo)) {
int min = timeinfo.tm_min;
// Trigger check when minute hits 0, 15, 30, or 45 and hasn't triggered yet this slot
if ((min == 0 || min == 15 || min == 30 || min == 45) && min != lastFetchedMinute) {
lastFetchedMinute = min;
// Trigger LED self-test to signal poll update
runSelfTest();
int newAQI = fetchAQI();
if (newAQI >= 0) {
currentAQI = newAQI;
}
// Format timestamp string for history log
char timeBuf[32];
strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %I:%M %p", &timeinfo);
String statusColor = "";
if (currentAQI < 0) {
statusColor = "Blue (Error)";
} else if (currentAQI <= 50) {
statusColor = "Green";
} else if (currentAQI <= 100) {
statusColor = "Yellow";
} else {
statusColor = "Red (Flashing)";
}
// Add to 24-hour history array (newest at the top)
addRecord(String(timeBuf), currentAQI, statusColor);
// Print stats to Serial Monitor
printStatusToSerial();
}
else if (min != 0 && min != 15 && min != 30 && min != 45) {
// Reset trigger lock when out of the target minute window
lastFetchedMinute = -1;
}
}
// Handle LED States based on current AQI value
if (currentAQI < 0) {
setLED(false, false, true, "Blue (Error)");
}
else if (currentAQI <= 50) {
setLED(false, true, false, "Green");
}
else if (currentAQI <= 100) {
setLED(true, true, false, "Yellow");
}
else {
if (currentMillis - lastBlinkTime >= 1000) {
lastBlinkTime = currentMillis;
blinkState = !blinkState;
}
setLED(blinkState, false, false, blinkState ? "Red (ON)" : "Red (OFF)");
}
}
-----


