Перевод статьи How to Build an AI-based Air Quality Monitoring using ESP32Mohammed Shaheen K S, Circuit Digest.

Воздух нам, людям, жизненно необходим, поэтому следить за ним важно. Качество воздуха оценивают через индекс качества воздуха (AQI, Air Quality Index) — единую числовую шкалу, которая показывает, насколько воздух загрязнён и какие последствия для здоровья это может иметь. Взвешенные частицы (Particulate Matter, PM) — это микроскопические твёрдые или жидкие частицы в воздухе в виде пыли или дыма; именно они — главное, что измеряется в AQI. Поэтому монитор качества воздуха на ESP32 — такой полезный DIY-проект: он превращает невидимое пылевое загрязнение в число, которое можно увидеть, отслеживать и на которое можно реагировать.

В этом руководстве мы соберём монитор качества воздуха на ESP32 с элементами ИИ, чтобы измерять эти взвешенные частицы. Он выводит количество PM разных размеров на TFT-дисплей и в облако CircuitDigest Cloud. Когда показания превышают порог, он сообщает, что окружающий воздух небезопасен, и отправляет оповещение на привязанный номер WhatsApp — это добавляет проекту практический слой безопасности, которого нет у простого вывода с датчика.

Что такое монитор качества воздуха на ESP32 и почему важен AQI#

Монитор качества воздуха на ESP32 — это беспроводное устройство, способное измерять уровень загрязнения и выдавать результат в виде AQI. В отличие от простого пылевого датчика со светодиодом, у этого монитора есть три возможности, которых обычно нет в DIY-проектах: полная спецификация показаний PM, логирование в облако с ИИ-порогами и оповещения в WhatsApp.

Ключевые преимущества#

  • Мониторинг в реальном времени — обрабатывает данные по 10 параметрам с интервалом 5 секунд.
  • Питание от аккумулятора — может работать от одной батареи 18650 с повышающим преобразователем.
  • Облачная панель — каждое показание логируется и отображается на графиках через сервис CircuitDigest Cloud по MQTT.
  • Мгновенные уведомления — приходят в WhatsApp, когда AQI переходит в категорию «плохо».
  • Дисплей — показания видны на экране 2,4″ без телефона.

Как это работает#

При включении монитор подключается к Wi-Fi, и на TFT-дисплее и в CircuitDigest Cloud (по MQTT) отображаются: индекс AQI, масса PM1.0, PM2.5, PM4, PM10, число частиц PM0.5, PM1.0, PM2.5, PM4.0, PM10 — а также пометка, безопасен ли конкретный параметр. При достижении заданного порога дисплей показывает, что параметр небезопасен, и на номер WhatsApp уходит оповещение.

Таблица порогов#

ПараметрПорог
1Масса PM1.030 мкг/м³
2Масса PM2.560 мкг/м³
3Масса PM4.080 мкг/м³
4Масса PM10100 мкг/м³
5Число частиц PM0.51000 частиц/см³
6Число частиц PM1.0800 частиц/см³
7Число частиц PM2.5700 частиц/см³
8Число частиц PM4500 частиц/см³
9Число частиц PM10400 частиц/см³

Диапазоны AQI и категории#

Диапазон AQIКатегория
10–50Хорошо (Good)
251–100Удовлетворительно (Satisfactory)
3101–200Умеренно (Moderate)
4201–300Плохо (Poor)
5301–400Очень плохо (Very Poor)
6401–500Опасно (Severe)

Компоненты#

КомпонентХарактеристикаКол-во
1МикроконтроллерESP32 Dev Kit1
2Датчик качества воздуха SensirionSPS301
3TFT-дисплей2.4 TFT SPI 240×320 (TJCTM24024-SPI)1
4Модуль зарядкиTP4056A1
5Повышающий преобразовательV599 (CKCS BS01)1
6Аккумулятор18650, 3,7 В1

