Siddhant Kumar
Project 039 · Environment

City Air Pollution Node.

A low-cost, honestly-calibrated air-quality node that measures street-level pollution where people actually breathe — and knows the limits of what a cheap sensor can claim.

Intermediate 12–18 hours 36 min read AQISensorsCloud
Jump to source Bill of materials
City Air Pollution Node — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Intermediate
Build time
12–18 hours
Indicative cost
₹3,600 – ₹4,800
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Environment
Last updated
28 July 2026
Contents — 26 sections

Project Overview

A low-cost, honestly-calibrated air-quality node that measures street-level pollution where people actually breathe — and knows the limits of what a cheap sensor can claim.

Official air-quality monitoring is accurate but sparse: a city of millions might have a handful of reference stations, so the number on the app is an average of places you are not standing. Pollution, though, is intensely local — a busy junction, a construction site, a street canyon where exhaust pools can be several times worse than the citywide figure a few hundred metres away. A dense network of low-cost nodes fills that gap, mapping pollution at the scale people experience it. The catch, and the thing this project takes seriously, is that a cheap sensor is only useful if you are honest about what it can and cannot measure and you calibrate it against reference instruments rather than trusting the raw number.

The node measures the pollutant that matters most for health, fine particulate matter (PM2.5 and PM10), with an optical particle counter, alongside temperature, humidity and pressure — not as afterthoughts but because humidity in particular corrupts low-cost PM readings: humid air makes particles swell and scatter more light, so an uncorrected sensor reads high on damp mornings and fog. It optionally adds a gas channel (a metal-oxide sensor for a broad "VOC/oxidising gas" signal, or an NDIR CO₂ sensor) with the firm caveat that low-cost gas sensors are qualitative — good for trends and relative comparisons, poor for absolute regulated concentrations. The design philosophy throughout is calibrate, correct, and caveat.

Because the whole value of the node is being cheap enough to deploy in numbers, it is built for exactly that: solar-plus-battery so it needs no mains, LoRa or Wi-Fi so it needs no wiring, and a weatherproof housing that shelters the electronics while letting outside air reach the sensor. It publishes a corrected reading and an AQI-style category to a map, applies a co-location calibration factor derived from sitting next to a reference station, and flags its own data quality so a fogged-out PM reading is marked as such rather than fed to the map as truth. One node is a curiosity; a hundred honest nodes are a street-level pollution map a city can act on.

A city skyline at night
A dense network of low-cost nodes maps pollution at the street scale that sparse official stations miss. Photograph sourced from Wikimedia Commons — Smart city.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Measures fine particulate matter (PM2.5, PM10) with an optical counter
  • Measures temperature, humidity and pressure, and humidity-corrects the PM reading
  • Optionally adds a qualitative gas channel (VOC/CO₂), clearly caveated
  • Applies a co-location calibration factor against a reference station
  • Computes an AQI-style category and flags its own data quality
  • Runs on solar + battery and reports over LoRa or Wi-Fi to a map
  • Marks unreliable readings (e.g. fog/high humidity) rather than hiding them

Real-World Applications

SettingHow it is used
Community air-quality networksNeighbourhood groups and NGOs building street-level pollution maps to reveal hotspots that sparse official stations miss.
School and campus monitoringShowing children and staff real, local exposure and informing decisions about outdoor activity on bad-air days.
Traffic and construction impact studiesQuantifying how much a junction, a building site or a policy change shifts local pollution over time.
Personal and workplace awarenessA calibrated node at home or work giving trustworthy trends where the nearest official station is kilometres away.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Health-relevant PM2.5/PM10 at street level, where people breathe
  • Humidity correction — the single biggest low-cost PM error source
  • Co-location calibration against reference instruments
  • Honest data-quality flags instead of false precision
  • Solar + LoRa/Wi-Fi for dense, wiring-free deployment
  • AQI-style categories for public-facing maps
  • Designed for networks: cheap, self-reporting, comparable nodes

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time12–18 hours
Indicative build cost₹3,600 – ₹4,800
Primary disciplineEnvironment
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Reading an optical PM sensor (PMS5003) over UART and interpreting its outputs
  • Humidity-correcting low-cost particulate readings
  • Co-location calibration against a reference monitor
  • Computing AQI categories and honest data-quality flags
  • Solar + LoRa/Wi-Fi design for outdoor networked nodes

