UniFi Protect
The one device integration openaqi ships today. Send readings from a UniFi air quality sensor with either the dashboard or the agent.
UniFi Protect is currently the only device-specific integration openaqi ships. It exists because it is the hardware we run ourselves, so it is the hardware we can actually test against.
More will follow, one at a time. Support gets added when someone can point it at a real device and watch the numbers come out right — a driver written against a datasheet and never run is worse than no driver, because it fails quietly and the readings look plausible.
Other devices are not accepted yet
Registering a station currently requires declaring a UniFi Protect air quality sensor. This is a deliberate narrow opening rather than a permanent policy.
The reason is that a reading carries no proof of what produced it. At resolution 7 a cell often holds a single sensor, so one contributor sending invented numbers moves that area's published air quality with nothing to contradict them. Starting with the one device we run ourselves keeps the dataset defensible while the checks that make a wider door safe get built.
If you have other hardware, tell us what it is. That is how the next device gets added — someone points the agent at a real unit and watches the numbers come out right.
A local console account is required
Not a Ubiquiti cloud login, and not an API key. UniFi's documented Integration API does not expose air quality at all — its sensor endpoints return an error for those fields, and the readings only exist behind a session cookie. Create a local user on the console with read access to Protect.
Two ways in
Both send identical readings. The difference is whether you keep a copy.
unifi-protect-airquality-dashboard is a self-hosted dashboard for the UniFi air quality sensor. It reads the console directly, keeps full history in your own ClickHouse, and draws the charts the Protect app does not. It is open source, and openaqi is one optional output of it rather than its purpose.
If you already run it, forwarding is one variable:
OPENAQI_KEY=oaq_your_station_keyThat is the entire integration. No key, no forwarding.
With one, it sends a batch a minute. Nothing changes about how it behaves locally: your ClickHouse is written first and openaqi is fed from a separate buffer, so us being slow, down or misconfigured cannot delay or lose a local write. A network failure keeps the readings and retries with backoff. A refused key stops immediately with a message rather than retrying forever.
OPENAQI_DRY_RUN=true # logs the exact payload, sends nothing
OPENAQI_SERVER=... # defaults to https://ingest.openaqi.netCounters and the last error appear in the dashboard's own /api/health under
openaqi, so you can confirm forwarding works without leaving it:
{
"openaqi": {
"enabled": true,
"sent": 90,
"rejected": 0,
"pending": 12,
"lastError": null,
"halted": null
}
}enabled: false means the key is not reaching the process — an absent key is
how the feature is switched off, so it stays quiet rather than erroring.
One binary, no database, no dashboard. It reads the same console and passes the readings on, keeping nothing.
openaqi-agent --source unifi \
--unifi-host 10.0.0.1 \
--unifi-user your-local-user \
--unifi-pass '••••••••' \
--dry-runDrop --dry-run and add --key oaq_your_station_key to send for real. With
more than one sensor, add --unifi-sensor "Bedroom" — and if the name is wrong
the agent lists the ones it found rather than failing silently.
The console's self-signed certificate is accepted by default for LAN addresses.
That is what --unifi-insecure controls, and it defaults on because a console
on your own network is exactly the case where verification cannot succeed.
For systemd units and the full flag list, see The agent.
What the sensor reports
Ten metrics reach openaqi from a UP-Sense-class device:
| Group | Metrics |
|---|---|
| Particulate | pm1p0 · pm2p5 · pm4p0 · pm10p0 |
| Gas | co2 · voc · tvoc |
| Climate | temperature · humidity |
| Index | aqi |
vape is not sent. UniFi reports it, but it is a vendor-specific index with
no published health meaning and no equivalent on other hardware, so it is not in
the catalogue and both clients drop it before sending. Left in,
it would earn a rejection on every batch forever.
Checking it works
Confirm the sender thinks it sent. The dashboard's /api/health shows
sent climbing and lastError: null. The agent logs each flush —
journalctl -u openaqi-agent -f.
Confirm we accepted it. Your account page shows when each station last reported and how many readings were rejected. A rising rejected count with a reason beats silence.
Confirm it is on the map. Readings appear within a minute or two. Location resolves from the station you registered, server-side — the payload carries no coordinates, so nothing you send affects where it lands.
What leaves your network
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, to an area of about 5 km². Not your sensor's name either — the dashboard knows it as "Bedroom"; openaqi never learns that. The readings table has no column for either, which is a property of the schema rather than a promise about our behaviour.