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 | input-only pin, FALLING edge |
| Button: Boot | 39 | input-only pin, FALLING edge |
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.
.
├── 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://backend-h5v6.onrender.com/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-Setup) with line commands:
SSID:<name> PASS:<secret> LAT:<deg> LON:<deg> DEVID:<dev_...> DEVKEY:<sk_...>
SAVE LOAD CLEAR PING STATUS
Until the app exists, mock app provisioning stands in for it:
MOCK_APP_PROVISIONING (default 1, include/config.h) seeds the config from
secrets.h on first boot — exactly the values the app would have sent — and
saves them to flash. BLE provisioning stays fully functional and overwrites the
mock values at any time. Set the flag to 0 once the real app ships.
Mock provisioning only fires when flash is empty. After changing secrets.h,
either send CLEAR over BLE or build once with WIPE_CONFIG_ON_BOOT set to
1 (include/config.h) — it wipes the saved config on every boot so the
re-seed happens. Set it back to 0 afterwards, or the device forgets BLE
provisioning on every restart.
Every 10 minutes (first upload right after boot) the firmware POSTs the latest
reading 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. TLS currently uses setInsecure() — cert
pinning is on the roadmap.
Three screens on the 16×2 LCD, refreshed with each 3 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 screens, Boot returns to the AQI screen, Settings jumps to the status screen (a real settings menu is on the roadmap).
A Makefile wraps the common PlatformIO and release steps. The version and
release notes are inputs you pass on the command line — they are not baked
into the Makefile.
| Target | What it does |
|---|---|
build |
Compile the firmware (pio run). |
merge |
Build, then merge bootloader + partitions + app into one flashable bin. |
tag |
Create and push a git tag (VERSION required). |
release |
Build, merge, tag, and publish a GitHub release with the artifacts. |
clean |
Remove build artifacts. |
| Variable | Default | Purpose |
|---|---|---|
VERSION |
(required) | Release/tag name, e.g. v0.1.0. No default — must be set. |
NOTES_FILE |
RELEASE_NOTES.md |
File whose contents become the release notes. |
NOTES |
(unset) | Inline release notes; overrides NOTES_FILE if given. |
PRERELEASE |
true |
Mark the GitHub release as a pre-release. Set false for a full release. |
Inputs are validated up front (check-release-inputs), so a missing version or
notes file fails immediately — before any git tag is pushed.
# Just build
make build
# Cut a pre-release, notes read from RELEASE_NOTES.md
make release VERSION=v0.1.0
# Notes from a specific file
make release VERSION=v0.1.0 NOTES_FILE=notes/v0.1.0.md
# Inline notes
make release VERSION=v0.1.0 NOTES="Fixed BLE provisioning"
# Full (non-pre) release
make release VERSION=v1.0.0 PRERELEASE=false- 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.