Bill of Materials

Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.

ComponentKey specificationQtyApprox. cost
ESP32 DevKit V1 (ESP-WROOM-32)
Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC1₹450
Plantower PMS5003 laser particulate sensor
The fan is a wear item — run it in duty cycles, not continuously.
PM1.0 / PM2.5 / PM10, 0–500 µg/m³, ±10 %, laser scattering, 30 s stabilisation1₹1,900
BME280 pressure/humidity/temperature sensor
Self-heating skews temperature by ~1 °C — read in forced mode, not continuous.
300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz1₹420
MQ-135 air-quality sensor
Not a true CO₂ sensor — calibrate against clean air (R0) before trusting ppm.
NH₃, NOx, benzene, smoke, CO₂ proxy, 10–1000 ppm, analogue output1₹180
MH-Z19B NDIR CO₂ sensor
Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm.
0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output1₹2,600
SX1278 LoRa 433 MHz module (Ra-02)
Never power the radio without an antenna — the PA will destroy itself.
−148 dBm sensitivity, +20 dBm output, up to 10 km line of sight, SF7–SF121₹480
20 W 12 V polycrystalline solar panel
Rated watts assume 1000 W/m² — plan for 60–70 % of nameplate in real installs.
Vmp 17.5 V, Imp 1.14 A, Voc 21.6 V, 350 × 290 mm, aluminium frame1₹1,200
TP4056 Li-ion charger + DW01 protection
Buy the version *with* protection ICs — the bare charger will over-discharge your cell.
1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection1₹45
18650 Li-ion cell 3400 mAh + holder
Never charge below 0 °C; always use a protected cell or a BMS.
3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge1₹450
Weatherproof vented housing
A Stevenson-style or louvred housing; a sealed box gives false readings
Shelters electronics, allows free-flowing outside air to the PM inlet, keeps rain/insects out1₹500
PM inlet filter/insect screenFine mesh at the PMS5003 inlet to keep insects out without restricting flow1₹60
Reference co-location time (access)
Not hardware — but essential to the node's validity
Time beside a regulatory monitor to derive calibration1

Estimated total: ₹8,285, excluding tools, shipping and consumables.

Tools and consumables

  • Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
  • Digital multimeter — continuity, DC volts and current ranges
  • Wire strippers, flush cutters and a small set of precision screwdrivers
  • Heat-shrink tubing and a heat gun (or a lighter, carefully)
  • A laptop with a USB port and the toolchain listed above

Hardware Specifications

PartSpecificationSupplyInterfaceReference
ESP32 DevKit V1 (ESP-WROOM-32)Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
Plantower PMS5003 laser particulate sensorPM1.0 / PM2.5 / PM10, 0–500 µg/m³, ±10 %, laser scattering, 30 s stabilisation5 VUART 9600Datasheet
BME280 pressure/humidity/temperature sensor300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz1.7–3.6 V (module has 3.3 V LDO)I²C (0x76/0x77) or SPIDatasheet
MQ-135 air-quality sensorNH₃, NOx, benzene, smoke, CO₂ proxy, 10–1000 ppm, analogue output5 V (heater)AnalogueDatasheet
MH-Z19B NDIR CO₂ sensor0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output4.5–5.5 VUART 9600 8N1, PWMDatasheet
SX1278 LoRa 433 MHz module (Ra-02)−148 dBm sensitivity, +20 dBm output, up to 10 km line of sight, SF7–SF123.3 VSPIDatasheet
20 W 12 V polycrystalline solar panelVmp 17.5 V, Imp 1.14 A, Voc 21.6 V, 350 × 290 mm, aluminium frame12 V nominalMC4 / screw terminalsDatasheet
TP4056 Li-ion charger + DW01 protection1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection4.5–5.5 V inmicro-USB / padsDatasheet
18650 Li-ion cell 3400 mAh + holder3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge3.0–4.2 VHolder / spot-welded tabsDatasheet

Consolidated electrical and interface specifications for every active part in the build.

