Community project

Desk Air Quality Monitor

Photo of Desk Air Quality Monitor
Generated with AI

ElementalTV AdOps Support - L1

Published November 8, 2026

This desk air quality monitor continuously tracks CO2 levels, particulate matter, temperature, and humidity in your workspace. Built around an ESP32 microcontroller, it combines a Sensirion SCD40 CO2 sensor with a PMS5003 particulate matter sensor to provide real-time air quality data displayed on a 2-inch TFT screen, with optional data logging to microSD card.

The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions for integrating the sensors, display, fan control circuit, RGB status indicator, and alert system. You'll also get the Arduino firmware with WiFi connectivity, web interface, Telegram notifications, and configurable thresholds so the monitor can alert you when air quality degrades and automatically trigger ventilation.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

Sensirion SCD40

Sensirion SCD40 miniaturized photoacoustic NDIR CO2 sensor with integrated temperature and humidity sensing. Measures CO2 in the range of 400-2000 ppm at specified accuracy, with a 0-40000 ppm output range. Communicates via I2C at fixed address 0x62. Supply voltage range is 2.4V-5.5V, with 3.3V or 5V typical; average current is ~18 mA at 3.3V during periodic measurement. Requires a clean, stable supply rail and has peak current around 205 mA at 3.3V. Ideal for indoor air quality monitoring applications such as baby room sensors.

1

PMS5003 Particulate Matter Sensor

Plantower PMS5003 laser particulate matter sensor. Reports PM1.0, PM2.5, and PM10 concentrations (plus six particle-size bins) once per second over a 9600-baud UART. The fan and laser diode run on 5V while the SET/RXD/TXD/RESET control and data pins are 3.3V TTL, so only power plus the sensor TX-to-MCU RX line is needed for basic readings. Ships on an 8-pin, 1.25mm-pitch cable; Adafruit sells it with a 0.1in breadboard adapter. PMS7003 is the compact-form-factor sibling in the same family and shares the same wiring/protocol.

1

ST7789 TFT Display 2.0 inch

2.0-inch IPS TFT color display breakout driven by the ST7789 controller over 4-wire SPI. Native resolution is 320x240. Adafruit's breakout includes a 3.3V regulator, auto-reset circuit, 3V/5V level shifting, and a microSD holder sharing the SPI bus. Display drawing uses SCK, MOSI, CS, DC, and optional RST; MISO and SDCS are only needed for the onboard microSD card.

1

MicroSD Card Module

SPI-based microSD card adapter module for SPI-capable microcontrollers. Uses MOSI, MISO, SCK, and CS plus power and ground. Many low-cost modules include a 3.3 V regulator and level shifting for 5 V MCU boards, while bare breakouts should be powered and signalled at 3.3 V.

1

5 V brushless DC fan

A small 5 V fan that pushes fresh air through the case when the readings are poor.

1

IRLZ44N Logic-Level N-Channel MOSFET

Logic-level N-channel MOSFET commonly used as a low-side switch for DC loads from 3.3V or 5V microcontroller GPIO. Use a gate resistor, a gate pulldown, and a flyback diode for inductive loads.

1

Piezo Buzzer

Small passive piezo buzzer (TDK PS1240) for beeps, tones and alerts. It has no built-in oscillator, so drive one lead with a 3-30 V square wave from a microcontroller pin using tone() or PWM and connect the other lead to ground. It is loudest around 4 kHz and works from about 2 to 10 kHz; driving both leads in antiphase roughly doubles the volume.

1

Rgb Led

Discrete four-pin common-cathode RGB LED with separate red, green, and blue anodes. Each colour channel needs its own current-limit resistor and can be PWM-dimmed from separate MCU GPIO pins.

1

Push Button

Silence / acknowledge

Momentary push button switch

1

Resistor

220 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

220 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

220 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

100 Ω

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

1N5819 Schottky flyback diode

A protective diode placed across the fan so switching it off does not damage the MOSFET.

Assemble it in 7 steps

1. Place the controller and power rails

Put the ESP32 DevKit on a breadboard or your two-layer PCB. Make one common ground rail and a 3.3 V rail; use the board’s 5 V/VIN rail only for the PMS5003 and fan. Power the finished station from the ESP32 USB socket.

  • Use black wire for every GND connection, red wire for 5 V, and orange wire for 3.3 V so mistakes are easier to spot.
  • Do not connect a separate 5 V supply unless its ground is also connected to the ESP32 ground; missing the shared ground makes signals unreliable.

