A Home Assistant custom integration that polls PurpleAir PA-II (and compatible) sensors directly on the LAN — no cloud, no API key.
See DESIGN.md for architecture and the decisions behind the implementation.
- Works with the internet down.
- No PurpleAir cloud rate limits, no API key, no third-party data sharing.
- Sub-minute polling is possible (sensor minimum is 10 s; we default to 120 s to match the sensor's natural averaging cadence).
Per configured sensor:
- PM1.0 / PM2.5 / PM10 mass concentration (µg/m³, ATM density) — always a primary entity; channel A and channel B added on dual-laser units. Primary on dual is the simple A/B average for v0.1.
- PM2.5 AQI — one entity per enabled correction, using the 2024-revised US EPA breakpoint table. Default: raw (no concentration correction) and EPA (Barkjohn 2021). AQandU and LRAPA available via the options flow.
- Temperature, humidity, dewpoint, pressure — only when the sensor has a BME280 or BME680. BME680 values preferred when both are present. Note: the temperature reading runs a few degrees high. The BME sits inside the PurpleAir enclosure and picks up heat from the laser counters and the ESP processor; PurpleAir's own guidance for outdoor units is to subtract roughly 8 °F (≈ 4.4 °C) from the reported value.
- VOC resistance (Ω) — only when the sensor has a BME680.
- Particle counts in six size bins, primary only, disabled by default. Enable individually from the device page when you want them.
- Diagnostics — WiFi signal, uptime, free heap, firmware version, last reported timestamp. Free heap and firmware are disabled by default.
- Online binary sensor — reflects the coordinator's last poll status.
- Channel disagreement binary sensor — dual-laser only. Trips when both PurpleAir thresholds are crossed (default ≥5 µg/m³ AND ≥70 %), configurable in options.
Single-laser sensors (some indoor PA-II units) skip the channel-B entities and the disagreement binary sensor automatically.
The quick way — two clicks if your Home Assistant browser session is already authenticated:
The first button opens HACS pointed at this repo (you'll still need to click Download in HACS and then restart Home Assistant). The second button opens the integration's config flow once it's installed.
The manual equivalent:
- HACS → Integrations → ⋮ → Custom repositories.
- Add
https://github.com/jpettitt/purpleair-localas an Integration. - Install "PurpleAir Local" from the list, then restart Home Assistant.
- Settings → Devices & Services → Add Integration → "PurpleAir Local".
- Enter the sensor's IP. Repeat for each sensor.
If a sensor's IP later changes (DHCP), edit it from the integration's Configure screen — the integration verifies the SensorId still matches and updates the host in place. No need to delete and re-add.
Each AQI entity carries two extra attributes that any card supporting templates can read:
category— a stable snake-case label for the current band (good,moderate,unhealthy, etc., or the UK DAQI formlow_1…very_high_10if you've selected that scheme).category_color— the official hex colour for that band.
The colour scheme is per-user, selected in the integration's options flow. Default is US EPA (AirNow); EU EAQI and UK DAQI are available.
mushroom-template-card
is the lightest-weight way to get a coloured icon next to the AQI
number. Pick the entity for whichever correction you want
(_aqi_raw, _aqi_epa, _aqi_aqandu, _aqi_lrapa).
type: custom:mushroom-template-card
entity: sensor.outdoor_0119_aqi_epa
icon: mdi:weather-hazy
icon_color: |
{{ state_attr('sensor.outdoor_0119_aqi_epa', 'category_color') }}
primary: |
{{ states('sensor.outdoor_0119_aqi_epa') }} AQI
secondary: |
{{ state_attr('sensor.outdoor_0119_aqi_epa', 'category')
| replace('_', ' ') | title }}apexcharts-card has a
built-in color_threshold for series so the line itself changes
colour as the AQI moves through the bands:
type: custom:apexcharts-card
header:
show: true
title: Outdoor AQI (24h)
graph_span: 24h
series:
- entity: sensor.outdoor_0119_aqi_epa
name: AQI (EPA)
type: line
stroke_width: 3
color_threshold:
- value: 0
color: "#00e400"
- value: 51
color: "#ffff00"
- value: 101
color: "#ff7e00"
- value: 151
color: "#ff0000"
- value: 201
color: "#8f3f97"
- value: 301
color: "#7e0023"For a single-point sparkline coloured by the current band, drive
the colour from the entity attribute directly (apexcharts evaluates
EVAL: strings as JS against the hass object):
type: custom:apexcharts-card
chart_type: radialBar
series:
- entity: sensor.outdoor_0119_aqi_epa
color: |
EVAL:hass.states['sensor.outdoor_0119_aqi_epa'].attributes.category_colorcustom:button-card lets you push the category colour all the way to the card background for a glanceable indoor/outdoor at-a-glance:
type: custom:button-card
entity: sensor.outdoor_0119_aqi_epa
name: Outdoor AQI
show_state: true
styles:
card:
- background-color: "[[[ return entity.attributes.category_color ]]]"
- color: white
- font-weight: bold
- padding: 16px
state:
- font-size: 2emThe standard HA Entity / Tile cards don't take templates in their
color field today, so for the built-ins you'll either use one of
the custom cards above or wrap a template sensor — see HA's template
sensor docs for
the pattern.
python3 -m venv .venv
.venv/bin/pip install -r requirements_test.txt
make testA docker-compose.yml at the repo root mounts
custom_components/purpleair_local/ read-only into a disposable Home
Assistant container, so edits to the integration are picked up by HA on
the next reload. Requires Docker (Desktop or compatible).
make ha-up # boots HA at http://localhost:8123 (first start ~1 min)
make ha-logs # tail container logs
make ha-restart # restart HA after editing the integration
make ha-down # stop, keep config
make ha-reset # wipe runtime config — back to the onboarding screenFirst boot walks through HA's user-creation flow; after that, add the
integration from Settings → Devices & Services → Add Integration →
"PurpleAir Local". Runtime data (database, secrets, logs) lives under
.dev/ha-config/ and is gitignored.
MIT.