Power Budget & Supply Sizing

Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Plantower PMS5003 laser particulate sensor5 V100The fan is a wear item — run it in duty cycles, not continuously.
BME280 pressure/humidity/temperature sensor1.7–3.6 V (module has 3.3 V LDO)0.4Self-heating skews temperature by ~1 °C — read in forced mode, not continuous.
MQ-135 air-quality sensor5 V (heater)150Not a true CO₂ sensor — calibrate against clean air (R0) before trusting ppm.
MH-Z19B NDIR CO₂ sensor4.5–5.5 V60Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm.
SX1278 LoRa 433 MHz module (Ra-02)3.3 V120Never power the radio without an antenna — the PA will destroy itself.
20 W 12 V polycrystalline solar panel12 V nominal1140Rated watts assume 1000 W/m² — plan for 60–70 % of nameplate in real installs.
TP4056 Li-ion charger + DW01 protection4.5–5.5 V in1000Buy the version *with* protection ICs — the bare charger will over-discharge your cell.

Summed typical draw is 2730.4 mA. With a 1.5× design margin the supply should deliver at least 4100 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.

  • Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
  • Add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager.
  • Set the correct port under Tools → Port. On Linux add yourself to the dialout group: sudo usermod -aG dialout $USER and log out and back in.
  • Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
  • Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.

Required libraries

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
Adafruit BME280 2.2.xCompensation maths for the Bosch pressure/humidity/temperature sensor.Library Manager → "Adafruit BME280 Library"
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-clock time from an NTP server for timestamping.Bundled (`configTime()` on ESP32)

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

City Air Pollution Node — system block diagramFunctional block diagram of the City Air Pollution Node system. Sense the airPM2.5/PM10PMS5003 opticalT/RH/PBME280Gas (opt)MQ135 / CO₂Correct + rateESP32humidity correctionCalibrationco-location factorAQI + qualitycategory + flagsLinkLoRa / Wi-Fito mapPublicAir mapstreet-levelAlertbad-air daysrightrightnone
City Air Pollution Node — system block diagram

Circuit Diagram & Wiring

Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.

City Air Pollution Node — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBPMS5003GPIO 16/17UART PM2.5/PM10countsPMS5003GPIO 26Sleep/wake(duty-cycle the fan)BME280GPIO 21/22Temp/RH/pressure(I²C)MQ135 / MH-Z19GPIO 34 / 4Optional gas channelLoRa SX1276GPIO 18/19/23SPI radio busLoRa SX1276GPIO 5/14/2Chip-select, reset,IRQTP4056VIN / 3V3 regSolar-charged 18650supplySolar panelTP4056 IN6 V panel → charger
City Air Pollution Node — wiring schematic
PeripheralPeripheral pinController pinSignal
PMS5003TX/RXGPIO 16/17UART PM2.5/PM10 counts
PMS5003SETGPIO 26Sleep/wake (duty-cycle the fan)
BME280SDA/SCLGPIO 21/22Temp/RH/pressure (I²C)
MQ135 / MH-Z19AOUT / UARTGPIO 34 / 4Optional gas channel
LoRa SX1276SCK/MISO/MOSIGPIO 18/19/23SPI radio bus
LoRa SX1276NSS/RST/DIO0GPIO 5/14/2Chip-select, reset, IRQ
TP4056OUTVIN / 3V3 regSolar-charged 18650 supply
Solar panel+/–TP4056 IN6 V panel → charger

Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.

Wiring explanation

  • Mount the PMS5003 so its inlet draws genuine outside air inside a vented housing — never a sealed box, which traps stale air and gives meaningless readings.
  • Duty-cycle the PM sensor via its SET pin: its fan and laser draw meaningful current and wear out, so wake it, let the reading settle for ~30 s, sample, then sleep it.
  • Keep the BME280 in the same airflow as the PM inlet so its humidity truly represents the air whose PM you are correcting.
  • If using the MQ135, give it a stable warmed supply and long warm-up; treat its output as a qualitative index, and keep its heater noise off the PM sensor's ground.
  • Screen the inlet against insects with fine mesh that does not restrict the airflow the optical counter depends on.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
ESP32 module reading the optical PM sensor, correcting for humidity and applying the co-location calibration. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

System Architecture

Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.

City Air Pollution Node — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · Plantower PMS5003 laser particulatesensor · BME280 pressure/humidity/temperature sensor · MQ-135 air-qualitysensorDriver layerwifi · pubsub · bme · arduinojsonApplication logicsampling loop · filtering · thresholds · state machineTransport layerLoRa/Wi-Fi → gateway → air-map broker · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
City Air Pollution Node — architecture stack

Working Principle

