Projects / Air quality
Wi-Fi Air Quality Dashboard
Air quality index, TVOC and eCO₂ on any phone in the house.
Overview
An affordable air-quality monitor built from three small boards. The ET-S66 ENS160 metal-oxide sensor reports an air quality index (1–5), total VOCs and an equivalent CO₂ estimate; the ET-S61 AHT20 feeds it temperature and humidity so its readings stay accurate.
The ET-M01 joins your Wi-Fi and serves a simple page you can open from any phone or laptop on the same network. Two Qwiic cables, no soldering.
- Air quality index 1–5 (UBA scale)
- TVOC in ppb and eCO₂ in ppm
- Temperature and humidity
- Live web page on your home network
What you need
ElectroTrical boards
You'll also need
- 2 Qwiic cables
- USB-C cable and 5 V USB charger
Wiring
Addresses: ENS160 0x53, AHT20 0x38. The ET-M01's Qwiic port is on IO6 (SDA) and IO7 (SCL).
Qwiic chain
| From | To | Note |
|---|---|---|
| ET-M01 Qwiic | ET-S66 Qwiic | |
| ET-S66 Qwiic | ET-S61 Qwiic |
Build it
- 01
Connect
Chain the three boards with Qwiic cables.
- 02
Set up the Arduino IDE
Install the Espressif ESP32 package, select “ESP32C3 Dev Module” and set USB CDC On Boot to Enabled. Install Adafruit AHTX0 from Library Manager.
- 03
Add your Wi-Fi
Put your network name and password in WIFI_SSID and WIFI_PASS at the top of the sketch. The ESP32-C3 supports 2.4 GHz networks only.
- 04
Upload and open the page
Serial Monitor shows the board's IP address. Open it in a browser on the same network; the page refreshes every 5 seconds.
- 05
Allow warm-up
The ENS160 needs about 3 minutes after each power-up, and up to an hour on its very first start, before readings are valid. The page shows “warming up” until then.
Arduino code
Written for the ET-M01 ESP32-C3 Mini Dev Board. Install these libraries from the Arduino Library Manager:
- Adafruit AHTX0
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Adafruit_AHTX0.h>
const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";
const uint8_t ENS = 0x53;
Adafruit_AHTX0 aht;
WebServer server(80);
float tC = 0, rh = 0;
int aqi = 0, tvoc = 0, eco2 = 0;
const char* state = "warming up";
void ensWrite(uint8_t reg, const uint8_t* d, int n) {
Wire.beginTransmission(ENS); Wire.write(reg); Wire.write(d, n); Wire.endTransmission();
}
uint8_t ensRead(uint8_t reg, uint8_t* d, int n) {
Wire.beginTransmission(ENS); Wire.write(reg); Wire.endTransmission(false);
Wire.requestFrom(ENS, (uint8_t)n);
for (int i = 0; i < n; i++) d[i] = Wire.read();
return d[0];
}
void readSensors() {
sensors_event_t h, t;
aht.getEvent(&h, &t);
tC = t.temperature; rh = h.relative_humidity;
// compensation: temperature in Kelvin x 64, humidity x 512 (little-endian)
uint16_t tk = (uint16_t)((tC + 273.15) * 64), hr = (uint16_t)(rh * 512);
uint8_t comp[4] = { (uint8_t)tk, (uint8_t)(tk >> 8), (uint8_t)hr, (uint8_t)(hr >> 8) };
ensWrite(0x13, comp, 4);
uint8_t st;
ensRead(0x20, &st, 1); // DEVICE_STATUS
int validity = (st >> 2) & 0x03; // 0 ok, 1 warm-up, 2 initial start-up
state = validity == 0 ? "ok" : validity == 1 ? "warming up" : "first start-up (up to 1 h)";
if (st & 0x02) { // NEWDAT
uint8_t d[5];
ensRead(0x21, d, 5); // AQI, TVOC (2), eCO2 (2)
aqi = d[0] & 0x07;
tvoc = d[1] | (d[2] << 8);
eco2 = d[3] | (d[4] << 8);
}
}
void handleRoot() {
static const char* label[] = {"-", "Excellent", "Good", "Moderate", "Poor", "Unhealthy"};
String html = "<!doctype html><meta name=viewport content='width=device-width'>"
"<meta http-equiv=refresh content=5><body style='font:18px sans-serif;max-width:420px;margin:30px auto'>"
"<h2>Air quality</h2>";
html += "<p style='font-size:42px;margin:0'><b>" + String(aqi) + "</b> " + label[aqi] + "</p>";
html += "<p>TVOC " + String(tvoc) + " ppb<br>eCO2 " + String(eco2) + " ppm<br>";
html += String(tC, 1) + " °C · " + String(rh, 0) + " %RH</p>";
html += "<p style='color:#888'>Sensor: " + String(state) + "</p>";
server.send(200, "text/html", html);
}
void setup() {
Serial.begin(115200);
Wire.begin(6, 7);
if (!aht.begin()) Serial.println("AHT20 not found (0x38)");
uint8_t mode = 0x02; // OPMODE = standard
ensWrite(0x10, &mode, 1);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
Serial.print("\nOpen http://"); Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.begin();
}
void loop() {
static unsigned long last = 0;
if (millis() - last > 2000) { last = millis(); readSensors(); }
server.handleClient();
}
Troubleshooting
Stuck on “first start-up”
Normal on the very first power-up of a new ENS160 — leave it running for an hour. After that, warm-up takes about 3 minutes.
eCO₂ reads high in a clean room
eCO₂ is estimated from VOCs, not measured. For true CO₂ use the ET-S02 SCD41 (see the Indoor Air Quality Monitor project).
No IP address appears
Check the Wi-Fi name and password, and that the network is 2.4 GHz.
Take it further
- Add the ET-D07 LED ring as a green/amber/red air light
- Expose the readings to Home Assistant with ESPHome
- Log readings to the ET-A14 FRAM for a 24-hour graph
Turning this into a product?
We design custom boards that combine these sensors on one PCB, with firmware and production.