OpenAQ community PM2.5 in Home Assistant
What the outdoor PM2.5 source landscape looks like in Singapore, how the OpenAQ v3 API routes data (and does not), and the Home Assistant integration patterns that survived real use: station-roster polling, entity-id naming, nearest-station projections with freshness cutoffs, and end-to-end verification through Prometheus rather than the HA API.
Everything here was verified on a personal homelab, 2026-10-08: Home Assistant 2025.x on an NixOS Raspberry Pi, Prometheus scraping HA’s /api/prometheus endpoint, and a Grafana 13 instance with a datasource-proxy query path. The OpenAQ v3 API shape is from its public docs; the API key, rate-limit, and endpoint behaviour was confirmed with live curl probes against the free tier.
The indoor half of this setup is covered in AirGradient ONE on ESPHome, fully local, and the NEA’s official regional feed in Singapore weather alerts and live maps with Home Assistant and Grafana. The network this runs on is the one in Home IoT: an ESPHome fleet on a Flint-bridged VLAN.
TL;DR:
- NEA’s data.gov.sg feed is reference-grade but spatially coarse: five regions for the whole island. Good as a sanity-check baseline, not as a neighbourhood reading.
- IQAir’s map looks denser because it mixes government reference stations with crowd low-cost sensors (AirVisual Pro, PurpleAir feeds) and modelled values - and it displays US AQI, not concentration. The data-source label on each marker is buried in a station detail panel.1
- OpenAQ aggregates only openly-licensed feeds. For Singapore that is ~14 currently-active locations within 25 km, mostly AirGradient’s outdoor network plus one Clarity NASA calibration node and HabitatMap AirBeams. It reports physical concentrations (ug/m3), not indices.2
- The v3 API routes sensor data per location, not per parameter.
/v3/locations/{id}/latestreturns all of a location’s sensors keyed only bysensorsId- you must map PM2.5’ssensorsIdfrom the locations response and filter results on it. The roster of active stations decays; probedatetimeLast, filter fresh, re-probe periodically. - HA derives entity_id from
name, notunique_id. “OpenAQ PM2.5 Midwood” becomessensor.openaq_pm2_5_midwood. Any template iterating entity ids must mirror that slugification - keying onunique_idmakes every derived template sensor silentlyunavailable. Verify against/var/lib/hass/.storage/core.entity_registryon the live host. - Verify through the pipeline, not the config. The HA Prometheus exporter + a Grafana datasource-proxy query confirms values end-to-end without an HA API token. Config looks correct and still fails silently in the template layer.
- Low-cost station caveat: OpenAQ does not verify placement - a sensor placed indoors still appears. Sanity-check divergent readings against the NEA regional feed.
Source landscape
Section titled “Source landscape”Three public outdoor PM2.5 sources for a Singapore address, and what each one actually gives you.
| Source | Station count (SG) | Reports | License | Best use |
|---|---|---|---|---|
| NEA (data.gov.sg) | 5 regions | ug/m3, 1 hr, keyless | Open | Sanity-check baseline; the authoritative reference |
| IQAir map | ~30+ markers | US AQI, real-time | Proprietary | Quick visual check; dense spots are often crowd sensors or modelled |
| OpenAQ v3 | ~14 active within 25 km | ug/m3, per-station, per-parameter | Open (varies by feed) | Nearest-neighbour projection; physical concentration for automations |
NEA’s feed covers the island in five coarse regions and is the reference-grade option - but it cannot tell you whether the air at your window matches the regional average.3 IQAir’s map shows far more dots, which makes it look more precise than it is: many markers are AirVisual Pro or PurpleAir low-cost sensors that the platform ingests, and some are modelled values where no physical station exists. The data-source label is shown in the station detail panel, not on the map pin.1 The map also displays US AQI (a piecewise-linear index), not the underlying PM2.5 concentration - converting back to ug/m3 from a rounded AQI value loses precision.
OpenAQ sits between them: it aggregates only feeds whose license permits republication, reports physical concentrations, and exposes a REST API with geospatial queries. The trade-off is that its station roster is thinner than IQAir’s full map and its active count decays as sensors go offline - it is a polling integrator, not a live dashboard.
OpenAQ v3 API shape
Section titled “OpenAQ v3 API shape”The parts that matter for a polling integration. Full endpoint reference at the OpenAQ docs.4
Auth and rate limits
Section titled “Auth and rate limits”API key in the X-API-Key header. Free tier: 60 requests per minute, 2,000 per hour.5 With a 15-minute poll interval across 14 stations, 14 requests every 900 seconds sits comfortably under both limits (under 1 req/min).
Geospatial location query
Section titled “Geospatial location query”GET /v3/locations?parameters_id=2&radius=25000&coordinates=1.3521,103.8198&limit=100
parameters_id=2filters to locations with a PM2.5 sensor.radiusmax is 25,000 m.- Returns per-location metadata:
id,name,coordinates(lat/lon),sensorsarray with each sensor’sidandparametersId, anddatetimeLast(the last measurement time for the location).
The sensors array is the critical extract: a location may have multiple parameters, and you need the id where parametersId === 2 (PM2.5) for the per-location latest query below. A location with no PM2.5 sensor is filtered by parameters_id=2 at the query level, so every returned location has at least one.
Per-location latest
Section titled “Per-location latest”GET /v3/locations/{id}/latest
Returns ALL of a location’s sensors in one call, keyed by sensorsId. There is no parameters_id filter on this endpoint - you get every parameter the location measures and must filter the response to the PM2.5 sensorsId from the locations query.
The response shape:
{ "results": [ { "sensorsId": 12345, "parameter": { "id": 2, "name": "pm2.5", "units": "ug/m3" }, "value": 18.4, "datetime": { "utc": "2026-10-08T06:00:00Z", "local": "..." } }, { "sensorsId": 12346, "parameter": { "id": 7, "name": "pm10", "units": "ug/m3" }, ... } ]}What the API does not do
Section titled “What the API does not do”/v3/parameters/{id}/latesthas no geospatial filter - it is global only, useless for a local projection.- No per-parameter filter on
/v3/locations/{id}/latest. You must mapsensorsIdyourself. - No combined “give me the latest PM2.5 for every station in this radius” endpoint. You iterate locations.
Station roster decay
Section titled “Station roster decay”Stations go offline. A location’s datetimeLast tells you when its last measurement arrived. Filter locations whose datetimeLast is older than your polling interval; a station that has been silent for two poll cycles is effectively gone until it returns. Re-run the locations query periodically (daily is enough) to pick up new stations and drop ones that OpenAQ has delisted.
Home Assistant integration patterns
Section titled “Home Assistant integration patterns”The non-obvious lessons from wiring 14 REST sensors, a roster template, and two nearest-station projections (home + phone).
Per-station REST sensors
Section titled “Per-station REST sensors”One rest resource per station exposes two sensors: a value sensor and a reading-time twin. One HTTP call serves both.
rest: - resource: https://api.openaq.org/v3/locations/3040714/latest scan_interval: 600 headers: X-API-Key: !secret openaq_api_key sensor: - name: OpenAQ PM2.5 Midwood unique_id: openaq_pm25_midwood unit_of_measurement: "µg/m³" device_class: pm25 state_class: measurement value_template: >- {% set r = value_json.results | selectattr('sensorsId', 'eq', 10444583) | list %} {{ r[0].value | round(1) if r | length > 0 else 'unknown' }} - name: OpenAQ PM2.5 Midwood reading time unique_id: openaq_pm25_midwood_time device_class: timestamp value_template: >- {% set r = value_json.results | selectattr('sensorsId', 'eq', 10444583) | list %} {{ r[0].datetime.utc if r | length > 0 else 'unknown' }}The sensorsId value (10444583 above) comes from the locations query - the /v3/locations response lists each location’s sensors with their parameter names, so you pick the sensor whose parameter.name is pm25 and use its id here. The location id (3040714) is the real Midwood station, not a placeholder.
Secrets on NixOS
Section titled “Secrets on NixOS”On NixOS, the home-assistant module unquotes strings with a leading bang, so !secret openaq_api_key works directly in the config attrset. The secrets.yaml itself stays host-local, outside the repo - it is referenced by the HA config directory path, not by a Nix path.
Nearest-station template sensors
Section titled “Nearest-station template sensors”A template sensor that picks the nearest station from the roster, with a freshness cutoff and (for the phone variant) a max-distance cutoff.
template: - sensor: - name: OpenAQ PM2.5 home unique_id: openaq_pm25_home unit_of_measurement: "µg/m³" device_class: pm25 state_class: measurement state: >- {% set lat = state_attr('zone.home', 'latitude') %} {% set lon = state_attr('zone.home', 'longitude') %} {% set stations = { 'sensor.openaq_pm2_5_midwood': [1.3641, 103.7637, 'Midwood'], 'sensor.openaq_pm2_5_shelford': [1.324983, 103.812506, 'Shelford'], ... } %} {% set ns = namespace(best=none, d2=99.0) %} {% if lat is not none and lon is not none %} {% set lat = lat | float %}{% set lon = lon | float %} {% for e, c in stations.items() %} {% set t = states(e ~ '_reading_time') %} {% if states(e) not in ['unknown', 'unavailable'] and t not in ['unknown', 'unavailable'] %} {% set dt = as_datetime(t) %} {% if dt is not none and (now() - dt).total_seconds() < 10800 %} {% set dlat = c[0] - lat %}{% set dlon = c[1] - lon %} {% set d = dlat * dlat + dlon * dlon %} {% if d < ns.d2 %} {% set ns.d2 = d %}{% set ns.best = e %} {% endif %} {% endif %} {% endif %} {% endfor %} {% endif %} {{ states(ns.best) if ns.best else 'unavailable' }} attributes: station: >- {{ stations[ns.best][2] if ns.best else 'unknown' }} distance_km: >- {{ (ns.d2 | sqrt * 111.32) | round(1) if ns.best else 'unknown' }} reading_time: >- {{ states(ns.best ~ '_reading_time') if ns.best else 'unknown' }}The main choices in this template:
- Squared-degree distance is adequate at Singapore’s latitude (cos ~1.0) and for the small distances involved. No haversine needed; the comparison runs on
dlat² + dlon²without a sqrt, which is cheaper and equivalent since every candidate is compared against the same best. - Freshness cutoff (10,800 s = 3 h) discards stations whose last reading is older than two missed hourly updates. Without this, a station that went offline three days ago still contributes its last value.
- Phone variant adds a max-distance candidate filter (
d2 < 0.25, ~55 km equirectangular) so an overseas phone reportsunavailableinstead of picking a home-city station 13,000 km away. - station / distance_km / reading_time attributes name the winning station and expose the computed distance and timestamp for dashboard text and alert bodies.
US EPA AQI conversion in Jinja
Section titled “US EPA AQI conversion in Jinja”OpenAQ reports concentration only. The US EPA AQI is a piecewise-linear function of PM2.5 concentration; it fits in a Jinja template without an external library.
Breakpoint table (ug/m3 to AQI), EPA revised February 2024, in force 2024-05-066:
| Category | AQI | PM2.5 (ug/m3) |
|---|---|---|
| Good | 0-50 | 0.0-9.0 |
| Moderate | 51-100 | 9.1-35.4 |
| USG | 101-150 | 35.5-55.4 |
| Unhealthy | 151-200 | 55.5-125.4 |
| V. Unhealthy | 201-300 | 125.5-225.4 |
| Hazardous | 301-500 | 225.5-325.4 |
Tables written before 2024 (with Good up to 12.0 ug/m3 and Unhealthy up to 150.4 ug/m3) are still common online and are stale. Use the 2024 breakpoints above.
Jinja implementation (loop-over-breakpoints style):
{% set c = states('sensor.openaq_pm2_5_phone') | float(none) %}{% if c is none %}unavailable{% else %} {% set bps = [ [0.0, 9.0, 0, 50], [9.0, 35.4, 51, 100], [35.4, 55.4, 101, 150], [55.4, 125.4, 151, 200], [125.4, 225.4, 201, 300], [225.4, 325.4, 301, 500] ] %} {% set ns = namespace(aqi=500) %} {% for lo, hi, ilo, ihi in bps %} {% if c > lo and c <= hi %} {% set ns.aqi = ((ihi - ilo) / (hi - lo) * (c - lo) + ilo) | round(0) %} {% endif %} {% endfor %} {% if c <= 0 %}{% set ns.aqi = 0 %}{% endif %} {{ ns.aqi }}{% endif %}End-to-end verification
Section titled “End-to-end verification”Do not verify through the HA config. The configuration can parse correctly and still produce unavailable sensors because a template references a non-existent entity_id or a Jinja filter resolves to none silently.
Verify through the pipeline:
-
Prometheus exporter: confirm the entity appears and has a value. The
/api/prometheusendpoint requires a bearer token (Prometheus scrapes it with a long-lived HA token):curl -s -H 'Authorization: Bearer <token>' http://hearth:8123/api/prometheus \| grep 'openaq_pm2_5_home'A missing line means the entity is
unavailableor not registered. -
Grafana datasource-proxy query: confirm the value is recent and plausible. The namespace prefix (
hearth_) and Prometheus-safe unit encoding (u0xb5for µg) come from HA’s prometheus integration config7:/api/datasources/proxy/uid/<datasource-uid>/api/v1/query\?query=hearth_sensor_pm25_u0xb5g_per_mu0xb3{entity="sensor.openaq_pm2_5_home"}The proxy path needs only a Grafana token, not an HA token - the agent authenticates against Grafana and the datasource carries its own HA credentials. Check availability in the same call:
hearth_entity_available{entity="sensor.openaq_pm2_5_home"} -
Sanity-check against NEA: the nearest OpenAQ station should track the NEA regional reading within a plausible offset. A community station reading 150 ug/m3 while NEA reports 20 ug/m3 island-wide means the sensor is indoors, faulty, or placed next to a source.
This verification surfaced the actual integration bug: the raw per-station sensors showed entity_available=1 while the nearest-station projection showed available=0. The projection template was keying its station dict on a different entity-id slug than HA had actually generated.
Refreshing the station roster
Section titled “Refreshing the station roster”A runbook for the curl + jq probe that builds the per-location sensor-ID map. Run when stations appear stale or the active count drops.
1. Fetch the location roster
Section titled “1. Fetch the location roster”curl -s -H "X-API-Key: $OPENAQ_API_KEY" \ "https://api.openaq.org/v3/locations?parameters_id=2&radius=25000&coordinates=1.3521,103.8198&limit=100" \ | jq '[.results[] | select(.datetimeLast.utc > "'$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)'")]'Change the coordinates to your home location. The datetimeLast filter drops stations that have not reported in the last two hours - adjust the window to match your polling cadence.
2. Extract per-location PM2.5 sensor IDs
Section titled “2. Extract per-location PM2.5 sensor IDs”curl -s -H "X-API-Key: $OPENAQ_API_KEY" \ "https://api.openaq.org/v3/locations?parameters_id=2&radius=25000&coordinates=1.3521,103.8198&limit=100" \ | jq '[.results[] | { id, name, lat: .coordinates.latitude, lon: .coordinates.longitude, pm25_sensor: [.sensors[] | select(.parametersId == 2)][0].id, last: .datetimeLast.utc }]'The output maps each location to its PM2.5 sensorsId - feed those into the REST sensor value_template selectattr filter.
3. Spot-check one station
Section titled “3. Spot-check one station”curl -s -H "X-API-Key: $OPENAQ_API_KEY" \ "https://api.openaq.org/v3/locations/<LOCATION_ID>/latest" \ | jq '.results[] | select(.sensorsId == <SENSOR_ID>)'Confirm the sensor ID resolves, the value is in ug/m3, and the timestamp is recent.
Statistics-graph “No statistics found”
Section titled “Statistics-graph “No statistics found””Brand-new entities with state_class: measurement show “No statistics found” in a statistics-graph card until the recorder’s hourly long-term-statistics run has aggregated data. It is not a bug - the entity registered, the value sensor is live, and the history-graph card (which reads from the recorder’s short-term states table) renders right away.
A history-graph card covers the first hours while the statistics table fills in. After the first hourly aggregation, the statistics-graph card renders normally.
What generalises
Section titled “What generalises”- Sensor-ID mapping is a general polling-integrator problem. Any API that returns multi-parameter results per station and lacks a per-parameter filter on the latest-values endpoint will need the same two-step probe: list resources to get IDs, then poll each resource filtering on the ID. The OpenAQ
locationstolocations/{id}/latestpattern is the specific instance. - HA’s name-to-entity_id slugification is deterministic but not obvious. The rule is: lowercase, replace spaces and dots with underscores. Test with one sensor and check
/var/lib/hass/.storage/core.entity_registrybefore wiring a template that iterates the roster. - Prometheus as the verification surface. The HA API needs an auth token and returns nested JSON; the Prometheus exporter needs the same bearer token but returns a flat
metric{label="value"}format. The datasource-proxy path through Grafana needs only a Grafana token - the datasource carries its own HA credentials. If you already run Prometheus, use it for health checks. - Community-station networks decay. OpenAQ’s active count is a moving window. A station that was reliable for months can disappear when its owner unplugs it. The daily roster refresh is not optional.
Related docs
Section titled “Related docs”- AirGradient ONE on ESPHome, fully local - the indoor sensor this setup compares against
- Singapore weather alerts and live maps with Home Assistant and Grafana - the NEA regional PM2.5 feed integrated the same way
- Home IoT: an ESPHome fleet on a Flint-bridged VLAN - the network design this runs on
References
Section titled “References”-
IQAir, “How can I check the data source of a station?,” IQAir Knowledge Base. https://www.iqair.com/support/knowledge-base/how-can-i-check-the-data-source-of-a-station ↩ ↩2
-
OpenAQ, “About OpenAQ,” OpenAQ Docs. https://docs.openaq.org/about/about/ ↩
-
Singapore NEA, “PM2.5,” data.gov.sg. https://api-open.data.gov.sg/v2/real-time/api/pm25 ↩
-
OpenAQ, “Quick Start,” OpenAQ Docs. https://docs.openaq.org/using-the-api/quick-start/ ↩
-
OpenAQ, “Rate Limits,” OpenAQ Docs. https://docs.openaq.org/using-the-api/rate-limits/ ↩
-
US EPA, “AQI Breakpoints,” Air Quality System. https://aqs.epa.gov/aqsweb/documents/codetables/aqi_breakpoints.html ↩
-
Home Assistant, “Prometheus Integration,” Home Assistant Docs. https://www.home-assistant.io/integrations/prometheus/ ↩