The health case for this node rests almost entirely on fine particulate matter. Particles smaller than 2.5 micrometres (PM2.5) penetrate deep into the lungs and cross into the bloodstream, and epidemiology ties them to a large share of air-pollution's health burden. A low-cost optical particle counter like the PMS5003 estimates PM by drawing air past a laser and measuring how much light suspended particles scatter, then inferring a mass concentration. That inference is the sensor's strength (cheap, real-time, sensitive to the size range that matters) and its weakness (it assumes particle properties that vary, and it is fooled by anything else that scatters light).

The most important correction is for humidity. Many airborne particles are hygroscopic — they absorb water and swell as humidity rises — so in damp air the same amount of pollutant scatters more light and the sensor over-reads, sometimes dramatically on foggy mornings when there is barely any actual pollution. A node that ignores this will publish alarming PM spikes that are really just weather. The fix is to measure humidity alongside PM and apply a correction that grows with relative humidity, and to flag or discard readings above a very high humidity where no correction is trustworthy. This single correction is the difference between a node that informs and one that misleads.

Calibration by co-location is what turns a hobby sensor into a credible one. Because low-cost sensors have unit-to-unit variation and a systematic offset from reference instruments, the accepted practice is to run the node for a period right beside a regulatory-grade monitor, compare the two, and derive a correction (often a linear factor plus the humidity term). Applied thereafter, that factor aligns the cheap node with the reference. Without co-location, a network of nodes is internally comparable at best and absolutely wrong at worst; with it, the nodes become a genuine extension of the reference network at higher spatial density.

Everything else follows from honesty about uncertainty. The optional gas sensor gets a trend, not a number, because metal-oxide sensors drift and cross-respond to many gases. Each transmitted reading carries a data-quality flag — good, humidity-suspect, sensor-warming, out-of-range — so the map can show or grey-out accordingly. An AQI-style category communicates risk in language the public understands without implying laboratory precision. The node's credibility, and therefore its usefulness, comes from claiming exactly as much as a cheap sensor can support and no more.

The maths behind it

Humidity correction of low-cost PM

plainHumidity correction of low-cost PM
Hygroscopic growth inflates scattered light at high RH.
A common correction form:

  PM_corr = PM_raw / (1 + a · (RH/100)^b / (1 − RH/100))

a, b are fitted during co-location (κ-Köhler-style growth).
Above ~RH 85–90% no correction is reliable → flag/discard.
This removes the fog/humid-morning false spikes.

Co-location calibration (linear + RH)

plainCo-location calibration (linear + RH)
Fit node output to a reference monitor:

  PM_ref ≈ m · PM_corr + c

m (slope) and c (offset) come from regression over a
co-location period spanning a range of concentrations.
Store m, c and the humidity coefficients per node —
low-cost sensors vary unit to unit.

AQI category from PM2.5

plainAQI category from PM2.5
Map corrected PM2.5 (µg/m³) to an AQI band via the
piecewise-linear breakpoints of the standard used
(e.g. Indian NAQI or US EPA):

  AQI = ((I_hi − I_lo)/(C_hi − C_lo))·(C − C_lo) + I_lo

where C sits in the [C_lo, C_hi] concentration band with
index range [I_lo, I_hi]. Report the band name (Good/
Moderate/Poor/…) for the public, the number for analysts.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

City Air Pollution Node — firmware flowchartControl flow through the main program loop. Wake PM sensor, settle fanRead PM, T, RH, P (+gas)Humidity within validrange?Apply RH + calibration correctionFlag data quality: high-RHApply RH + calibrationcorrectionFlag data quality: high-RHCompute AQI categoryTransmit corrected value +flagsSleep PM sensor; wait interval
City Air Pollution Node — firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

  1. Build the vented housing

    Fit the PMS5003 with its inlet exposed to free-flowing outside air inside a louvred/Stevenson-style housing that keeps rain and direct sun off the electronics but does not seal the air in.

    Screen the inlet against insects with fine mesh and mount the housing away from very local sources (not directly over an exhaust or a barbecue) unless you specifically want that microenvironment.

  2. Wire and duty-cycle the sensors

    Connect the PMS5003 over UART with its SET pin on a GPIO so firmware can sleep the fan/laser between reads. Place the BME280 in the same airflow.

    If fitting a gas sensor, give it a clean warmed supply and accept a long stabilisation time; keep its heater current off the PM sensor ground.

  3. Set up power and radio

    Angle the solar panel to the sun and route the LoRa antenna clear of metal. Ensure the battery and charger sit in the sheltered part of the housing, not in the wet airflow.