2. Wire the CO2 sensor

Connect scd40_1 VCC to 3V3 (power), GND to GND (ground), SDA to GPIO21 (data), and SCL to GPIO22 (clock). Keep this small sensor away from the fan exhaust so it measures room air rather than a direct draft.

  • The sensor needs several minutes after first power-up before its readings settle.

3. Wire the dust sensor

Connect pms5003_1 VCC to 5V (power), GND to GND (ground), TX to GPIO16 (sensor readings), and RX to GPIO17 (sensor control). Mount it so its two air openings are not blocked by the case.

  • Use the supplied cable and adapter if your PMS5003 has the small white plug; do not force the plug backwards.
  • The PMS5003 needs 5 V for its internal fan, but its TX and RX signal wires are 3.3 V logic; do not connect either signal wire to 5 V.

4. Wire the screen and card module

For tft_1, connect VCC to 3V3 (power), GND to GND (ground), SCK to GPIO18 (shared clock), MOSI to GPIO23 (shared data), CS to GPIO5 (screen select), DC to GPIO26 (screen command/data), and BL to 3V3 (backlight power). For sd_1, connect VCC to 3V3 (power), GND to GND (ground), SCK to GPIO18 (shared clock), MOSI to GPIO23 (shared data), MISO to GPIO19 (data from card), and CS to GPIO13 (card select).

  • Format the microSD card as FAT32 before inserting it. The screen and card share three wires, but each has its own CS wire so they do not conflict.
  • Make sure VCC and GND are not swapped on either module; swapped power can damage the screen or card module.

5. Build the fan switch

Connect fan_1 VCC to 5V (power) and fan_1 GND to mosfet_1 DRAIN (switched negative). Connect mosfet_1 SOURCE to GND (ground). Connect GPIO27 to r_gate P1 (fan-control signal), r_gate P2 to mosfet_1 GATE (signal), and connect r_gate_pull from that same GATE point to GND (keeps the fan off while the board starts). Put diode_fan_1 across the fan: its striped CATHODE end to 5V (protection) and its ANODE end to the fan negative/MOSFET DRAIN point (protection).

  • For a bare TO-220 IRLZ44N facing you with its writing visible and legs downward, the usual leg order is Gate, Drain, Source; confirm the marking on your actual part before soldering.
  • The stripe on the diode must face the 5 V fan wire. Reversing it makes a direct short when the fan turns on.

6. Add the light, buzzer, and buttons

Connect rgb_1 GND to GND (ground). Connect RGB red through r_red to GPIO25 (red status), green through r_green to GPIO32 (green status), and blue through r_blue to GPIO14 (blue status); each resistor is 220 Ω. Connect buzzer_1 SIG to GPIO33 (alert sound) and GND to GND (ground). Connect button_silence with one leg to GND and its other leg to GPIO4 (silence signal); connect r_button_silence_pull from 3V3 to that GPIO4/button point (keeps the input steady). Use the ESP32’s built-in BOOT button to change between live values and the graph.

  • On the RGB LED, identify its common-cathode leg from the part’s flat edge or datasheet before soldering. A quick tap on the silence button mutes later sound alerts.
  • Each RGB color needs its own 220 Ω resistor. Connecting an LED color straight to a GPIO can damage the LED or ESP32.

7. Finish the case and test the airflow

Fit the display in the front opening, keep the SCD40 exposed to room air, and give the PMS5003 a clear inlet and outlet. Mount fan_1 so it pushes air out of the case rather than directly blowing over the sensors. Insert the microSD card, then close the case without pinching the sensor cable.

  • Before closing the case, plug in USB and check that the screen lights and the fan stays off while the air is good.
  • Do not seal the sensor openings inside an airtight case; the station would then measure trapped case air instead of your room.

Review all connections

1. Connections between "scd40_1" and "ESP32"

Functionscd40_1ESP32
powerVCC → clean 3.3 V power3V3
groundGND → groundGND
i2cSDA → I2C dataGPIO 21
i2cSCL → I2C clockGPIO 22

2. Connections between "pms5003_1" and "ESP32"

Functionpms5003_1ESP32
powerVCC → 5 V power5V
groundGND → groundGND
dataTX → 3.3 V serial readings into ESP32GPIO 16
dataRX → optional 3.3 V serial control from ESP32GPIO 17

3. Connections between "tft_1" and "ESP32"