Схема: подключение SPS30 к ESP32#

Схема подключения датчика SPS30, TFT-дисплея и питания к ESP32

Схема монитора качества воздуха на ESP32.

Схема довольно простая. Плюс аккумулятора 18650 (3,7 В) — к клемме B+ модуля TP4056, минус — к B-. Клемму OUT+ модуля TP4056 — к боковой клемме тумблера, средний контакт тумблера — к IN+ повышающего преобразователя, OUT- модуля TP4056 — к IN- преобразователя. OUT+ преобразователя — к пину VIN ESP32, OUT- — к GND ESP32.

VCC датчика SPS30 — к VIN ESP32, GND — к GND ESP32, RX датчика — к TX2 (GPIO 17) ESP32, TX датчика — к RX2 (GPIO 16) ESP32. VCC TFT-дисплея — к 3.3 В ESP32, GND — к GND, CS — к GPIO 2, RESET — к GPIO 4, DC — к GPIO 5, SDI (MOSI) — к GPIO 23, SCK — к GPIO 18, LED — к 3.3 В, SDO (MISO) — к GPIO 19.

SPS30 против PMS5003 и SDS011: как выбрать датчик PM#

Sensirion SPS30 — не единственный датчик PM для ESP32. Ниже — сравнение с двумя другими популярными датчиками на рассеянии лазера.

ХарактеристикаSensirion SPS30Plantower PMS5003Nova SDS011
Массовая концентрацияPM1.0, PM2.5, PM4.0, PM10PM1.0, PM2.5, PM10только PM2.5, PM10
Счётчик числа частицДа — 5 диапазонов (0,5–10 мкм)Да — 6 диапазонов (0,3–10 мкм)Нет
ПринципРассеяние лазераРассеяние лазераРассеяние лазера
ИнтерфейсUART или I2CUARTUART или PWM
Лучше всего дляПолноспектрального AQI, облачных панелейБюджетных комнатных проектовПростых логгеров PM2.5/PM10

Мы выбрали SPS30, потому что это единственный из трёх, кто выдаёт все девять параметров массы и числа частиц, которые нужны нашей панели и логике порогов.

Настройка CircuitDigest Cloud по шагам#

Шаг 1 ⇒ Регистрация / вход. Зарегистрируйтесь или войдите в аккаунт CircuitDigest Cloud, указав действующий email и пароль.

Экран регистрации и входа CircuitDigest Cloud

Регистрация и вход в CircuitDigest Cloud.

Шаг 2 ⇒ Добавить устройство. После входа нажмите Dashboard вверху, выберите Device слева, нажмите Add New Device и задайте имя проекта (у нас — Air_Quality_Monitor). Нажмите синюю кнопку Add Device.

Добавление нового устройства в панель CircuitDigest Cloud

Добавление нового устройства.

Шаг 3 ⇒ Добавление переменных. Создав устройство, нажмите Variables слева, выберите созданное устройство из списка и нажмите Add Variable. Сначала создадим переменную для массы PM1.0: задайте имя, выберите ключ (мы выбрали analog-input-1), направление bidirectional, единицу μg/m³ и нажмите Create Variable. То же проделайте ещё для 9 переменных, меняя единицы.

Создание переменных для данных датчика PM в облаке

Создание переменных под параметры датчика.

Шаг 4 ⇒ Список переменных. Десять переменных с ключами: analog-input-1 — масса PM1.0, analog-input-2 — масса PM2.5, analog-input-3 — масса PM4.0, analog-input-4 — масса PM10, analog-input-5 — число PM0.5, analog-input-6 — число PM1.0, analog-input-7 — число PM2.5, analog-input-8 — число PM4.0, analog-input-9 — число PM10, analog-input-10 — индекс AQI.

Список 10 переменных CircuitDigest Cloud, сопоставленных параметрам датчика

Список из 10 переменных.