Step-by-Step Implementation Guide

Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.

  1. Duty-cycle the PM sensor correctly

    Wake the PMS5003, let its fan run ~30 seconds so the airflow and reading stabilise, average several samples, then sleep it. This saves power and greatly extends the fan/laser life.

  2. Apply the corrections in order

    Correct the raw PM for humidity first, then apply the co-location slope/offset, then map to AQI, and attach a data-quality flag reflecting humidity, warm-up and range.

    cpppm-correct.ino
    struct Reading { float pm25, pm10, rh, temp; uint8_t quality; };
    enum { Q_GOOD=0, Q_HUMID=1, Q_WARMING=2, Q_RANGE=3 };
    
    // Node-specific constants from co-location, stored in flash.
    float RH_A, RH_B, CAL_M, CAL_C;
    
    // Humidity correction then linear calibration; sets a quality flag.
    float correctPM(float raw, float rh, uint8_t &quality) {
      if (rh > 90.0f) { quality = Q_HUMID; return raw; }   // no trustworthy fix
      float growth = 1.0f + RH_A * powf(rh/100.0f, RH_B) / (1.0f - rh/100.0f);
      float dehumid = raw / growth;                          // remove swelling
      return CAL_M * dehumid + CAL_C;                        // align to reference
    }
    
    // Piecewise-linear AQI (breakpoints per the standard in use).
    int aqiFromPM25(float c) {
      static const float C[] = {0,30,60,90,120,250,500};    // NAQI-style bands
      static const int   I[] = {0,50,100,200,300,400,500};
      for (int k = 1; k < 7; k++)
        if (c <= C[k])
          return (int)((I[k]-I[k-1])/(C[k]-C[k-1])*(c-C[k-1]) + I[k-1]);
      return 500;
    }
    if (rh > 90.0f)Above very high humidity no correction is trustworthy, so the reading is passed through but flagged Q_HUMID rather than being "corrected" with a formula that no longer holds.
    float growth = 1.0f + RH_AModels the hygroscopic swelling of particles as a function of humidity and divides it out — removing the fog and damp-morning false spikes that fool uncorrected sensors.
    return CAL_M * dehumid + CAL_CApplies the node's own co-location slope and offset so its output aligns with the reference monitor it was calibrated against.
    int aqiFromPM25(float c)Converts the corrected concentration into a standard AQI number via piecewise-linear breakpoints, which the map turns into a public-friendly band name.
  3. Transmit with flags and sleep

    Send the corrected PM2.5/PM10, AQI, the raw values, humidity and the data-quality flag, then sleep the PM sensor and the radio until the next interval.

Complete Source Code

The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.

cppcity-air-node.ino
/* ═══════════════════════════════════════════════════════════════
   City Air Pollution Node — ESP32, PMS5003, BME280, LoRa, solar

   Measures street-level PM2.5/PM10, humidity-corrects it, applies a
   co-location calibration, computes AQI, flags data quality, and
   reports over LoRa/Wi-Fi. Honest about low-cost-sensor limits.
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <LoRa.h>
#include <SPI.h>
#include <Preferences.h>

#define PMS_RX     16
#define PMS_TX     17
#define PMS_SET    26     // LOW = sleep fan/laser
#define LORA_CS     5
#define LORA_RST   14
#define LORA_DIO0   2
#define SLEEP_S   300     // 5 min duty cycle

Adafruit_BME280 bme;
Preferences     prefs;
HardwareSerial  pms(2);

float RH_A, RH_B, CAL_M, CAL_C;
enum { Q_GOOD=0, Q_HUMID=1, Q_WARMING=2, Q_RANGE=3 };

/* ── read a PMS5003 frame (32 bytes, 0x42 0x4D header) ───────── */
bool readPMS(float &pm25, float &pm10) {
  uint8_t b[32]; int n = 0; unsigned long t0 = millis();
  while (n < 32 && millis() - t0 < 2000) {
    if (pms.available()) {
      uint8_t c = pms.read();
      if (n == 0 && c != 0x42) continue;      // resync to header
      if (n == 1 && c != 0x4D) { n = 0; continue; }
      b[n++] = c;
    }
  }
  if (n < 32) return false;
  uint16_t sum = 0; for (int i = 0; i < 30; i++) sum += b[i];
  if (sum != ((b[30] << 8) | b[31])) return false;   // checksum
  pm25 = (b[12] << 8) | b[13];               // atmospheric PM2.5
  pm10 = (b[14] << 8) | b[15];               // atmospheric PM10
  return true;
}

