Bertrand Selva
Published © CC BY-NC

Compact & Low-Cost ESP32 IoT Air Quality Monitor (AP Mode)

Invisible indoor pollutants? This ESP32 IoT node reveals them—TVOC, eCO₂, temp & humidity—via its own Wi-Fi dashboard.

BeginnerWork in progress4 hours2,203
Compact & Low-Cost ESP32 IoT Air Quality Monitor (AP Mode)

Things used in this project

Hardware components

LILYGO T-Display S3 (ESP32-S3 + 1.9″ ST7789)
×1
ENS160+AHT21 CARBON Dioxide CO2 eCO2 TVOC Air Quality And Temperature And Humidity Sensor Replace CCS811 For Arduino
×1

Software apps and online services

Arduino IDE 2.x
ESP32 Board Support Package (“esp32 by Espressif Systems”)
TFT_eSPI
PNGdec
ScioSense _ENS160
AHTxx
WebServer (ESP32 core)
WiFi (ESP32 core)

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Dupont Wires
3D Printer (e.g., Creality Ender 3)

Story

Read more

Schematics

aqi_5BnbK3NJCv.png

Code

code

C/C++
#include <AHTxx.h>
#include <Arduino.h>
// #include <BluetoothSerial.h>  // Inclure la bibliothque BluetoothSerial
#include <PNGdec.h>
#include <TFT_eSPI.h>  // Bibliothque graphique
#include <WebServer.h>
#include <WiFi.h>
#include <Wire.h>
#include <image.h>

#include "SPI.h"
#include "ScioSense_ENS160.h"  // ENS160 library

const char *ssid = "ESP8266_Mesure";
const char *password = "12345678";

// Configuration de l'IP fixe
IPAddress local_IP(192, 168, 4, 22);  // Adresse IP fixe
IPAddress gateway(192, 168, 4, 1);
IPAddress subnet(255, 255, 255, 0);
WebServer server(80);

// Dfinition des constantes
#define MAX_IMAGE_WIDTH 240  // Ajustez pour vos images

// Dfinition des variables
int16_t xpos = 0;
int16_t ypos = 0;
float temperature, humidity, eCO2, TVOC;
int AQI = 0;
int AQI_precedent = 0;
uint16_t compteur = 0;

// Dfinition des variables pour les bibliothques
TFT_eSPI tft = TFT_eSPI();  // Dclaration de l'instance de l'cran
ScioSense_ENS160 ens160(ENS160_I2CADDR_1);
AHTxx aht20(AHTXX_ADDRESS_X38, AHT2x_SENSOR);  // Adresse du capteur, type du capteur
PNG png;                                       // Instance du dcodeur PNG
// BluetoothSerial SerialBT;                      // Instance de la bibliothque BluetoothSerial

/// FONCTIONS

// Page principale
void handleRoot() {
  server.sendHeader("Cache-Control", "no-store");
  String html =
      "<!DOCTYPE html>\
      <html>\
      <head>\
      <meta charset='UTF-8'>\
      <title>ESP32 Mesures</title>\
      <style>\
      body { font-family: Arial, sans-serif; text-align: center; background-color: #f4f4f9; color: #333; margin: 0; padding: 0; }\
      h1 { font-size: 28px; margin-top: 20px; }\
      ul { list-style: none; padding: 0; font-size: 20px; }\
      li { margin: 15px 0; }\
      .aqi-bar { width: 80%; height: 25px; margin: 20px auto; background: linear-gradient(to right, green, yellow, orange, red); position: relative; border-radius: 5px; overflow: hidden; }\
      .aqi-fill { height: 100%; background: white; width: " +
      String((1 - (AQI / 5.0)) * 100) +
      "%; position: absolute; right: 0; top: 0; }\
      footer { margin-top: 20px; font-size: 14px; color: #666; }\
      </style>\
      <script>\
      setTimeout(function() { location.reload(true); }, 1000); // Force le rechargement toutes les secondes\
      </script>\
      </head>\
      <body>\
      <h1>ESP32 - Mesures de Qualit de l'Air</h1>\
      <ul>\
      <li><strong>Temprature :</strong> " +
      String(temperature, 2) +
      " C</li>\
      <li><strong>Humidit :</strong> " +
      String(humidity, 2) +
      " %</li>\
      <li><strong>eCO2 :</strong> " +
      String(eCO2, 0) +
      " ppm</li>\
      <li><strong>TVOC :</strong> " +
      String(TVOC, 0) +
      " ppb</li>\
      <li><strong>AQI :</strong> " +
      String(AQI) +
      "</li>\
      </ul>\
      <div class='aqi-bar'>\
        <div class='aqi-fill'></div>\
      </div>\
      <footer>Page mise  jour automatiquement toutes les secondes</footer>\
      </body>\
      </html>";
  server.send(200, "text/html", html);
}

// Fonction de rappel pour dessiner des pixels sur l'cran
void pngDraw(PNGDRAW *pDraw) {
  uint16_t lineBuffer[MAX_IMAGE_WIDTH];
  png.getLineAsRGB565(pDraw, lineBuffer, PNG_RGB565_BIG_ENDIAN, 0xffffffff);
  tft.pushImage(xpos, ypos + pDraw->y, pDraw->iWidth, 1, lineBuffer);
}