Шаг 5 ⇒ Добавление виджета. Нажмите Dashboard слева, выберите устройство. Нажмите Add Widget → Value Display Widget. В меню выберите устройство, задайте имя (PM 1.0 mass), переменную analog-input-1, единицу μg/m³ и нажмите Add Widget. То же для остальных 9 виджетов. Для массы PM единица μg/m³, для числа PM — particles/cm³.

Добавление виджета вывода значений в облачную панель

Добавление виджета вывода значения.

Шаг 6 ⇒ Интеграция WhatsApp API. Нажмите вкладку Home вверху, прокрутите вниз до WhatsApp notification и нажмите. Выберите Link Number, укажите свой номер — придёт OTP-код. Подтвердите его — настройка WhatsApp API завершена.

Настройка WhatsApp API для оповещений о качестве воздуха

Интеграция WhatsApp API для оповещений.

Разбор прошивки#

Прошивка написана в Arduino IDE. Ниже — разбор ключевых блоков кода.

#define WIFI_SSID      "xxxxx" //your Wi-Fi SSID
#define WIFI_PASS      "xxxxx" //your Wi-Fi password
#define DEVICE_ID      "xxxxxxxxxx" // Physical Device ID
#define CONNECTION_KEY "xxxxxxxxxx" // Connection Key
#define API_KEY        "xxxxxxxxxx" // API Key
// WhatsApp Notification Settings
#define WHATSAPP_PHONE     "xxxxxxxxxx"   // Linked phone number with country code

Данные сети и авторизация в облаке#

Здесь хранятся учётные данные для подключения к Wi-Fi и облаку: имя сети, пароль, ID устройства, ключ подключения и API-ключ. Не забудьте заменить все значения на свои.

// --- Layout: 2 columns x 5 rows = 10 boxes, 240x320 Portrait ---
const int boxW = 120; // 2 cols * 120 = 240px width
const int boxH = 64;  // 5 rows * 64  = 320px height
const int cols = 2;
const int rows = 5;
struct ParamBox {
 const char* label;
 const char* unit;
 float threshold;
 bool  isCPCB;
};
ParamBox params[10] = {
 { "PM1.0 MASS", "ug/m3",  30.0,  false },
 { "PM2.5 MASS", "ug/m3",  60.0,  true  },
 { "PM4.0 MASS", "ug/m3",  80.0,  false },
 { "PM10 MASS",  "ug/m3",  100.0, true  },
 { "PM0.5 NUM",  "#/cm3",  1000.0,false },
 { "PM1.0 NUM",  "#/cm3",  800.0, false },
 { "PM2.5 NUM",  "#/cm3",  700.0, false },
 { "PM4.0 NUM",  "#/cm3",  500.0, false },
 { "PM10 NUM",   "#/cm3",  400.0, false },
 { "CPCB AQI",  "",       100.0, true  }
};

Разметка TFT-экрана и привязка метрик#

Этот участок настраивает интерфейс TFT-дисплея — сетку 2×5 из ячеек по 120×64 пикселя. Здесь же заданы пороги параметров.

Последовательность запуска#

void setup() {
 // 1. Disable brownout detector to prevent rebooting on battery voltage dips
 WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);
 Serial.begin(115200);
 delay(2000); // Initial 2s delay for power supply voltage stabilization
 ...
}

Эта функция выполняет поэтапный «мягкий старт», чтобы избежать просадки питания (brownout) от батареи: сначала 2 секунды на выход повышающего преобразователя на 5 В, затем 5 секунд на включение дисплея и ещё 5 секунд на запуск датчика SPS30. После этого включается Wi-Fi, а его мощность передачи снижается до 15 дБм, чтобы уменьшить пиковый ток.

Отказоустойчивое переподключение Wi-Fi#