float correctPM(float raw, float rh, uint8_t &q) {
  if (rh > 90.0f) { q = Q_HUMID; return raw; }
  float growth = 1.0f + RH_A * powf(rh/100.0f, RH_B) / (1.0f - rh/100.0f);
  return CAL_M * (raw / growth) + CAL_C;
}

int aqiFromPM25(float c) {
  static const float C[] = {0,30,60,90,120,250,500};
  static const int   I[] = {0,50,100,200,300,400,500};
  for (int k = 1; k < 7; k++)
    if (c <= C[k])
      return (int)((I[k]-I[k-1])/(C[k]-C[k-1])*(c-C[k-1]) + I[k-1]);
  return 500;
}

void transmit(float pm25c, float pm10c, float pm25raw,
              float rh, float temp, int aqi, uint8_t q) {
  LoRa.beginPacket();
  LoRa.printf("{\"node\":1,\"pm25\":%.1f,\"pm10\":%.1f,\"pm25_raw\":%.1f,"
              "\"rh\":%.0f,\"t\":%.1f,\"aqi\":%d,\"q\":%d}",
              pm25c, pm10c, pm25raw, rh, temp, aqi, q);
  LoRa.endPacket();
}

void loadCal() {
  prefs.begin("air", true);
  RH_A  = prefs.getFloat("rhA", 0.25f);
  RH_B  = prefs.getFloat("rhB", 1.0f);
  CAL_M = prefs.getFloat("m", 1.0f);
  CAL_C = prefs.getFloat("c", 0.0f);
  prefs.end();
}

void setup() {
  Serial.begin(115200);
  loadCal();
  pinMode(PMS_SET, OUTPUT);
  pms.begin(9600, SERIAL_8N1, PMS_RX, PMS_TX);
  Wire.begin(21, 22);
  bme.begin(0x76);

  // wake PM sensor and let the fan settle before sampling
  digitalWrite(PMS_SET, HIGH);
  delay(30000);

  float pm25 = 0, pm10 = 0; int good = 0;
  for (int i = 0; i < 5; i++) {                 // average a few frames
    float a, b;
    if (readPMS(a, b)) { pm25 += a; pm10 += b; good++; }
    delay(1000);
  }
  digitalWrite(PMS_SET, LOW);                   // sleep the fan/laser

  float rh = bme.readHumidity();
  float temp = bme.readTemperature();
  uint8_t q = good ? Q_GOOD : Q_RANGE;
  if (good) { pm25 /= good; pm10 /= good; }

  float pm25c = correctPM(pm25, rh, q);
  float pm10c = correctPM(pm10, rh, q);
  int   aqi   = aqiFromPM25(pm25c);

  SPI.begin();
  LoRa.setPins(LORA_CS, LORA_RST, LORA_DIO0);
  LoRa.begin(433E6);
  LoRa.setSpreadingFactor(10);
  transmit(pm25c, pm10c, pm25, rh, temp, aqi, q);

  esp_sleep_enable_timer_wakeup((uint64_t)SLEEP_S * 1000000ULL);
  esp_deep_sleep_start();
}