Functiontft_1ESP32
powerVCC → 3.3 V power3V3
groundGND → groundGND
spiSCK → shared SPI clockGPIO 18
spiMOSI → shared SPI data to displayGPIO 23
digitalDC → display command/data selectGPIO 26
powerBL → backlight always on3V3
spiCS → display selectGPIO 5

4. Connections between "sd_1" and "ESP32"

Functionsd_1ESP32
powerVCC → 3.3 V power3V3
groundGND → groundGND
spiSCK → shared SPI clockGPIO 18
spiMOSI → shared SPI data to cardGPIO 23
spiMISO → shared SPI data from cardGPIO 19
spiCS → card selectGPIO 13

5. Connections between "fan_1" and "ESP32"

Functionfan_1ESP32
powerVCC → 5 V fan power5V
groundGND → IRLZ44N Logic-Level N-Channel MOSFET DRAINEXT

6. Connections between "mosfet_1" and "ESP32"

Functionmosfet_1ESP32
groundSOURCE → common groundGND
digitalGATE → Resistor P2EXT

7. Connections between "r_gate" and "ESP32"

Functionr_gateESP32
digitalP1 → fan-control signalGPIO 27

8. Connections between "r_gate_pull" and "ESP32"

Functionr_gate_pullESP32
digitalP1 → IRLZ44N Logic-Level N-Channel MOSFET GATEEXT
groundP2 → groundGND

9. Connections between "buzzer_1" and "ESP32"

Functionbuzzer_1ESP32
pwmSIG → alert tone signalGPIO 33
groundGND → groundGND

10. Connections between "rgb_1" and "ESP32"

Functionrgb_1ESP32
groundGND → common LED groundGND
dataR → Resistor P2EXT
dataG → Resistor P2EXT
dataB → Resistor P2EXT

11. Connections between "r_red" and "ESP32"

Functionr_redESP32
digitalP1 → red brightness signalGPIO 25

12. Connections between "r_green" and "ESP32"

Functionr_greenESP32
digitalP1 → green brightness signalGPIO 32

13. Connections between "r_blue" and "ESP32"

Functionr_blueESP32
digitalP1 → blue brightness signalGPIO 14

14. Connections between "button_silence" and "ESP32"

Functionbutton_silenceESP32
groundGND → groundGND
digitalSIGNAL → silence button signalGPIO 4

15. Connections between "r_button_silence_pull" and "ESP32"

Functionr_button_silence_pullESP32
powerP1 → button pull-up power3V3
digitalP2 → Push Button SIGNALEXT

16. Connections between "diode_fan_1" and "ESP32"

Functiondiode_fan_1ESP32
powerANODE → IRLZ44N Logic-Level N-Channel MOSFET DRAINEXT
powerCATHODE → striped end to 5 V fan positive5V

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClientSecure.h>
#include <time.h>
#include <SensirionI2cScd4x.h>
#include <Adafruit_PM25AQI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>

// Enter these only if you want the station to join your home Wi-Fi and use internet time/Telegram.
const char* WIFI_SSID = "";
const char* WIFI_PASSWORD = "";
const char* TELEGRAM_BOT_TOKEN = "";
const char* TELEGRAM_CHAT_ID = "";
const char* AP_NAME = "Study-Air-Station";
const char* AP_PASSWORD = "studyair2026";

const int SCD_SDA_PIN = 21;
const int SCD_SCL_PIN = 22;
const int PMS_RX_PIN = 16;
const int PMS_TX_PIN = 17;
const int TFT_CS_PIN = 5;
const int TFT_DC_PIN = 26;
const int SD_CS_PIN = 13;
const int SPI_SCK_PIN = 18;
const int SPI_MISO_PIN = 19;
const int SPI_MOSI_PIN = 23;
const int FAN_PIN = 27;
const int BUZZER_PIN = 33;
const int LED_R_PIN = 25;
const int LED_G_PIN = 32;
const int LED_B_PIN = 14;
const int SILENCE_PIN = 4;
const int PAGE_PIN = 0; // Onboard BOOT button, held low when pressed.
const uint8_t BUZZER_CHANNEL = 0;
const uint8_t RED_CHANNEL = 1;
const uint8_t GREEN_CHANNEL = 2;
const uint8_t BLUE_CHANNEL = 3;