if (WiFi.status() != WL_CONNECTED) {
   Serial.println("Wi-Fi Disconnected!");
   Serial.println("Shutting down sensor and restarting system to execute staged soft-start...");
   // Stop SPS30 fan before resetting system
   sensor.stopMeasurement();
   // 2-second delay to settle power grid before rebooting
   delay(2000);
   // Trigger full ESP32 microcontroller software reboot
   ESP.restart();
 }

Этот блок следит за состоянием Wi-Fi. Если связь пропала, он немедленно останавливает мотор датчика, чтобы сберечь батарею, и перезагружает систему.

Отправка в облако по MQTT и контроль порога AQI#

// 4. Check CPCB AQI Threshold for WhatsApp Alert (Poor Category > 200)
   if (cpcbAqi > AQI_POOR_THRESHOLD) {
     if (!whatsappAlertTriggered) {
       Serial.println("CPCB AQI in POOR Category (>200)! Triggering WhatsApp Alert...");
       sendWhatsAppAlert(cpcbAqi);
       whatsappAlertTriggered = true; // Lock alert until AQI recovers below 200
     }
   } else {
     // Reset alert state when AQI drops back to Safe/Moderate range (<= 200)
     whatsappAlertTriggered = false;
   }

Основной цикл выполняется каждые 5 секунд: он снимает массовую концентрацию, число частиц и индекс AQI с датчика SPS30, обрабатывая ошибки связи. Затем устройство публикует все 10 значений в CDcloud по MQTT двумя последовательными JSON-пакетами с интервалом 200 мс (чтобы избежать потери пакетов) и проверяет AQI на порог 200. При превышении инициируется оповещение в WhatsApp с блокировкой, чтобы не спамить сообщениями.

Результат#

Живые показания PM и AQI на TFT-дисплее монитора

Живые показания всех 10 параметров PM и AQI на TFT-дисплее.

Монитор выводит показания всех 10 параметров взвешенных частиц в виде сетки из 5 строк и 2 столбцов, а также отображает и хранит данные в CircuitDigest Cloud. При достижении заданного порога AQI отправляется оповещение в WhatsApp на номер из кода (он должен быть зарегистрирован в WhatsApp API CircuitDigest Cloud).

Устранение неполадок#

ПроблемаРешение
Датчик не включается после подачи питанияУбедитесь, что VCC у SPS30 подключён к 5 В или VIN ESP32, а не к 3.3 В
Какой аккумулятор использоватьИзбегайте LiPo на 800 мА·ч 3,7 В — вентилятор SPS30 при старте берёт около 800 мА. Используйте элемент 18650
Система не подключается к Wi-FiНастройте точку доступа/роутер на 2,4 ГГц — ESP32 не поддерживает 5 ГГц
Куда ставить выключательНе ставьте выключатель между OUT+ преобразователя и VIN ESP32 — преобразователь остаётся включённым. Ставьте между OUT+ TP4056 и IN+ преобразователя
Мерцание TFT-дисплеяОбычно указывает на проблему с питанием; проверьте чистые 3.3 В с пина 3.3V ESP32

Полный код проекта#

