ESP32 firmware for an air-quality monitor that reads particulate matter, temperature, and humidity, drives a 16×2 I2C LCD, handles four push buttons via interrupts, and reports over WiFi.
- MCU: ESP32 (DOIT ESP32 DevKit v1)
- PM sensor: Plantower PMS5003 (UART, on
Serial2) - Temp/Humidity: DHT22
- Display: 16×2 character LCD over I2C (PCF8574 backpack, address
0x3F) - Input: 4 push buttons (Right / Left / Settings / Boot)
| Function | GPIO | Notes |
|---|---|---|
| I2C SDA / SCL | 21 / 22 | LCD |
| PMS5003 RX / TX | 16 / 17 | Serial2, 9600 baud |
| DHT22 data | 14 | |
| Button: Right | 34 | input-only pin, FALLING edge |
| Button: Left | 35 | input-only pin, FALLING edge |
| Button: Settings | 36 | disabled (see note below) |
| Button: Boot | 39 | disabled (see note below) |
Note: GPIO 34/35/36/39 are input-only and have no internal pull-ups — make sure each button has an external pull-up resistor.
Settings/Boot are disabled in firmware: GPIO36/39 are RTC-domain pins and an ESP32 erratum makes them glitch on every WiFi modem-sleep wake, firing phantom interrupts. WiFi power-save was kept instead of these buttons; rewire them to non-RTC pins (and re-enable in
src/buttons.cpp) to bring them back.
.
├── platformio.ini # build config, board, library deps
├── src/main.cpp # firmware source
├── include/
│ ├── secrets.h # WiFi credentials (gitignored — create from template)
│ └── secrets.example.h # template to copy
└── README.md
This is a PlatformIO project.
-
Copy the secrets template and fill in your WiFi details and device credentials (register a device against the backend to get
DEVICE_IDand the one-timeDEVICE_KEY):cp include/secrets.example.h include/secrets.h # then edit include/secrets.h -
Build:
pio run
-
Upload to the board:
pio run -t upload
-
Open the serial monitor (9600 baud):
pio device monitor
- Secrets live in
include/secrets.h:WIFI_SSID,WIFI_PASSWORD,DEVICE_ID,DEVICE_KEY,MOCK_LAT,MOCK_LON. - Board, monitor speed, and library dependencies are set in
platformio.ini. - The backend base URL is
BACKEND_BASE_URLininclude/config.h(defaulthttps://api.air-iq.net/api/v1; override with a-DBACKEND_BASE_URL=...build flag).
The device is designed to be provisioned by a mobile app over BLE (Nordic UART
service, advertised as AirMonitor-XXXX, where XXXX is a stable device suffix)
with newline-terminated commands:
SSID:<name> PASS:<secret> LAT:<deg> LON:<deg> DEVID:<dev_...> DEVKEY:<sk_...>
SAVE LOAD CLEAR PING STATUS
Responses are newline-terminated and may be split across 20-byte BLE
notifications. The client must buffer notification bytes until a newline.
LOAD is framed by LOAD_BEGIN / LOAD_END and returns one setting per line;
SAVE is framed by SAVE_BEGIN / SAVE_END and reports SAVE_OK or
SAVE_FAILED:WIFI.
For development, mock provisioning can seed an empty configuration from
secrets.h, exactly as the app would over BLE. Normal firmware builds disable
mock provisioning and preserve saved configuration. BLE provisioning remains
fully functional and can overwrite mock values at any time.
Sensors are sampled every 20 seconds; the display always shows the latest
sample. Every 10 minutes (first upload right after boot) the firmware
averages the window's samples (~30 per upload; AQI recomputed from the averaged
concentrations) and POSTs the result to {BACKEND_BASE_URL}/data with
X-Device-ID / X-Device-Key headers:
{
"pms_data": { "pm1_0": 5, "pm2_5": 12, "pm10_0": 18, "provider": "pms5003" },
"aqi": 57,
"temperature_data": { "temperature": 31.2, "humidity": 64.5, "heat_index": 36.8, "provider": "dht22" },
"location": { "lat": 24.8607, "lon": 67.0011, "provider": "mobile" }
}location is omitted while lat/lon are unset (0/0). Uploads are skipped (and
logged) when WiFi is down, credentials are missing, or the reading is
incomplete; the next tick retries. HTTPS validates the server against the GTS
Root R4 CA certificate embedded in the firmware image.
Three screens on the 16×2 LCD, refreshed with each 20 s sensor sample using fixed-width padded row writes (no flicker, no stale characters):
- AQI (home) —
AQI 54 Moderate/PM2.5 25ug/m3 - Climate —
T 29.1C HI 33.5C/Humidity 61% - Status —
WiFi <ip>orWiFi DOWN/Up 201 3m ago
Buttons: Right/Left cycle through all screens. (Settings/Boot are disabled — see the pin-map note; a real settings menu is on the roadmap.)
Run make release from a clean main branch and enter major, minor, patch, or an exact
semantic version when prompted. To bypass the prompt, run make release version=1.4.0. A leading
v and prerelease suffixes such as 1.4.0-rc.1 are accepted. The command builds the firmware,
tags the commit, and publishes the artifacts in a GitHub release.
Releasing requires authenticated gh and PlatformIO CLIs and changes external Git/GitHub state.
- Fix the HTTPClient URL-parser bug. The query string is currently only
reachable by adding a
/before?in the request URL, as a workaround for an upstream parser bug. Track down and fix the root cause (or upstream a patch). See docs/upstream-bug-httpclient-url-parser.md. - Reduce firmware size. Adding BLE (Bluedroid) on top of WiFi + TLS pushed
the binary past the default 1.3 MB app partition, so the build now uses the
huge_app.csvpartition scheme (3 MB app, no OTA). Investigate slimming the build (e.g. NimBLE instead of Bluedroid) to recover headroom and re-enable OTA.