const uint16_t CO2_WARN = 1000;
const uint16_t CO2_BAD = 1200;
const uint16_t PM25_WARN = 15;
const uint16_t PM25_BAD = 35;
const uint32_t SAMPLE_INTERVAL_MS = 5000;
const uint32_t LOG_INTERVAL_MS = 60000;
const uint32_t GRAPH_INTERVAL_MS = 300000;

SensirionI2cScd4x scd4x;
Adafruit_PM25AQI pms;
Adafruit_ST7789 tft(TFT_CS_PIN, TFT_DC_PIN, -1);
WebServer server(80);

struct Reading {
  uint16_t co2 = 0;
  float temperature = NAN;
  float humidity = NAN;
  uint16_t pm25 = 0;
  uint16_t pm10 = 0;
  bool scdOk = false;
  bool pmsOk = false;
};
Reading reading;
uint16_t co2Trend[288];
uint16_t pmTrend[288];
uint16_t trendCount = 0;
uint16_t trendHead = 0;
uint8_t airLevel = 0; // 0 green, 1 yellow, 2 red
uint8_t page = 0;
bool sdReady = false;
bool alertSilenced = false;
bool lastSilenceState = HIGH;
bool lastPageState = HIGH;
uint32_t lastSample = 0;
uint32_t lastLog = 0;
uint32_t lastGraph = 0;
uint32_t lastTelegram = 0;

String timestampText() {
  time_t now = time(nullptr);
  if (now < 1700000000) return String("uptime_") + String(millis() / 1000);
  struct tm timeInfo;
  localtime_r(&now, &timeInfo);
  char buffer[24];
  strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &timeInfo);
  return String(buffer);
}

String jsonReading() {
  String out = "{\"time\":\"" + timestampText() + "\",\"co2\":" + String(reading.co2);
  out += ",\"temperature\":" + String(reading.temperature, 1);
  out += ",\"humidity\":" + String(reading.humidity, 1);
  out += ",\"pm25\":" + String(reading.pm25) + ",\"pm10\":" + String(reading.pm10);
  out += ",\"level\":" + String(airLevel) + ",\"sd\":" + String(sdReady ? "true" : "false") + "}";
  return out;
}

void setRgb(uint8_t r, uint8_t g, uint8_t b) {
  ledcWrite(LED_R_PIN, r); ledcWrite(LED_G_PIN, g); ledcWrite(LED_B_PIN, b);
}

void updateOutputs() {
  bool bad = airLevel == 2;
  digitalWrite(FAN_PIN, bad ? HIGH : LOW);
  if (airLevel == 0) setRgb(0, 180, 0);
  else if (airLevel == 1) setRgb(180, 100, 0);
  else setRgb(200, 0, 0);
}

void beepAlert() {
  if (!alertSilenced) {
    ledcWriteTone(BUZZER_PIN, airLevel == 2 ? 2400 : 1600);
    delay(120);
    ledcWriteTone(BUZZER_PIN, 0);
  }
}

void drawScreen();

void readSensors() {
  bool changed = false;
  bool dataReady = false;
  uint16_t co2 = 0;
  float temperature = NAN, humidity = NAN;
  if (scd4x.getDataReadyStatus(dataReady) == 0 && dataReady &&
      scd4x.readMeasurement(co2, temperature, humidity) == 0 && co2 > 0) {
    changed |= reading.co2 != co2;
    reading.co2 = co2;
    reading.temperature = temperature;
    reading.humidity = humidity;
    reading.scdOk = true;
  }
  PM25_AQI_Data pmData;
  if (pms.read(&pmData)) {
    changed |= reading.pm25 != pmData.pm25_env || reading.pm10 != pmData.pm100_env;
    reading.pm25 = pmData.pm25_env;
    reading.pm10 = pmData.pm100_env;
    reading.pmsOk = true;
  }
  uint8_t prior = airLevel;
  if ((reading.scdOk && reading.co2 >= CO2_BAD) || (reading.pmsOk && reading.pm25 >= PM25_BAD)) airLevel = 2;
  else if ((reading.scdOk && reading.co2 >= CO2_WARN) || (reading.pmsOk && reading.pm25 >= PM25_WARN)) airLevel = 1;
  else airLevel = 0;
  updateOutputs();
  if (airLevel > prior) beepAlert();
  if (changed || airLevel != prior) drawScreen();
}

void addTrendPoint() {
  co2Trend[trendHead] = reading.co2;
  pmTrend[trendHead] = reading.pm25;
  trendHead = (trendHead + 1) % 288;
  if (trendCount < 288) trendCount++;
}