// Copyright (c) 2026 Jobit Joseph, Circuit Digest
// SPDX-License-Identifier: MIT
// =====================================================================
// ESP32 + SPS30 (UART) + ILI9341 + CircuitDigestCloud + WhatsApp Alert
// 2x5 Dashboard (PORTRAIT MODE) + Dual-batch MQTT + Indian CPCB AQI
// Extended 5-Second Soft-Start, Brownout Handling & Auto-Reset on Wi-Fi Loss
// =====================================================================
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <SPI.h>
#include <SensirionUartSps30.h>
#include <CircuitDigestCloud.h>
#include <WiFiClientSecure.h>
// ESP32 System Headers for Brownout Detector Configuration
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"
// ── Fill in your credentials ────────────────────────────────────────────────
#define WIFI_SSID      "your-Wi-Fi SSID"       // your Wi-Fi SSID
#define WIFI_PASS      "your-Wi-Fi password"   // your Wi-Fi password
#define DEVICE_ID      "your-device-id"        // Physical Device ID
#define CONNECTION_KEY "your-connection-key"   // Connection Key
#define API_KEY        "your-api-key"          // API Key
// WhatsApp Notification Settings
#define WHATSAPP_PHONE     "your-registered-phone-number" // Linked phone number with country code
#define AQI_POOR_THRESHOLD 200.0f                         // CPCB limit for POOR Air Quality (AQI > 200)
// Cloud Variable Keys
#define KEY_PM1_0_MASS "analog-input-1"
#define KEY_PM2_5_MASS "analog-input-2"
#define KEY_PM4_0_MASS "analog-input-3"
#define KEY_PM10_MASS  "analog-input-4"
#define KEY_PM0_5_NUM  "analog-input-5"
#define KEY_PM1_0_NUM  "analog-input-6"
#define KEY_PM2_5_NUM  "analog-input-7"
#define KEY_PM4_0_NUM  "analog-input-8"
#define KEY_PM10_NUM   "analog-input-9"
#define KEY_AQI        "analog-input-10" // Indian CPCB AQI
// ────────────────────────────────────────────────────────────────────────────
#define SENSOR_SERIAL_INTERFACE Serial2   // ESP32 HW Serial2 -> RX16 / TX17
#ifdef NO_ERROR
#undef NO_ERROR
#endif
#define NO_ERROR 0
CircuitDigestCloud CDcloud;
SensirionUartSps30 sensor;
static char errorMessage[64];
static int16_t error;
// Flag to track alert state and prevent spamming WhatsApp messages
static bool whatsappAlertTriggered = false;
// --- TFT Pin Configurations ---
#define TFT_CS   2   // GPIO 2
#define TFT_DC   5   // GPIO 5
#define TFT_RST  4   // GPIO 4
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
// --- Layout: 2 columns x 5 rows = 10 boxes, 240x320 Portrait ---
const int boxW = 120; // 2 cols * 120 = 240px width
const int boxH = 64;  // 5 rows * 64  = 320px height
const int cols = 2;
const int rows = 5;
struct ParamBox {
 const char* label;
 const char* unit;
 float threshold;
 bool  isCPCB;
};
ParamBox params[10] = {
 { "PM1.0 MASS", "ug/m3",  30.0,  false },
 { "PM2.5 MASS", "ug/m3",  60.0,  true  },
 { "PM4.0 MASS", "ug/m3",  80.0,  false },
 { "PM10 MASS",  "ug/m3",  100.0, true  },
 { "PM0.5 NUM",  "#/cm3",  1000.0,false },
 { "PM1.0 NUM",  "#/cm3",  800.0, false },
 { "PM2.5 NUM",  "#/cm3",  700.0, false },
 { "PM4.0 NUM",  "#/cm3",  500.0, false },
 { "PM10 NUM",   "#/cm3",  400.0, false },
 { "CPCB AQI",  "",       100.0, true  }
};
void drawDashboardGrid();
void updateBox(int i, float value);
void sendWhatsAppAlert(float aqiValue);
float calculateAQI_CPCB(float pm25, float pm10);
void setup() {
 // 1. Disable brownout detector to prevent rebooting on battery voltage dips
 WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);
 Serial.begin(115200);
 delay(2000); // Initial 2s delay for power supply voltage stabilization
 // --- Step 1: Initialize Display ---
 Serial.println("[Boot Step 1/3] Starting TFT Display...");
 tft.begin();
 tft.setRotation(0);             // Portrait mode, 240x320
 tft.fillScreen(ILI9341_BLACK);
 drawDashboardGrid();
 Serial.println("Waiting 5 seconds for display power stabilization...");
 delay(5000); // 5-second pause to isolate display power draw
 // --- Step 2: Initialize SPS30 Sensor ---
 Serial.println("[Boot Step 2/3] Starting SPS30 Sensor...");
 SENSOR_SERIAL_INTERFACE.begin(115200, SERIAL_8N1, 16, 17);
 sensor.begin(SENSOR_SERIAL_INTERFACE);
 sensor.stopMeasurement();
 int8_t serialNumber[32] = {0};
 error = sensor.readSerialNumber(serialNumber, 32);
 if (error != NO_ERROR) {
   Serial.print("Error reading serial number: ");
   errorToString(error, errorMessage, sizeof errorMessage);
   Serial.println(errorMessage);
 }
 error = sensor.startMeasurement(SPS30_OUTPUT_FORMAT_OUTPUT_FORMAT_FLOAT);
 if (error != NO_ERROR) {
   Serial.print("Error starting measurement: ");
   errorToString(error, errorMessage, sizeof errorMessage);
   Serial.println(errorMessage);
 } else {
   Serial.println("SPS30 Measurement started successfully.");
 }
 Serial.println("Waiting 5 seconds for SPS30 internal fan to stabilize...");
 delay(5000); //5-second pause to let fan motor reach full operational speed
 // --- Step 3: Lower Wi-Fi TX Power & Connect to Cloud ---
 Serial.println("[Boot Step 3/3] Turning on Wi-Fi & Connecting to Cloud...");
 
 // Lower Wi-Fi transmit power to prevent voltage sags on battery power
 WiFi.setTxPower(WIFI_POWER_15dBm);
 if (!CDcloud.begin(WIFI_SSID, WIFI_PASS, DEVICE_ID, CONNECTION_KEY, API_KEY)) {
   Serial.println("CDcloud begin() failed — check credentials. Rebooting...");
   delay(2000);
   ESP.restart(); // Reset system if initial cloud connection fails
 } else {
   Serial.println("CDcloud initialized successfully.");
 }
 Serial.println("Boot sequence complete. Entering main loop.");
}
void loop() {
 // Drive WiFi reconnect, MQTT connection, and auto-heartbeat
 CDcloud.loop();
 // ── Wi-Fi Disconnection & Full System Reboot Handler ───────────────────────
 if (WiFi.status() != WL_CONNECTED) {
   Serial.println("Wi-Fi Disconnected!");
   Serial.println("Shutting down sensor and restarting system to execute staged soft-start...");
   // Stop SPS30 fan before resetting system
   sensor.stopMeasurement();
   // 2-second delay to settle power grid before rebooting
   delay(2000);
   // Trigger full ESP32 microcontroller software reboot
   ESP.restart();
 }
 // ──────────────────────────────────────────────────────────────────────────
 static uint32_t lastPublish = 0;
 if (millis() - lastPublish >= 5000) { // Update every 5 seconds
   lastPublish = millis();
   float mc1p0 = 0, mc2p5 = 0, mc4p0 = 0, mc10p0 = 0;
   float nc0p5 = 0, nc1p0 = 0, nc2p5 = 0, nc4p0 = 0, nc10p0 = 0;
   float typicalParticleSize = 0;
   error = sensor.readMeasurementValuesFloat(mc1p0, mc2p5, mc4p0, mc10p0,
                                              nc0p5, nc1p0, nc2p5, nc4p0,
                                              nc10p0, typicalParticleSize);
   if (error != NO_ERROR) {
     Serial.print("Error reading SPS30 values: ");
     errorToString(error, errorMessage, sizeof errorMessage);
     Serial.println(errorMessage);
     return;
   }
   // Compute Indian CPCB AQI based on PM2.5 and PM10
   float cpcbAqi = calculateAQI_CPCB(mc2p5, mc10p0);
   float values[10] = {
     mc1p0, mc2p5, mc4p0, mc10p0,
     nc0p5, nc1p0, nc2p5, nc4p0, nc10p0,
     cpcbAqi
   };
   // 1. Refresh local TFT screen boxes
   for (int i = 0; i < 10; i++) {
     updateBox(i, values[i]);
   }
   // 2. Publish BATCH 1 (5 Parameters)
   CDcloud.publish({
     {KEY_PM1_0_MASS, mc1p0},
     {KEY_PM2_5_MASS, mc2p5},
     {KEY_PM4_0_MASS, mc4p0},
     {KEY_PM10_MASS,  mc10p0},
     {KEY_PM0_5_NUM,  nc0p5}
   });
   delay(200);
   // 3. Publish BATCH 2 (5 Parameters)
   CDcloud.publish({
     {KEY_PM1_0_NUM,  nc1p0},
     {KEY_PM2_5_NUM,  nc2p5},
     {KEY_PM4_0_NUM,  nc4p0},
     {KEY_PM10_NUM,   nc10p0},
     {KEY_AQI,        cpcbAqi}
   });
   Serial.println("SPS30 metrics published to Cloud.");
   // 4. Check CPCB AQI Threshold for WhatsApp Alert (Poor Category > 200)
   if (cpcbAqi > AQI_POOR_THRESHOLD) {
     if (!whatsappAlertTriggered) {
       Serial.println(" CPCB AQI in POOR Category (>200)! Triggering WhatsApp Alert...");
       sendWhatsAppAlert(cpcbAqi);
       whatsappAlertTriggered = true; // Lock alert until AQI recovers below 200
     }
   } else {
     // Reset alert state when AQI drops back to Safe/Moderate range (<= 200)
     whatsappAlertTriggered = false;
   }
 }
}
// ---------------------------------------------------------------
// Indian CPCB AQI Calculation Sub-functions
// ---------------------------------------------------------------
float getSubIndex_PM25(float pm25) {
 if (pm25 <= 0.0f)   return 0.0f;
 if (pm25 <= 30.0f)  return (50.0f / 30.0f) * pm25;
 if (pm25 <= 60.0f)  return 51.0f + ((100.0f - 51.0f) / (60.0f - 30.0f)) * (pm25 - 30.0f);
 if (pm25 <= 90.0f)  return 101.0f + ((200.0f - 101.0f) / (90.0f - 60.0f)) * (pm25 - 60.0f);
 if (pm25 <= 120.0f) return 201.0f + ((300.0f - 201.0f) / (120.0f - 90.0f)) * (pm25 - 90.0f);
 if (pm25 <= 250.0f) return 301.0f + ((400.0f - 301.0f) / (250.0f - 120.0f)) * (pm25 - 120.0f);
 return 401.0f + ((500.0f - 401.0f) / (150.0f)) * (pm25 - 250.0f);
}
float getSubIndex_PM10(float pm10) {
 if (pm10 <= 0.0f)   return 0.0f;
 if (pm10 <= 50.0f)  return (50.0f / 50.0f) * pm10;
 if (pm10 <= 100.0f) return 51.0f + ((100.0f - 51.0f) / (100.0f - 50.0f)) * (pm10 - 50.0f);
 if (pm10 <= 250.0f) return 101.0f + ((200.0f - 101.0f) / (250.0f - 100.0f)) * (pm10 - 100.0f);
 if (pm10 <= 350.0f) return 201.0f + ((300.0f - 201.0f) / (350.0f - 250.0f)) * (pm10 - 250.0f);
 if (pm10 <= 430.0f) return 301.0f + ((400.0f - 301.0f) / (430.0f - 350.0f)) * (pm10 - 430.0f);
 return 401.0f + ((500.0f - 401.0f) / (70.0f)) * (pm10 - 430.0f);
}
float calculateAQI_CPCB(float pm25, float pm10) {
 float subPM25 = getSubIndex_PM25(pm25);
 float subPM10 = getSubIndex_PM10(pm10);
 return (subPM25 > subPM10) ? subPM25 : subPM10;
}
// ---------------------------------------------------------------
// Function to send WhatsApp HTTP REST API alert
// ---------------------------------------------------------------
void sendWhatsAppAlert(float aqiValue) {
 WiFiClientSecure client;
 client.setInsecure(); // Bypass SSL certificate verification
 if (!client.connect("www.circuitdigest.cloud", 443)) {
   Serial.println("WhatsApp connection failed!");
   return;
 }
 String payload =
   "{\"phone_number\":\"" + String(WHATSAPP_PHONE) + "\","
   "\"template_id\":\"threshold_violation_alert\","
   "\"variables\":{"
   "\"device_name\":\"ESP32 Air Monitor\","
   "\"parameter\":\"CPCB AQI\","
   "\"measured_value\":\"" + String((int)aqiValue) + "\","
   "\"limit\":\"200 (POOR Category)\","
   "\"location\":\"Indoor Room\"}}";
 client.println("POST /api/v1/whatsapp/send HTTP/1.1");
 client.println("Host: www.circuitdigest.cloud");
 client.println("X-API-Key: " + String(API_KEY));
 client.println("Content-Type: application/json");
 client.println("Connection: close");
 client.print("Content-Length: ");
 client.println(payload.length());
 client.println();
 client.print(payload);
 Serial.println("WhatsApp API Payload Sent. Server Response:");
 while (client.connected() || client.available()) {
   if (client.available()) {
     String line = client.readStringUntil('\n');
     Serial.println(line);
   }
 }
 client.stop();
}
// ---------------------------------------------------------------
// Draw static 2x5 grid dashboard (Portrait Layout)
// ---------------------------------------------------------------
void drawDashboardGrid() {
 tft.setTextColor(ILI9341_WHITE);
 tft.setTextSize(1);
 for (int i = 0; i < 10; i++) {
   int col = i % cols;
   int row = i / cols;
   int x = col * boxW;
   int y = row * boxH;
   tft.drawRect(x, y, boxW, boxH, ILI9341_DARKGREY);
   tft.setCursor(x + 5, y + 4);
   tft.print(params[i].label);
 }
}
// ---------------------------------------------------------------
// Update content area of one dashboard slot (Portrait Layout)
// ---------------------------------------------------------------
void updateBox(int i, float value) {
 int col = i % cols;
 int row = i / cols;
 int x = col * boxW;
 int y = row * boxH;
 // Clear value and status area inside the box
 tft.fillRect(x + 2, y + 16, boxW - 4, boxH - 18, ILI9341_BLACK);
 bool hasThreshold = params[i].threshold > 0;
 bool safe = true;
 uint16_t valColor = ILI9341_CYAN;
 if (hasThreshold) {
   safe = (value <= params[i].threshold);
   valColor = safe ? ILI9341_GREEN : ILI9341_RED;
 }
 // --- Value Line ---
 tft.setTextSize(2);
 tft.setTextColor(valColor);
 tft.setCursor(x + 5, y + 20);
 
 // Format AQI without decimals, keep 1 decimal for other values
 if (i == 9) {
   tft.print(value, 0);
 } else {
   tft.print(value, 1);
 }
 // --- Unit Line ---
 tft.setTextSize(1);
 tft.setCursor(x + 5, y + 38);
 tft.setTextColor(ILI9341_WHITE);
 tft.print(params[i].unit);
 // --- Status Banner Line ---
 tft.setCursor(x + 5, y + 50);
 if (!hasThreshold) {
   tft.setTextColor(ILI9341_YELLOW);
   tft.print("INFO ONLY");
 } else if (safe) {
   tft.setTextColor(ILI9341_GREEN);
   tft.print(params[i].isCPCB ? "SAFE (CPCB)" : "SAFE");
 } else {
   tft.setTextColor(ILI9341_RED);
   tft.print(params[i].isCPCB ? "NOT SAFE(CPCB)" : "NOT SAFE");
 }
}

Исходный код, детали схемы и инструкции по сборке доступны в репозитории проекта на GitHub (ссылка — в оригинале статьи).