bsidio/unifi-protect-airquality-dashboard
Realtime dashboard for the UniFi UP-AirQuality sensor, with history in ClickHouse. Reads air quality the public Protect API cannot expose. - bsidio/unifi-protect-airquality-dashboard
2 stars · 0 forks · 0 watchers
README
UniFi Protect Air Quality Dashboard
Realtime dashboard for the UniFi UP-AirQuality (Vape Detection & Air Quality Sensor), storing every reading in ClickHouse. Next.js app + a collector that holds one WebSocket open to your Protect console and writes readings as they arrive (~1 Hz).
More screenshots Onboarding — checks config, ClickHouse, Protect, and the collector, refreshing itself until everything is green.
Table view — the same numbers without relying on colour.
Login — only shown when AUTH_ENABLED=true.
Features
Live readings over a single WebSocket (~1 Hz), no polling Full history in ClickHouse, one table, queryable however you like Severity levels from published standards, with the source named in the UI Particulates stacked as size bands, so the total is PM10 rather than four-times-counted Drag-and-drop chart layout, saved per browser Relative ranges (15m → 30d) or an absolute calendar range Public dashboard, or single-account login — one env var Onboarding page that diagnoses a broken install
Why this needs a login, not an API key The endpoint you would expect to use does not work:
Route Result
GET /proxy/protect/integration/v1/sensors/{id} + API key No air quality. The public sensor schema stops at light / humidity / temperature, all null on a UP-AirQuality
wss://…/integration/v1/subscribe/devices + API key Connects, then stays silent — the public schema has no airQuality field to push
The sensor itself, on :8080 ESP32, SSH disabled, one auth-gated POST /api/info. Telemetry leaves over ucp4, a proprietary binary protocol — not REST
Protect backend ports :7443 / :7444 302 back through the same nginx proxy. No auth bypass
GET /proxy/protect/api/bootstrap + session cookie ✅ sensors[].airQuality
wss://…/proxy/protect/ws/updates + session cookie ✅ live pushes
The private API rejects API keys (500 on X-API-KEY, 401 on Bearer), and there is no api-key-login route to trade a key for a session. So the collector logs in with a username and password, exactly as the browser does. See uiprotect#1007. Frames on that socket are undocumented: a run of 8-byte headers (type, format, deflated, _, uint32 size), each followed by an optionally zlib-deflated payload. Packet 1 is the action, packet 2 the data. Pushes can be partial, so lib/protect.ts merges them onto the last full reading. Setup cp .env.example .env # fill it in npm install npm run dev # http://localhost:3000 Visit /onboarding — it checks config, ClickHouse, Protect, and the collector, and refreshes itself until everything is green. The readings table is created automatically on first run. Requirements
A local UniFi OS account without MFA (MFA breaks unattended reconnects). A dedicated read-only account is the sane choice. ClickHouse reachable from the app.
Configuration Everything lives in .env. Changing it requires a server restart — the auth middleware compiles its env in.
Variable Meaning
APP_NAME Site name in the header and browser tab
UNIFI_HOST Console address, e.g. 192.168.1.1
UNIFI_USER / UNIFI_PASS Local console account
UNIFI_VERIFY_SSL 0 unless the console has a real certificate
CLICKHOUSE_URL / _DB / _USER / _PASSWORD Database connection
AUTH_ENABLED false = public dashboard, true = login required
AUTH_USER / AUTH_PASSWORD The single account used when auth is on
APP_SECRET Session signing key — openssl rand -hex 32
COLLECTOR_ENABLED Set false to run the UI without ingesting
COLLECTOR_FLUSH_MS Batch insert interval
COLLECTOR_DEDUPE Only write a metric when its value changes
Auth is a signed cookie (HMAC-SHA256, Web Crypto). No session store, no users table. Storage One table — sensor data only. Devices for the picker are derived from it. CREATE TABLE readings ( ts DateTime64(3,'UTC'), console LowCardinality(String), sensor_id LowCardinality(String), sensor_name LowCardinality(String), metric LowCardinality(String), value Float64, status LowCardinality(String) ) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (sensor_id, metric, ts) TTL toDateTime(ts) + INTERVAL 24 MONTH Dedupe changes what row counts mean. With COLLECTOR_DEDUPE=true a metric is written only when it changes, so TVOC (few distinct values) has far fewer rows than VOC (many) — that is not data loss. Because of this, /api/series carries the last known value forward across empty buckets; without it the charts would render as dotted fragments and any average would skew toward whichever metric churns most. Metrics, units, and levels Units come from the UP-AirQuality tech specs. Note VOC and TVOC are indices (idx), not ppb.
Metric Unit Sensor range Level source
AQI — 0–500 US EPA AQI categories
Vape — 0–100 Device's own vapeSettings alert threshold (50)
CO₂ ppm 0–40,000 ASHRAE / common IAQ guidance
VOC idx 1–500 Sensirion VOC Index (100 = room baseline)
TVOC idx — No published bands — falls back to UniFi's own status word
PM1.0 µg/m³ 0–1000 No PM1 standard; PM2.5 bands as a proxy
PM2.5 µg/m³ 0–1000 US EPA 24-hour breakpoints
PM4.0 µg/m³ 0–1000 No PM4 standard; PM10 bands as a proxy
PM10 µg/m³ 0–1000 US EPA 24-hour breakpoints
Temperature °C 0–40 Not graded — comfort is a preference, not an air-quality level
Humidity % RH 0–90 EPA / ASHRAE indoor guidance
UniFi's airQualitySettings only carries user-configurable alert thresholds (null until you set them), so severity bands come from the public standards above rather than from the device. Every colour is paired with its word (Good / Fair / Elevated / Poor / Severe), and hovering a level shows which standard produced it — colour never carries the meaning alone. Metrics without a defensible band say so and defer to the sensor. Particulates are stacked as size bands PM readings are cumulative — PM10 already contains PM2.5, which contains PM1.0. Stacking the raw values would count the same particles up to four times. The stacked chart plots the band between adjacent cuts (≤1.0, 1.0–2.5, 2.5–4.0, 4.0–10), so the total equals PM10 and each band is an honest mass-in-this-size-range. Layout Charts are draggable; order, metric selection, range, and device are saved to localStorage per browser. "Reset layout" restores the default. Metrics live on very different scales (CO₂ ~700 ppm beside PM2.5 ~3 µg/m³), so they render as small multiples — one chart, one axis — rather than sharing a second y-axis. API
Route Purpose
GET /api/health Config, ClickHouse, Protect, collector — powers onboarding
GET /api/devices Sensors seen in ClickHouse, merged with live ones
GET /api/latest?sensor= Freshest reading (in-process, else ClickHouse)
GET /api/series?sensor=&metrics=&range= or &from=&to= Bucketed history
GET /api/live SSE stream of readings
POST /api/auth/login · /logout Session cookie
tools/ — standalone Python The original prototype, still useful for debugging without the web app: pip install -r tools/requirements.txt python3 tools/stream.py --once # one snapshot python3 tools/stream.py # live stream python3 tools/server.py # minimal local dashboard, no ClickHouse It reads the same root .env. Where readings are stored By default this dashboard keeps everything in a ClickHouse you run. It does not have to. STORE=clickhouse # a database you host. Nothing is forwarded. STORE=openaqi # no local database; openaqi keeps your history for you. STORE=both # written locally and forwarded. Read locally. Leave STORE unset and nothing changes: it resolves to both when OPENAQI_KEY is set and clickhouse when it is not, which is what your install is already doing. STORE=openaqi is the low-maintenance option. You are already sending these readings; this reads them back, so there is no database to stand up, back up, or watch fill a disk. Set OPENAQI_KEY and drop the CLICKHOUSE_* variables entirely. It is an honest trade, and worth knowing the terms before you pick it:
clickhouse openaqi
History before you switched forwarding on kept not there — nothing is backfilled
vape charted live only; openaqi has no such metric
CSV status column the sensor's own word empty; openaqi stores numbers
Long windows exact, from raw rows from averages, so p95 is understated
If the store is unreachable local, so rarely no history until it returns
Resolution raw raw up to 2h, then bucketed
The dashboard says which of these apply as they happen rather than leaving you to work it out: the footer names the backend, a chip appears on the statistics when they came from averages, and vape is marked live only. A note on comparing the two. The numbers will not match exactly, and that is not a bug. The local writer de-duplicates — it stores a row only when a value changes — while the forwarder sends every reading. So ClickHouse holds change events and openaqi holds the full stream, which moves avg, p50, p95 and the sample count. The figures built to survive this are seconds_above and seconds_total: they are duration-weighted, so they mean the same thing in both. Contribute to openaqi (optional) Your sensor is measuring air quality in a place official monitoring almost certainly does not cover — often the nearest reference station is the only one for the whole city. openaqi.net is a free public map built from readings like these. Set one variable and this dashboard forwards its readings there once a minute:
Register a station at https://openaqi.net/account — approximate
coordinates are fine, and they are never published.
OPENAQI_KEY=oaq_your_station_key That is the whole integration. No key, no forwarding. What is sent. Timestamps, metric names and numbers. Nothing else. { "readings": [ { "ts": "2026-08-02T20:03:25.185Z", "metric": "pm2p5", "value": 9.1 } ] } Not your coordinates — openaqi resolves location from the station you registered, server-side, to an area of about 5 km². Not your sensor's name either: this dashboard knows it as "Bedroom"; openaqi never learns that. Its readings table has no column for either. Check rather than trust: OPENAQI_DRY_RUN=true # logs the exact payload and sends nothing What it will not do. Forwarding runs on its own timer with its own buffer, so openaqi being slow, down or misconfigured cannot delay or lose a local write — your ClickHouse is always written first. A network failure keeps the readings and retries with backoff. A rejected key stops immediately with a message rather than retrying forever, and vape is filtered out because openaqi's catalogue does not include it. Status appears in /api/health under openaqi — counters and the last error, never the key. Contributing Issues and pull requests are welcome — see CONTRIBUTING.md. Two things worth reading before changing anything important: lib/protect.ts decodes an undocumented protocol that Ubiquiti can change in any release, and lib/metrics.ts deliberately leaves thresholds off metrics that have no published bands rather than inventing numbers. AcknowledgementsChart components from EvilCharts (ECharts engine), installed via the shadcn registry and vendored under components/evilcharts/. uiprotect#1007 documented the gap in the public integration API. UPAQ-MQTT showed that the private WebSocket was the practical route to this data.
Disclaimer Not affiliated with, endorsed by, or supported by Ubiquiti. "UniFi" and "UniFi Protect" are trademarks of Ubiquiti Inc. This talks to a private, undocumented API using your console credentials. It reads only; it never writes to your Protect install. A Protect update can break it without warning. Use a dedicated, least-privilege local account. License MIT