uint16_t trendAt(uint16_t index) {
  uint16_t first = (trendHead + 288 - trendCount) % 288;
  return co2Trend[(first + index) % 288];
}

void drawHeader(uint16_t color, const char* label) {
  tft.fillRect(0, 0, 320, 26, color);
  tft.setTextColor(ST77XX_WHITE, color);
  tft.setTextSize(2);
  tft.setCursor(6, 5);
  tft.print(label);
}

void drawScreen() {
  uint16_t color = airLevel == 0 ? ST77XX_GREEN : (airLevel == 1 ? ST77XX_YELLOW : ST77XX_RED);
  tft.fillScreen(ST77XX_BLACK);
  drawHeader(color, airLevel == 0 ? "AIR: GOOD" : (airLevel == 1 ? "AIR: FAIR" : "OPEN WINDOW"));
  tft.setTextSize(2);
  tft.setTextColor(ST77XX_WHITE, ST77XX_BLACK);
  if (page == 0) {
    tft.setCursor(10, 42); tft.printf("CO2  %u ppm", reading.co2);
    tft.setCursor(10, 72); tft.printf("PM2.5  %u ug/m3", reading.pm25);
    tft.setCursor(10, 102); tft.printf("PM10   %u ug/m3", reading.pm10);
    tft.setCursor(10, 142); tft.printf("Temp  %.1f C", reading.temperature);
    tft.setCursor(10, 172); tft.printf("Humidity %.0f%%", reading.humidity);
    tft.setTextSize(1); tft.setCursor(10, 215);
    tft.print(sdReady ? "Logging to microSD" : "microSD unavailable");
  } else {
    tft.setTextSize(1); tft.setCursor(8, 35); tft.print("CO2 24-hour trend (5 min points)");
    tft.drawRect(8, 50, 304, 150, ST77XX_WHITE);
    uint16_t maxValue = 1200;
    for (uint16_t i = 0; i < trendCount; i++) if (trendAt(i) > maxValue) maxValue = trendAt(i);
    if (maxValue < 600) maxValue = 600;
    for (uint16_t i = 1; i < trendCount; i++) {
      int x0 = 9 + (i - 1) * 302 / 287;
      int x1 = 9 + i * 302 / 287;
      int y0 = 198 - (int)((uint32_t)trendAt(i - 1) * 146 / maxValue);
      int y1 = 198 - (int)((uint32_t)trendAt(i) * 146 / maxValue);
      if (y0 < 51) y0 = 51; if (y1 < 51) y1 = 51;
      tft.drawLine(x0, y0, x1, y1, ST77XX_CYAN);
    }
    tft.setCursor(10, 210); tft.printf("now %u ppm, scale %u", reading.co2, maxValue);
    tft.setCursor(10, 225); tft.print("Press BOOT to return");
  }
}

void appendLog() {
  if (!sdReady) return;
  File file = SD.open("/air.csv", FILE_APPEND);
  if (!file) { sdReady = false; return; }
  file.printf("%s,%u,%.1f,%.1f,%u,%u,%u\n", timestampText().c_str(), reading.co2,
              reading.temperature, reading.humidity, reading.pm25, reading.pm10, airLevel);
  file.close();
}

String urlEncode(const String& value) {
  String out;
  const char* hex = "0123456789ABCDEF";
  for (size_t i = 0; i < value.length(); i++) {
    char c = value[i];
    if (isalnum(c)) out += c;
    else if (c == ' ') out += '+';
    else { out += '%'; out += hex[(c >> 4) & 15]; out += hex[c & 15]; }
  }
  return out;
}

void sendTelegramAlert() {
  if (WiFi.status() != WL_CONNECTED || strlen(TELEGRAM_BOT_TOKEN) == 0 || strlen(TELEGRAM_CHAT_ID) == 0) return;
  if (millis() - lastTelegram < 900000) return;
  lastTelegram = millis();
  String message = airLevel == 2 ? "Study Air Station: CO2 " + String(reading.co2) + " ppm, PM2.5 " + String(reading.pm25) + " ug/m3. Open a window."
                                 : "Study Air Station: air quality is elevated. CO2 " + String(reading.co2) + " ppm.";
  WiFiClientSecure client;
  client.setInsecure();
  if (client.connect("api.telegram.org", 443)) {
    String path = "/bot" + String(TELEGRAM_BOT_TOKEN) + "/sendMessage?chat_id=" + String(TELEGRAM_CHAT_ID) + "&text=" + urlEncode(message);
    client.print("GET " + path + " HTTP/1.1\r\nHost: api.telegram.org\r\nConnection: close\r\n\r\n");
    client.stop();
  }
}