void fond_ecran() {
  tft.fillScreen(TFT_BLACK);  // Efface l'cran
  tft.setCursor(20, 10);
  tft.setTextColor(TFT_GREEN);
  tft.printf("Temp: ", temperature);
  tft.setCursor(20, 30);
  tft.printf("Hum.: ", humidity);
  tft.setCursor(20, 55);
  tft.printf("eCO2: ", eCO2);
  tft.setCursor(20, 75);
  tft.printf("TVOC: ", TVOC);
}

void miseajour_ecran() {
  tft.fillRect(105, 0, 120, 94, TFT_BLACK);
  tft.setTextSize(2);
  tft.setTextColor(TFT_WHITE);
  tft.setCursor(105, 10);
  tft.printf("%.2f C\n", temperature);
  tft.setCursor(105, 30);
  tft.printf("%.2f %%\n", humidity);
  tft.setCursor(105, 55);
  tft.printf("%.0f ppm \n", eCO2);
  tft.setCursor(105, 75);
  tft.printf("%.0f ppb \n", TVOC);

  if (AQI_precedent != AQI) {
    tft.fillRect(17, 100, 3, 25, TFT_BLACK);
    tft.fillRect(220, 100, 3, 25, TFT_BLACK);
    tft.fillRectHGradient(20, 100, 200, 25, TFT_GREEN, TFT_RED);
    tft.setTextSize(3);
    tft.setTextColor(TFT_BLACK);
    tft.setCursor(95, 101);
    tft.printf("AQI");
    float position = 18 + 200 * (25 * (AQI - 1) / 100.00);
    tft.fillRect((int)position, 100, 4, 25, TFT_YELLOW);
  }
}

/*--------------------------------------------------------------------------
  SETUP function
  initiate sensor
 --------------------------------------------------------------------------*/
void setup() {
  // Initialisation cran
  tft.init();
  tft.setRotation(1);  // Rotation de l'cran, ajustez selon vos besoins
  tft.fillScreen(TFT_BLACK);

  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.setTextSize(2);
  tft.setCursor(0, 0);

  int16_t rc = png.openFLASH((uint8_t *)fond_reduit, sizeof(fond_reduit), pngDraw);
  if (rc == PNG_SUCCESS) {
    Serial.println("Fichier PNG ouvert avec succs");
    tft.startWrite();
    uint32_t dt = millis();
    rc = png.decode(NULL, 0);
    Serial.print(millis() - dt);
    Serial.println("ms");
    tft.endWrite();
  }

  // Initialiser le bus I2C avec les pins spcifies
  Wire.begin(21, 22);

  Serial.begin(9600);

  // // Initialisation Bluetooth
  // SerialBT.begin("ESP32_AirQuality"); // Nom du priphrique Bluetooth
  // Serial.println("Le priphrique est prt  jumeler!");
  // Configuration de l'IP fixe
  WiFi.config(local_IP, gateway, subnet);
  WiFi.softAP(ssid, password);
  IPAddress IP = WiFi.softAPIP();
  Serial.print("Point d'accs IP address: ");
  Serial.println(IP);

  server.on("/", handleRoot);
  server.begin();
  Serial.println("Serveur web dmarr");

  while (!Serial) {
  }

  delay(500);

  Serial.print("ENS160...");
  ens160.begin();
  Serial.println(ens160.available() ? "done." : "failed!");
  if (ens160.available()) {
    // Print ENS160 versions
    Serial.print("\tRev: ");
    Serial.print(ens160.getMajorRev());
    Serial.print(".");
    Serial.print(ens160.getMinorRev());
    Serial.print(".");
    Serial.println(ens160.getBuild());

    ens160.setMode(ENS160_OPMODE_RESET);
    delay(100);
    ens160.setMode(ENS160_OPMODE_STD);

    // Initialisation AHT20
    while (aht20.begin() != true) {
      Serial.println(F("AHT2x non connect ou chec du chargement des coefficients de calibration"));
      delay(5000);
    }
    Serial.println(F("AHT20 OK"));
  }

  fond_ecran();
}

/*--------------------------------------------------------------------------
  MAIN LOOP FUNCTION
  Cycle every 1000ms and perform measurement
 --------------------------------------------------------------------------*/

void loop() {
  if (ens160.available()) {
    temperature = aht20.readTemperature();  // read 6-bytes via I2C, takes 80 milliseconds
    humidity = aht20.readHumidity();
    ens160.set_envdata(temperature, humidity);

    ens160.measure(true);
    ens160.measureRaw(true);
    AQI_precedent = AQI;
    AQI = ens160.getAQI();
    TVOC = ens160.getTVOC();
    eCO2 = ens160.geteCO2();

    // Envoyer les donnes par Bluetooth
    // SerialBT.printf("Temp: %.2f C, Hum: %.2f %%, eCO2: %.0f ppm, TVOC: %.0f ppb, AQI: %d\n", temperature, humidity, eCO2, TVOC, AQI);
  }
  miseajour_ecran();
  server.handleClient();
  delay(1000);

  compteur++;
  if (compteur > 300) {
    compteur = 0;
    ens160.setMode(ENS160_OPMODE_RESET);
    delay(250);
    ens160.setMode(ENS160_OPMODE_STD);
  }
}

Credits

Bertrand Selva
2 projects • 4 followers
I build low-power embedded systems with smart sensors and edge AI. I work on real-time firmware, data collection, and embedded deep learning
Thanks to Bertrand.

Comments