void loop() {}   // deep sleep restarts setup()
bool readPMS(Parses the PMS5003's 32-byte frame, resynchronising to the 0x42 0x4D header and verifying the checksum so a garbled UART read is rejected rather than published.
digitalWrite(PMS_SET, HIGH); delay(30000);Wakes the fan and laser and waits half a minute for the airflow to stabilise before any sample is taken — sampling on cold-start gives unreliable numbers.
for (int i = 0; i < 5; i++)Averages several valid frames to beat the frame-to-frame noise of an optical counter, counting how many were good for the quality flag.
digitalWrite(PMS_SET, LOW);Immediately sleeps the fan/laser after sampling, which both saves solar power and greatly extends the sensor's limited fan life.
transmit(pm25c, pm10c, pm25,Sends both the corrected and the raw PM plus humidity and the quality flag, so the map can trust, correct, or grey-out each reading transparently.

Configuration & Calibration

Configuration steps

  • Load the node's co-location constants (humidity coefficients, slope, offset) into flash after calibration.
  • Set the AQI breakpoints to the standard you report against (e.g. Indian NAQI or US EPA).
  • Choose the duty-cycle interval (5 min is common) balancing map freshness against fan life and power.
  • Pick the region-legal LoRa frequency or configure Wi-Fi/MQTT, and set the high-humidity flag threshold.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Co-location

    Run the node beside a reference monitor for a period spanning a range of concentrations and humidities; regress its corrected output against the reference to get slope, offset and the humidity coefficients.

  2. Humidity term

    Confirm that after correction, damp-morning/fog spikes disappear relative to the reference; adjust RH_A/RH_B if the node still over-reads at high humidity.

  3. Gas channel (if fitted)

    Do not attempt an absolute calibration of a low-cost gas sensor; establish a clean-air baseline and report only relative trends.

Network Architecture & Connectivity

City Air Pollution Node — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsAir nodeESP32 + PMS5003Other nodesacross the cityLoRa / Wi-FiCity gatewayto MQTTMQTT 1883Broker + mapcorrected + flaggedPublic mapstreet-level AQIPhonebad-air alerts
City Air Pollution Node — network topology

Communication protocol

Each node publishes corrected PM, raw PM, humidity, AQI and a data-quality flag every few minutes. Sending both raw and corrected values plus the flag lets the map apply network-wide calibration and honestly show or grey-out each reading.

Topic / endpointDirectionPayload
air/node/1/readingnode → brokerPM2.5/10 corrected+raw, RH, AQI, quality
air/node/1/statusnode → brokerbattery, RSSI, calibration date
air/node/1/calbroker → nodepush updated calibration constants

Message contract between the device and the broker.

Cloud platform configuration

A broker feeds a map that renders street-level AQI, and can push refreshed calibration constants to nodes as new co-location data arrives — so the whole network improves over time.

Dashboard setup

A city map of coloured node dots with a time-slider, plus per-node history overlaying corrected and raw PM against humidity so anomalies are explainable.

Mobile app integration

Alerts when a user's local node (or their saved area) crosses into an unhealthy AQI band.

Security considerations

  • Sign each reading with a per-node key so the public map cannot be poisoned by spoofed nodes.
  • Version and authenticate calibration pushes so only the network operator can change a node's constants.
  • Publish the data-quality flag openly so users can judge reliability themselves.

Testing Procedure & Expected Output

Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.

TestWhat you should see
Sample immediately on wake vs after fan settleSettled readings are stable; cold-start readings are not — confirming the warm-up matters
Breathe/smoke near the inlet brieflyPM rises and recovers; frame checksum validates each read
Expose to a foggy/high-humidity morningRaw PM spikes; corrected PM stays sane or the reading is flagged Q_HUMID
Compare to the reference during co-locationCorrected node tracks the reference within expected low-cost tolerance
Run a solar day/night cycleBattery recovers; duty-cycling keeps power sustainable

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

The map shows each node as a coloured dot (AQI band), expanding to corrected PM2.5/PM10, humidity, the raw value and the data-quality flag.

jsonair-packet.json
{
  "node": 1,
  "pm25": 47.2,
  "pm10": 68.9,
  "pm25_raw": 61.0,
  "rh": 82,
  "t": 24.1,
  "aqi": 128,
  "q": 0
}

Here the raw PM2.5 of 61 is corrected down to 47 after removing humidity-driven swelling, giving an AQI of 128 (Moderate) — the correction preventing a damp evening from being reported as far worse than it is.

A LoRa radio transceiver module
A LoRa radio carries corrected, quality-flagged readings from each node to the citywide air map. Photograph sourced from Wikimedia Commons — LoRa module.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Readings spike every humid morning

Likely cause. No humidity correction; hygroscopic particle growth

Fix. Apply the RH correction; flag/discard above ~90% RH; this is the classic false-spike

Node reads consistently high/low vs reference

Likely cause. Not co-location calibrated, or unit variation

Fix. Run a co-location and apply the derived slope/offset

PM values are noisy/jumpy

Likely cause. Sampling before the fan settles, or too few frames

Fix. Wait ~30 s after wake and average several validated frames

All PM reads fail (checksum)

Likely cause. UART wiring/baud wrong, or sensor asleep

Fix. Confirm TX/RX and 9600 baud; ensure SET is high before reading

Gas channel drifts wildly

Likely cause. Expecting absolute values from a low-cost gas sensor

Fix. Report only relative trends; re-baseline in clean air; do not present as regulated concentration

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

An I²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an I²C scanner sketch first — it should print the device address. Most breakout boards include 4.7 kΩ pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

Performance Optimisation

  • Duty-cycle the PMS5003 aggressively — its fan and laser are the main power draw and the main wear item.
  • Average several validated frames per read rather than sampling continuously.
  • Deep-sleep between intervals; a 5-minute cadence is fine for a pollution map and easy on solar.
  • Send compact packets and let the cloud handle heavy analytics and mapping.
  • Replace every delay() with a millis() comparison — blocking delays are the single most common cause of dropped readings.
  • Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
  • Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
  • Use uint8_t / uint16_t where the range allows; on an 8-bit AVR a 32-bit add costs four times as much.
  • Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
  • Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
  • For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.

Safety Precautions

  • Present readings honestly: a low-cost node informs and maps trends; it is not a regulatory instrument, and the UI should say so.
  • Never report the gas channel as an absolute regulated concentration.
  • Mount housings securely and safely at street level, clear of traffic and tampering.
  • Keep the lithium battery and charger sheltered from the wet airflow the PM inlet needs.
  • Lithium cells vent and burn when abused. Only use protected cells or a proper BMS, never charge below 0 °C, and never leave a charging pack unattended on a wooden desk.
  • MQ-series sensors run a hot element. They get genuinely hot, need ventilation, and must never be enclosed in a sealed plastic box.
  • Never power an RF module without its antenna fitted — the reflected power destroys the output stage. Check your local licence-free band and duty-cycle limits before transmitting.
  • Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
  • Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
  • Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.

Maintenance

  • Re-run co-location periodically; low-cost sensors drift over months.
  • Clean the inlet screen and check for insect ingress that restricts airflow.
  • Replace the PM sensor when its fan noise rises or readings degrade — it is a wear part.
  • Keep the solar panel clean and verify the calibration date shown on the map is current.
  • Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
  • Clean the sensing element on a schedule. Optical and electrochemical sensors foul, and a fouled sensor reports plausible nonsense rather than failing outright.
  • Log pack voltage. When resting voltage after a full charge drops below about 4.0 V, the cell is near end of life — replace it.
  • Wash the panel every few weeks in dusty conditions; a visible dust film costs 15–25 % of the harvest.
  • Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
  • Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
  • Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.

Future Improvements & Upgrades

A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.

  • Fuse many nodes with reference stations in a spatial model for a continuously-calibrated citywide field.
  • Add a proper NO₂/O₃ electrochemical channel for the traffic-related gases PM misses.
  • Machine-learn the humidity/calibration correction per node from ongoing co-location.
  • Add source attribution (traffic vs dust vs burning) from PM size ratios and diurnal patterns.
  • Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
  • Add over-the-air firmware updates so you never have to physically reach a deployed node again.
  • Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
  • Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
  • Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
  • Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.

Frequently Asked Questions

Is a cheap node accurate enough to be useful?

After humidity correction and co-location calibration, yes — for mapping trends and hotspots at high spatial density. It is not a regulatory instrument, and the design says so with data-quality flags rather than pretending otherwise.

Why does humidity matter so much?

Many particles absorb water and swell in damp air, scattering more light, so an uncorrected optical sensor over-reads on foggy mornings. Correcting for humidity removes those false spikes.

What is co-location and why is it essential?

Running the node beside a reference monitor to derive a correction factor. Low-cost sensors have offsets and unit variation; co-location aligns them to truth and makes a network credible.

Can it measure CO₂ or toxic gases accurately?

Only qualitatively. Low-cost gas sensors drift and cross-respond, so the node reports gas as a relative trend, never as a regulated absolute value.

How dense can a network be?

As dense as you can deploy nodes — that is the whole point. Cheap, solar, wireless, comparable nodes let you map pollution street by street where official stations are kilometres apart.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. WHO — ambient air pollution and healthWHO
  2. US EPA — air sensor guidebook and correction methodsUS EPA
  3. PMS5003 optical particle sensor (datasheet)Plantower
  4. Hygroscopic growth and low-cost PM correction — literatureReference
  5. Air Quality Index — computation and breakpointsReference