A high-density, real-time air quality and environment monitor running on a Raspberry Pi with a Pimoroni Enviro+ HAT. Four pages on the 160×80 ST7735 screen (indoor air, particulates, outdoor weather, gas), changed by a wave over the proximity sensor, with persistent SQLite logging.
enviro_screens.py draws each page; a hand over the LTR559 proximity sensor moves to the next one. Corner dots show which page is on.
| Page | Shows |
|---|---|
| 1 · Glance | EPA AQI gauge (2024 PM2.5 table); indoor °F, humidity, pressure; outdoor °F and humidity from the Ambient station with trend |
| 2 · Air | PM1, PM2.5, PM10 in µg/m³ with trend lines on one shared scale |
| 3 · Outside | Wind direction, speed and gust; UV; daily rain (Ambient station) |
| 4 · Gas | Oxidising, reducing, NH3 sensor resistance in kΩ with trends |
A missing or failed reading shows -- and leaves a gap in its trend; it is never drawn as zero. Outdoor values older than display.outdoor_stale_s show --.
Color coding: the AQI gauge and PM2.5 value use the EPA category colors (Good green, Moderate yellow, Unhealthy for Sensitive Groups orange, Unhealthy red, Very Unhealthy purple, Hazardous maroon). Indoor temperature, humidity and gas values use the green/yellow/orange (gas: red) bands in thresholds.
| Sensor | Chip | Measures | Notes |
|---|---|---|---|
| Temperature | BME280 | °F (self-heat compensated) | Constant offset calibration.self_heat_offset_c in the CONFIG_PATH document |
| Humidity | BME280 | % RH | |
| Pressure | BME280 | hPa | |
| Light | LTR559 | Lux | Also used as proximity sensor |
| Oxidising gas | MICS6814 | kΩ (NO₂, O₃) | Higher resistance = cleaner air |
| Reducing gas | MICS6814 | kΩ (CO, VOCs) | Lower resistance = more pollution |
| Ammonia | MICS6814 | kΩ (NH₃) | Lower resistance = more pollution |
| PM1.0 | PMS5003 | µg/m³ | External particulate sensor |
| PM2.5 | PMS5003 | µg/m³ | Fine particles — primary AQI metric |
| PM10 | PMS5003 | µg/m³ | Coarse particles |
🔬 Planned: Adafruit SCD-41 breakout (CO₂, I2C 0x62) — no address conflict with existing sensors
flowchart LR
subgraph Hardware
BME280 -->|I2C| PI
LTR559 -->|I2C| PI
MICS6814 -->|I2C/ADC| PI
PMS5003 -->|UART| PI
end
subgraph PI["Raspberry Pi"]
enviro_dash3.py
ambient_wx.py
nws_wx.py
provider_collector.py
dynamic_config.pi5.json
end
subgraph Canonical["Canonical tier"]
RAW[("📥 raw-capture/\nprovider responses as received")]
end
subgraph Derived["Derived tier"]
SQLITE[("🗄️ SQLite\nenviro.db")]
end
subgraph Outputs
DISPLAY["🖥️ ST7735\n160×80 Display"]
GRAFANA["📈 Grafana\n(container, reads enviro.db)"]
OFFSITE[("☁️ Offsite backup\nverified by remote hash")]
end
dynamic_config.pi5.json -->|hot-reload| enviro_dash3.py
enviro_dash3.py --> DISPLAY
enviro_dash3.py -->|every 60s| SQLITE
ambient_wx.py & nws_wx.py & provider_collector.py -->|response bytes| RAW
ambient_wx.py & nws_wx.py & provider_collector.py -->|projected rows| SQLITE
RAW -->|replay| SQLITE
SQLITE --> GRAFANA
RAW & SQLITE -->|daily| OFFSITE
| Component | Where to buy |
|---|---|
| Pimoroni Enviro+ | Pimoroni |
| PMS5003 Particulate Sensor | Pimoroni |
| Raspberry Pi (3 B+, 4, 5, or Zero 2 W) | Various |
| Adafruit SCD-41 CO₂ Sensor | Adafruit (planned) |
git clone https://github.com/pimoroni/enviroplus-python
cd enviroplus-python
./install.shgit clone https://github.com/strommy76/enviroplus.git ~/projects/enviroplus
cd ~/projects/enviropluspip install -r requirements.txtcp .env.example .env # add credentials — secrets only, no tuning values here| Variable | Description |
|---|---|
SQLITE_PATH |
Path to SQLite database |
CONFIG_PATH |
Path to the dashboard config document (dynamic_config.pi5.json) |
NWS_API_BASE_URL |
NWS API base URL |
NWS_STATIONS |
Ordered NWS station identifiers; first fresh observation wins |
NWS_USER_AGENT |
NWS-required caller identification |
NWS_POLL_S |
NWS poll interval in seconds |
NWS_SHUTDOWN_CHECK_S |
NWS service shutdown check cadence in seconds |
NWS_MAX_OBSERVATION_AGE_S |
Maximum accepted NWS latest-observation age in seconds |
NWS_COLLECTION_LOOKBACK_S |
Bounded NWS observations query window in seconds |
All tuning values (calibration, thresholds, intervals, display) live in the CONFIG_PATH document — see below.
sudo systemctl link "$PWD/enviro_dash.service"
sudo systemctl enable --now enviro_dashCheck status:
sudo systemctl status enviro_dash
journalctl -u enviro_dash -f # or tail -f enviro_dash.log; each service has its own <service>.logBefore using this deployment example, reconcile the unit's device bindings and
operations-alert template with the target host. Repository content does not select
the active hardware unit; prove that binding from the running service manager as
required by PROJECT-CONFIG.md. Preserve and compare an existing copied unit as
described in the repair plan. For the repaired display,
GPIO9 is DC; pi-config owns the spi0-2cs,no_miso firmware binding that frees it.
All tunable runtime values live here. The running script watches this file and hot-reloads within 2 seconds of any change — no restart needed.
{
"calibration": {
"self_heat_offset_c": 6.35, ← measured BME280 self-heat (see its provenance key)
"bme_samples": 3, ← BME280 readings averaged per loop (reduces noise)
"cpu_hist_size": 30 ← CPU temp history (proportional model only)
},
"intervals": {
"publish_s": 60, ← seconds between SQLite writes
"display_refresh_s": 2 ← display update rate
},
"thresholds": { ... }, ← color bands for temperature, humidity and gas
"display": { ... } ← proximity tap level, outdoor staleness
}To recalibrate temperature: re-measure the offset against a reference over 5-minute means and update self_heat_offset_c; its self_heat_offset_provenance records how the current value was measured.
The BME280 sits close to the Pi's CPU and reads high. On this Pi 5 with M.2 spacing the error is a near-constant pedestal, so the dashboard subtracts a measured constant, calibration.self_heat_offset_c. cpu_factor_override remains only for hardware where the sensor sits directly above the SoC; setting both fails loud. Humidity is corrected for the same self-heating with the Magnus formula.
Known-bad spans in the authoritative readings table. Rows are kept as written; these notes are how a reader knows to discount them.
| Span (UTC) | Columns | Issue |
|---|---|---|
| 2026-07-30 → 2026-08-05 | pm1, pm25, pm10 |
~85,000 PMS5003 read timeouts; the code of the time re-stored the last good values as fresh, so PM in this span is largely repeats. Fixed 2026-10-11 (failed reads now store NULL). |
| 2026-09-17 00:26 → 2026-10-11 12:48 | all | No rows: the dashboard could not start after kernel 6.18.50 claimed GPIO9 (fixed by pi-config spi0-2cs,no_miso). |
CREATE TABLE readings (
ts TEXT PRIMARY KEY, -- UTC: "2026-03-15 21:30:00"
temp_f REAL,
humidity REAL,
pressure REAL,
lux REAL,
oxidising REAL,
reducing REAL,
ammonia REAL,
pm1 REAL,
pm25 REAL,
pm10 REAL,
cpu_temp_c REAL, -- Pi CPU temperature (°C)
cpu_load REAL, -- 1-minute load average
mem_free_mb REAL, -- available memory (MB)
uptime_s INTEGER -- system uptime (seconds)
);Query example — correlate CPU temp with sensor drift:
SELECT ts, temp_f, cpu_temp_c, cpu_load
FROM readings
WHERE ts >= '2026-03-15 22:00:00'
ORDER BY ts;| Resource | Link |
|---|---|
| Pimoroni Enviro+ Python library | https://github.com/pimoroni/enviroplus-python |
| PMS5003 Python library | https://github.com/pimoroni/pms5003-python |
| LTR559 Python library | https://github.com/pimoroni/ltr559-python |
| Pimoroni Enviro+ product page | https://shop.pimoroni.com/products/enviro-plus |
| Pimoroni learning: Enviro+ | https://learn.pimoroni.com/article/getting-started-with-enviro-plus |
| MICS6814 datasheet | https://www.sgxsensortech.com/content/uploads/2015/02/1143_Datasheet-MiCS-6814-rev-8.pdf |
| PMS5003 datasheet | https://www.aqmd.gov/docs/default-source/aq-spec/resources-page/plantower-pms5003-manual_v2-3.pdf |
| Adafruit SCD-41 guide | https://learn.adafruit.com/adafruit-scd-40-and-scd-41 |
- 10-sensor dashboard on ST7735 display
- CPU temperature compensation with auto-derived factor
- SQLite logging with UTC timestamps + Pi telemetry
- Rotating log file
- systemd service with auto-restart
- Hot-reloadable dashboard config, single source of truth
- BME280 averaging + CPU history smoothing for noise reduction
- Ruff linting with pre-commit hook (referee not installed)
- Adafruit SCD-41 CO₂ sensor integration
- Grafana dashboard on AI host via Tailscale
- Migrate to Raspberry Pi 5