void setupWeb() {
  server.on("/", []() {
    String html = "<!doctype html><html><meta name=viewport content='width=device-width'><style>body{font-family:sans-serif;background:#101820;color:#fff;margin:24px}.card{padding:18px;background:#1f2d36;border-radius:12px;max-width:420px}b{color:#66e0ff}</style><body><div class=card><h1>Study Air Station</h1><p>Refreshes every 10 seconds.</p><div id=d>Loading...</div></div><script>async function u(){let r=await fetch('/api');let x=await r.json();d.innerHTML='<p><b>'+x.time+'</b></p><p>CO2: '+x.co2+' ppm</p><p>PM2.5: '+x.pm25+' ug/m3 &nbsp; PM10: '+x.pm10+' ug/m3</p><p>Temperature: '+x.temperature+' C &nbsp; Humidity: '+x.humidity+'%</p><p>Air level: '+['good','fair','bad'][x.level]+'</p><p>microSD: '+(x.sd?'ready':'not ready')+'</p>'}u();setInterval(u,10000)</script></body></html>";
    server.send(200, "text/html", html);
  });
  server.on("/api", []() { server.send(200, "application/json", jsonReading()); });
  server.begin();
}

void setupNetwork() {
  if (strlen(WIFI_SSID) > 0) {
    WiFi.mode(WIFI_STA);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    uint32_t started = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - started < 15000) delay(250);
  }
  if (WiFi.status() == WL_CONNECTED) configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  else { WiFi.mode(WIFI_AP); WiFi.softAP(AP_NAME, AP_PASSWORD); }
}

void setup() {
  Serial.begin(115200);
  pinMode(FAN_PIN, OUTPUT); digitalWrite(FAN_PIN, LOW);
  pinMode(SILENCE_PIN, INPUT); // External 10 kOhm resistor holds this high.
  pinMode(PAGE_PIN, INPUT_PULLUP);
  ledcAttach(BUZZER_PIN, 2000, 8);
  ledcAttach(LED_R_PIN, 5000, 8);
  ledcAttach(LED_G_PIN, 5000, 8);
  ledcAttach(LED_B_PIN, 5000, 8);
  setRgb(0, 0, 0);
  Wire.begin(SCD_SDA_PIN, SCD_SCL_PIN);
  scd4x.begin(Wire, 0x62);
  scd4x.stopPeriodicMeasurement(); delay(500);
  scd4x.startPeriodicMeasurement();
  Serial2.begin(9600, SERIAL_8N1, PMS_RX_PIN, PMS_TX_PIN);
  pms.begin_UART(&Serial2);
  SPI.begin(SPI_SCK_PIN, SPI_MISO_PIN, SPI_MOSI_PIN, SD_CS_PIN);
  tft.init(240, 320); tft.setRotation(1); tft.fillScreen(ST77XX_BLACK);
  sdReady = SD.begin(SD_CS_PIN);
  if (sdReady && !SD.exists("/air.csv")) {
    File file = SD.open("/air.csv", FILE_WRITE);
    if (file) { file.println("timestamp,co2_ppm,temperature_c,humidity_pct,pm25_ugm3,pm10_ugm3,level"); file.close(); }
  }
  setupNetwork();
  setupWeb();
  drawScreen();
}

void loop() {
  server.handleClient();
  bool silenceNow = digitalRead(SILENCE_PIN);
  bool pageNow = digitalRead(PAGE_PIN);
  if (lastSilenceState == HIGH && silenceNow == LOW) { alertSilenced = true; drawScreen(); }
  if (lastPageState == HIGH && pageNow == LOW) { page = 1 - page; drawScreen(); }
  lastSilenceState = silenceNow;
  lastPageState = pageNow;
  uint32_t now = millis();
  if (now - lastSample >= SAMPLE_INTERVAL_MS) { lastSample = now; readSensors(); if (airLevel > 0) sendTelegramAlert(); }
  if (now - lastLog >= LOG_INTERVAL_MS) { lastLog = now; appendLog(); }
  if (now - lastGraph >= GRAPH_INTERVAL_MS) { lastGraph = now; addTrendPoint(); if (page == 1) drawScreen(); }
  delay(10);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik