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.
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
| Setting | How it is used |
|---|---|
| Community air-quality networks | Neighbourhood groups and NGOs building street-level pollution maps to reveal hotspots that sparse official stations miss. |
| School and campus monitoring | Showing children and staff real, local exposure and informing decisions about outdoor activity on bad-air days. |
| Traffic and construction impact studies | Quantifying how much a junction, a building site or a policy change shifts local pollution over time. |
| Personal and workplace awareness | A 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 12–18 hours |
| Indicative build cost | ₹3,600 – ₹4,800 |
| Primary discipline | Environment |
| Reference platform | ESP32 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.
| Component | Key specification | Qty | Approx. 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 DAC | 1 | ₹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 stabilisation | 1 | ₹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 Hz | 1 | ₹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 output | 1 | ₹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 output | 1 | ₹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–SF12 | 1 | ₹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 frame | 1 | ₹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 protection | 1 | ₹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 discharge | 1 | ₹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 out | 1 | ₹500 |
| PM inlet filter/insect screen | Fine mesh at the PMS5003 inlet to keep insects out without restricting flow | 1 | ₹60 |
| Reference co-location time (access) Not hardware — but essential to the node's validity | Time beside a regulatory monitor to derive calibration | 1 | — |
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
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| 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 DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| Plantower PMS5003 laser particulate sensor | PM1.0 / PM2.5 / PM10, 0–500 µg/m³, ±10 %, laser scattering, 30 s stabilisation | 5 V | UART 9600 | Datasheet |
| BME280 pressure/humidity/temperature sensor | 300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz | 1.7–3.6 V (module has 3.3 V LDO) | I²C (0x76/0x77) or SPI | Datasheet |
| MQ-135 air-quality sensor | NH₃, NOx, benzene, smoke, CO₂ proxy, 10–1000 ppm, analogue output | 5 V (heater) | Analogue | Datasheet |
| MH-Z19B NDIR CO₂ sensor | 0–5000 ppm ±(50 ppm + 5 %), NDIR, 60 s warm-up, UART + PWM output | 4.5–5.5 V | UART 9600 8N1, PWM | Datasheet |
| SX1278 LoRa 433 MHz module (Ra-02) | −148 dBm sensitivity, +20 dBm output, up to 10 km line of sight, SF7–SF12 | 3.3 V | SPI | Datasheet |
| 20 W 12 V polycrystalline solar panel | Vmp 17.5 V, Imp 1.14 A, Voc 21.6 V, 350 × 290 mm, aluminium frame | 12 V nominal | MC4 / screw terminals | Datasheet |
| TP4056 Li-ion charger + DW01 protection | 1 A programmable CC/CV charge to 4.2 V ±1 %, over-discharge and short protection | 4.5–5.5 V in | micro-USB / pads | Datasheet |
| 18650 Li-ion cell 3400 mAh + holder | 3.7 V nominal, 4.2 V full, 3400 mAh, ~12.6 Wh, 2 C discharge | 3.0–4.2 V | Holder / spot-welded tabs | Datasheet |
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.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| Plantower PMS5003 laser particulate sensor | 5 V | 100 | The fan is a wear item — run it in duty cycles, not continuously. |
| BME280 pressure/humidity/temperature sensor | 1.7–3.6 V (module has 3.3 V LDO) | 0.4 | Self-heating skews temperature by ~1 °C — read in forced mode, not continuous. |
| MQ-135 air-quality sensor | 5 V (heater) | 150 | Not a true CO₂ sensor — calibrate against clean air (R0) before trusting ppm. |
| MH-Z19B NDIR CO₂ sensor | 4.5–5.5 V | 60 | Disable auto-baseline calibration (ABC) for sealed rooms or it drifts to 400 ppm. |
| SX1278 LoRa 433 MHz module (Ra-02) | 3.3 V | 120 | Never power the radio without an antenna — the PA will destroy itself. |
| 20 W 12 V polycrystalline solar panel | 12 V nominal | 1140 | Rated watts assume 1000 W/m² — plan for 60–70 % of nameplate in real installs. |
| TP4056 Li-ion charger + DW01 protection | 4.5–5.5 V in | 1000 | Buy 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.jsonunder 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
dialoutgroup:sudo usermod -aG dialout $USERand 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
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| Adafruit BME280 2.2.x | Compensation maths for the Bosch pressure/humidity/temperature sensor. | Library Manager → "Adafruit BME280 Library" |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-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.
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.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| PMS5003 | TX/RX | GPIO 16/17 | UART PM2.5/PM10 counts |
| PMS5003 | SET | GPIO 26 | Sleep/wake (duty-cycle the fan) |
| BME280 | SDA/SCL | GPIO 21/22 | Temp/RH/pressure (I²C) |
| MQ135 / MH-Z19 | AOUT / UART | GPIO 34 / 4 | Optional gas channel |
| LoRa SX1276 | SCK/MISO/MOSI | GPIO 18/19/23 | SPI radio bus |
| LoRa SX1276 | NSS/RST/DIO0 | GPIO 5/14/2 | Chip-select, reset, IRQ |
| TP4056 | OUT | VIN / 3V3 reg | Solar-charged 18650 supply |
| Solar panel | +/– | TP4056 IN | 6 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.
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.
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
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)
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
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.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
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.
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.
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.
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.
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.inostruct 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.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.
/* ═══════════════════════════════════════════════════════════════
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()
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.
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.
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.
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
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 / endpoint | Direction | Payload |
|---|---|---|
air/node/1/reading | node → broker | PM2.5/10 corrected+raw, RH, AQI, quality |
air/node/1/status | node → broker | battery, RSSI, calibration date |
air/node/1/cal | broker → node | push 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.
| Test | What you should see |
|---|---|
| Sample immediately on wake vs after fan settle | Settled readings are stable; cold-start readings are not — confirming the warm-up matters |
| Breathe/smoke near the inlet briefly | PM rises and recovers; frame checksum validates each read |
| Expose to a foggy/high-humidity morning | Raw PM spikes; corrected PM stays sane or the reading is flagged Q_HUMID |
| Compare to the reference during co-location | Corrected node tracks the reference within expected low-cost tolerance |
| Run a solar day/night cycle | Battery 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.
{
"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.
Troubleshooting: Common Errors & Fixes
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 amillis()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_twhere 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.