{"id":49450781,"url":"https://github.com/ptr727/homeassistant-purpleair","last_synced_at":"2026-05-21T01:10:08.220Z","repository":{"id":352505615,"uuid":"1213847079","full_name":"ptr727/homeassistant-purpleair","owner":"ptr727","description":"PurpleAir integration for Home Assistant","archived":false,"fork":false,"pushed_at":"2026-05-19T06:37:29.000Z","size":582,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-19T08:59:39.463Z","etag":null,"topics":["hacs","home-assistant","purpleair"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ptr727.png","metadata":{"files":{"readme":"README.md","changelog":"HISTORY.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-04-17T20:32:33.000Z","updated_at":"2026-05-08T19:39:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/ptr727/homeassistant-purpleair","commit_stats":null,"previous_names":["ptr727/homeassistant-purpleair"],"tags_count":58,"template":false,"template_full_name":"ludeeus/integration_blueprint","purl":"pkg:github/ptr727/homeassistant-purpleair","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ptr727%2Fhomeassistant-purpleair","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ptr727%2Fhomeassistant-purpleair/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ptr727%2Fhomeassistant-purpleair/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ptr727%2Fhomeassistant-purpleair/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ptr727","download_url":"https://codeload.github.com/ptr727/homeassistant-purpleair/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ptr727%2Fhomeassistant-purpleair/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33283632,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-20T15:12:43.734Z","status":"ssl_error","status_checked_at":"2026-05-20T15:12:42.300Z","response_time":356,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hacs","home-assistant","purpleair"],"created_at":"2026-04-30T02:05:18.938Z","updated_at":"2026-05-21T01:10:08.213Z","avatar_url":"https://github.com/ptr727.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# PurpleAir Integration for Home Assistant\n\nA Home Assistant [custom integration][ha-custom-integration-link] for [PurpleAir][purpleair-link] air-quality sensors.\n\n\u003e **Not the built-in PurpleAir integration.** This custom integration shares the `purpleair` domain with the core built-in one. When loaded, Home Assistant's loader picks the custom version over the built-in and migrates existing config entries forward — the upgrade is automatic and preserves entity IDs and history. **The downgrade is not:** if you later remove this custom integration, the built-in cannot read the migrated v2 entries. See [Migration][migration-link] below for details. In the **Add Integration** picker this appears as **\"PurpleAir (custom)\"** to distinguish it from the built-in **\"PurpleAir\"**.\n\n## Build and Distribution\n\n### Build Status\n\n[![Build Status][buildstatus-shield]][actions-link]\\\n[![Last Build][lastbuild-shield]][actions-link]\\\n[![Last Commit][lastcommit-shield]][commits-link]\\\n[![Coverage][coverage-shield]][coverage-link]\n\n### Releases\n\n[![Release Version][releaseversion-shield]][releases-link]\\\n[![Pre-Release Version][prereleaseversion-shield]][releases-link]\\\n[![HACS Custom][hacs-shield]][hacs-link]\\\n[![Quality Scale][qualityscale-shield]][qualityscale-link]\\\n[![Home Assistant][haversion-shield]][haversion-link]\\\n[![License][license-shield]][license-link]\n\n### Release Notes\n\nRelease highlights — see [Release History](./HISTORY.md) for details.\n\n**Version 0.1**:\n\n- Private sensor support via per-sensor read keys (free API points when querying your own sensors).\n- Subentry layout — one subentry per sensor; automatic v1 → v2 migration from the built-in integration preserving entity IDs, devices, and long-term-statistics history.\n- Cost-aware field selection — only fields backing enabled entities are requested, and static device-info fields are fetched once per day.\n- Quality-aware availability — entities go unavailable on `channel_state == 0` (\"No PM\"), a stale `last_seen`, or `confidence \u003c 50` when both PM channels are reporting (single-channel sensors aren't gated on confidence because there's no second channel to cross-check).\n- Account-level **Remaining points** and **Consumption rate** diagnostic sensors (both enabled by default), backed by a daily refresh of `GET /v1/organization`. A persistent repair issue fires when the balance drops below seven days of consumption or the API rejects requests with `PaymentRequiredError`.\n- Sensor selection from a map — pick nearby public sensors from a radius-filtered map picker.\n- Disabled-by-default derived entities: PM2.5 EPA mass concentration (US EPA piecewise humidity correction) and PM2.5 air quality index (US EPA AQI from the 24-hour average, 2024 NAAQS breakpoints).\n- Enabled-by-default diagnostic entities: Confidence, Channel state, Last seen — these surface the values the availability gate uses, so a sensor marked Unavailable can be diagnosed at a glance from its device card. They cost zero extra API points (already fetched on every refresh).\n- Disabled-by-default diagnostic entities: Channel flags, PM2.5 ALT, PM2.5 10-minute/30-minute/60-minute/6-hour/24-hour/1-week averages.\n- Platinum-tier quality-scale compliance.\n\nSee [GitHub Releases][releases-link] for per-release changes.\\\nSee [Release History](./HISTORY.md) for historic changes.\n\n## Features\n\n**Features beyond Home Assistant's built-in PurpleAir integration**:\n\n- **Private sensor support.** Each subentry can supply its own per-sensor **Read Key**, so the integration can query unlisted private sensors and query self-owned sensors at no API-point cost.\n- **Config subentries.** One subentry per sensor (the current HA model) instead of a single config entry holding a list of sensor indices.\n- **Sensor selection from a map.** Pick nearby public sensors from a radius-filtered map picker.\n- **Cost-aware field selection.** Only fields for *enabled* entities are requested, and static device-info fields are fetched once per day instead of every refresh — see [API points and field selection](#api-points-and-field-selection).\n- **Quality-aware availability.** Entities are marked unavailable when the sensor reports no PM data (`channel_state == 0`, \"No PM\"), when it has stopped reporting (`last_seen` older than 10 min), or when both Plantower channels are reporting and disagree too much (`confidence \u003c 50`). Single-channel sensors (PA-I or one channel downgraded) aren't gated on confidence because there's no second channel to cross-check against — on those sensors the displayed Confidence value reflects internal sensor-health checks rather than channel agreement, so values typically sit between 20 and 40 by design and that's not a defect. Confidence, channel state, and last-seen diagnostic entities are all enabled by default so the reason a sensor went unavailable is visible at a glance from the device card.\n- **Hardware-aware entities.** The Volatile organic compounds (IAQ) entity is only created for devices whose `hardware` string indicates a BME680/688 gas sensor (PA-II-ZEN and newer). PA-I and original PA-II boards ship a BME280 with no gas-sensing capability, so the integration skips the entity entirely on those boards rather than registering one that would always sit at `unknown` (the API returns `voc: null`, which HA renders as Unknown for measurement entities). Existing installs that already have the entity registered keep it (the gate is bypassed for entities already present in the entity registry).\n- **Remaining-points diagnostics.** Account-level **Remaining points** and **Consumption rate** sensors (both enabled by default) plus a persistent repair issue when fewer than seven days of points remain or the API rejects requests with `PaymentRequiredError`.\n- **Platinum-tier quality scale.** Full [HA quality-scale][qualityscale-rules-link] platinum tier: `parallel-updates`, `entity-unavailable`, `log-when-unavailable`, `repair-issues`, `reconfiguration-flow`, entity translations, exception translations, ≥ 95 % test coverage, and more — see [`quality_scale.yaml`](custom_components/purpleair/quality_scale.yaml).\n- **Automatic v1 → v2 migration.** Existing config entries from the built-in integration are converted to the subentry layout on first load; entity IDs, devices, and history are preserved.\n\n**Why private sensor support matters**:\n\n**PurpleAir uses a points for data access model**, see [PurpleAir Community: API Pricing][purpleair-api-pricing-link] for details. New accounts start with enough points to run for about a month using this integration, before more points may need to be purchased.\n\n**Sensor owners can access data for their own sensors free of charge**, see [PurpleAir community: API points for sensor owners][free-points-link]. To run this integration long-term at no cost for your own sensors, use the **Read Key** that was provided via email during sensor registration.\n\n## Installation\n\n### Via HACS (Recommended)\n\n1. In HACS, open **Integrations → ⋮ → Custom repositories**.\n1. Add `https://github.com/ptr727/homeassistant-purpleair` with category **Integration**.\n1. Install **PurpleAir** from the HACS list and restart Home Assistant.\n\n### Manual\n\nCopy `custom_components/purpleair/` into your Home Assistant `\u003cconfig\u003e/custom_components/` directory and restart Home Assistant.\n\n## Configuration\n\n### 1. Get a PurpleAir API Key\n\n- Create a free account at the [PurpleAir Developer Portal][purpleair-developer-link].\n- On the [API Keys page][purpleair-keys-link] create an API key.\n- On the [Projects page][purpleair-projects-link] buy points as required (not required for using your own sensors).\n- Return to the keys page and copy the API key (it looks like a GUID).\n\n### 2. Add the Integration in Home Assistant\n\n**Settings → Devices \u0026 Services → Add Integration → PurpleAir** and paste your API key.\n\n### 3. Add Sensors\n\nEach sensor is added as a **subentry** under the integration. Two methods:\n\n- **Map search.** Pick from public sensors near a latitude/longitude/radius.\n- **Manual entry.** Enter the sensor **Index** plus optional **Read Key**.\n  - The Read Key is **required for private sensors** that are not shown on the public sensor map.\n  - The Read Key is **required for no cost API usage** of your own sensors (the Read Key is sent via email during sensor registration). Refer to [PurpleAir community: API points for sensor owners][free-points-link].\n\n### Account-Level Diagnostics\n\nIn addition to the per-sensor subentries, the integration registers a single per-config-entry **organization** device (named `\u003centry-title\u003e organization` — e.g. \"PurpleAir organization\" for the default integration title) that surfaces account-level information shared across all sensors under the same API key. It backs the **Remaining points** and **Consumption rate** diagnostic sensors (both enabled by default), plus the points-related repair issues. In **Settings → Devices \u0026 Services → PurpleAir** this device appears under HA's \"Devices that don't belong to a sub-entry\" heading. That label reads as a defect but is intentional: the organization endpoint is account-scoped (per API key), not per-sensor, so the device deliberately has no subentry parent. The device also disambiguates account-level entities when multiple PurpleAir API keys are configured — without it, entity IDs and friendly names would collide across accounts.\n\n## Sensor Behavior and Calibration\n\nThese notes explain why entities report the values they do. The integration takes two different approaches depending on how settled the underlying math is:\n\n- **Widely-adopted, well-specified corrections are implemented in code** as disabled-by-default opt-in entities — specifically the US EPA PM2.5 humidity correction and the US EPA PM2.5 AQI. The formulas are cited below with their source documents.\n- **Local, deployment-specific calibrations** (ambient temperature/humidity offsets, per-channel corrections, alternative AQI schemas) remain user-territory — the integration exposes the raw fields and the README shows template-sensor examples for the common cases.\n\nAll field semantics below are verified against the [official API documentation][purpleair-api-link].\n\n### PM2.5 Mass Concentration\n\nThe `PM2.5 mass concentration` sensor returns the API's `pm2.5` field. On the real-time endpoint this field is **already**:\n\n- indoor-vs-outdoor aware — it uses the CF=1 variant on sensors registered as indoor and the ATM variant on outdoor sensors;\n- downgrade-aware — if one of the two Plantower channels is flagged as degraded, its reading is excluded from the average automatically.\n\nSee the [API docs § `pm2.5`][purpleair-api-pm25-link] for the full spec. You do not need to pick between ATM and CF=1 manually.\n\nFor the Wallace **ALT-CF3** variant (often preferred for wildfire smoke and low-concentration outdoor monitoring) enable the disabled-by-default **PM2.5 ALT mass concentration** sensor. See [the API docs § `pm2.5_alt`][purpleair-api-pm25-link] for the formula.\n\nFor US EPA-corrected PM2.5, enable the opt-in **PM2.5 EPA mass concentration** entity — see [EPA-corrected PM2.5](#epa-corrected-pm25-pm25-epa-mass-concentration) below for the formula and source.\n\n### Rolling Averages\n\nThe disabled-by-default **PM2.5 10/30/60-minute**, **6/24-hour**, and **1-week average** sensors expose the API's running-average fields. These are the preferred input for AQI-style reporting (e.g. the US EPA AQI is defined against a 24-hour average). Same indoor/outdoor auto-selection applies.\n\n### Internal Temperature and Humidity\n\nThe `temperature` and `humidity` entities expose readings from **inside the sensor housing**, not ambient conditions. Per the API docs:\n\n\u003e *This matches the \"Operating Temperature\" map layer and is not representative of ambient conditions. Formulas can be applied to estimate ambient temperature.*\n\nIn practice, a PA-II reads roughly **8 °F hotter** and **4 %RH drier** than the ambient air around it. No correction is applied to the entity values — they are the raw sensor readings.\n\nIf you need an ambient estimate, use a template sensor. Example:\n\n```yaml\ntemplate:\n  - sensor:\n      - name: \"Backyard ambient temperature\"\n        device_class: temperature\n        unit_of_measurement: \"°F\"\n        # Rule of thumb: PA-II reads ~8 °F hotter than ambient.\n        # See the PurpleAir community for more precise formulas.\n        state: \u003e-\n          {% set t = states('sensor.backyard_temperature') | float(none) %}\n          {{ (t - 8) if t is number else none }}\n      - name: \"Backyard ambient humidity\"\n        device_class: humidity\n        unit_of_measurement: \"%\"\n        # Rule of thumb: PA-II reads ~4 %RH below ambient; cap at 100.\n        state: \u003e-\n          {% set h = states('sensor.backyard_humidity') | float(none) %}\n          {{ [h + 4, 100] | min if h is number else none }}\n```\n\n### EPA-Corrected PM2.5 (`PM2.5 EPA Mass Concentration`)\n\nA disabled-by-default sensor that applies the US EPA's published correction to the raw PurpleAir PM2.5 output. Reference: **\"Fire and Smoke Map Sensor Data Processing\"**, EPA Office of Research and Development, revised 2021, page 26 of [`dirEntryId=353088`][epa-pm25-link].\n\nImplementation details:\n\n- Inputs: the PurpleAir `pm2.5` field (ATM variant auto-selected for outdoor sensors) and raw `humidity`. Both are requested automatically when this sensor is enabled — you do not need to also enable the baseline PM2.5 and humidity entities.\n- Uses a piecewise formula with five regions (PM \u003c 30, 30 ≤ PM \u003c 50, 50 ≤ PM \\\u003c 210, 210 ≤ PM \u003c 260, PM ≥ 260) with linear blending across the two transition regions so the output is continuous at every breakpoint.\n- Uses the sensor's **internal** housing humidity as input, matching how the EPA regression was fit — no ambient correction is applied to humidity here.\n- Calibrated for outdoor sensors; enabling it on an indoor sensor is not meaningful.\n\nThe code lives in `_pm25_epa_correction` in [`sensor.py`](custom_components/purpleair/sensor.py). The implementation has unit tests that verify each region's formula and the continuity of every boundary.\n\n### US AQI from 24-Hour PM2.5 (`PM2.5 Air Quality Index`)\n\nA disabled-by-default sensor that reports the US EPA Air Quality Index for PM2.5 based on the sensor's 24-hour rolling average.\n\n- Input: the PurpleAir `pm2.5_24hour` field (auto-selected for indoor/outdoor and excluding downgraded channels).\n- Uses the breakpoint table from [AirNow — Air Quality Index (AQI) Basics][airnow-aqi-link], updated to the **2024 NAAQS revision** (Good/Moderate threshold lowered from 12.0 → 9.0 µg/m³, higher bands tightened).\n- Concentrations are truncated to 0.1 µg/m³ before lookup (40 CFR § 58 App. G), AQI within each band is linearly interpolated, and values above 500.4 µg/m³ cap at AQI 500.\n\nThe breakpoint table and lookup live in `_pm25_aqi` in [`sensor.py`](custom_components/purpleair/sensor.py); unit tests cover every band edge.\n\n### Availability Signals\n\nEntities become **unavailable** when any of:\n\n- the sensor's `confidence` score is below 50 % (the two PMS channels disagree too much to trust the average);\n- `channel_state` reports **No PM** (no PM sensor detected at all);\n- `last_seen` is more than 10 minutes behind the coordinator's `data_timestamp_utc` (the sensor has stopped reporting).\n\nEach transition is logged once at `INFO` under the `custom_components.purpleair` logger.\n\n### API Points and Field Selection\n\nPurpleAir charges API points per **field** per sensor per call. The integration takes two steps to minimize that cost:\n\n**1. Only fetch fields for enabled entities.** Each [`PurpleAirSensorEntityDescription`](custom_components/purpleair/sensor.py) declares its required API fields; at refresh time the coordinator walks the entity registry for the config entry and unions the `api_fields` of every enabled description. Disabled entities contribute zero API fields to the outgoing request. Enabling or disabling an entity in the UI triggers an immediate refresh so the field set reflects reality on the next cycle.\n\n**2. Static fields are cached for 24 hours.** The API's field catalog mixes values that change every reading (PM2.5, humidity, `confidence`, `last_seen`) with values that only change on firmware updates or user actions (`name`, `hardware`, `model`, `firmware_version`, `latitude`, `longitude`). The coordinator splits them into two sets:\n\n| Set | Fields | Fetch cadence |\n| --- | --- | --- |\n| `STATIC_DEVICE_FIELDS` | `name`, `hardware`, `model`, `firmware_version`, `latitude`, `longitude` | Once at setup, then every 24 h |\n| `AVAILABILITY_FIELDS` | `last_seen`, `confidence`, `channel_state`, `channel_flags` | Every refresh (5 min) |\n| Per-entity fields | e.g. `temperature`, `humidity`, `pm2.5`, `pm2.5_24hour` | Every refresh, only for enabled entities |\n\nReloading the config entry (**Settings → Devices \u0026 Services → PurpleAir → ⋮ → Reload**) forces an immediate static re-fetch — useful after a firmware update or sensor relocation.\n\n**Measured cost** for a default install of **one sensor with the six enabled-by-default entities** (temperature, humidity, pressure, PM1.0/PM2.5/PM10 mass concentrations). Both rows query the same 16 fields (4 availability + 6 default-enabled entity fields + 6 static device fields); the difference is whether the static fields ride along on every refresh or only once per day:\n\n| Scenario | Fields per refresh | Refreshes per day | Field-fetches per day |\n| --- | --- | --- | --- |\n| Same fields, refetched every cycle (naive) | 16 | 288 | **4,608** |\n| This integration (static fields cached 24 h) | 10 + 6 once daily | 288 + 1 | **2,886** (≈ 37 % less) |\n\nThe savings here come from the static-cache split alone. A second saving comes from **not** fetching fields for disabled entities: the integration declares 32 unique fields across all entities, but a default install only fetches 16 of them. Enabling every optional entity (PM particle counts, RSSI, uptime, ALT, six rolling averages, diagnostics) raises the per-refresh set to 26 fields; disabling a sensor you aren't using immediately drops its fields out of the next refresh.\n\nFree points are available for sensor owners who use their own sensor's Read Key; see [API points for sensor owners][free-points-link].\n\nThe integration tracks remaining points and consumption rate via the [Account-Level Diagnostics](#account-level-diagnostics) and raises a **PurpleAir API points are running low** repair issue when fewer than seven days of points remain at the current consumption rate. New small accounts can hit the threshold soon after install while the consumption rate stabilizes; that's expected. Two ways to clear the warning:\n\n- **Buy more points** at the [PurpleAir Developer dashboard][purpleair-projects-link].\n- **Use a per-sensor Read Key** for sensors you own. Queries to your own sensors with their Read Key cost zero points. For new sensors, enter the Read Key when adding (see [3. Add Sensors](#3-add-sensors)). For sensors migrated from the built-in integration that don't yet have a Read Key, see [Switch an Existing Sensor to a Read Key](#switch-an-existing-sensor-to-a-read-key).\n\nA separate **PurpleAir API points are exhausted** repair issue fires (severity error) if the account runs out of points entirely; it clears automatically on the next successful refresh after points are restored.\n\n## Upstream PRs\n\n### Upstream `aiopurpleair` PR\n\nThis integration depends on the `aiopurpleair` library. The latest canonical release (`aiopurpleair==2025.08.1`) covers only the sensors endpoints and maps three error codes to exceptions, which means several of the [API's documented error codes][purpleair-api-link] collapse to a generic `PurpleAirError`, and there is no `GET /v1/organization` endpoint for tracking remaining API points.\n\nThe integration's typed error handling, organization coordinator, and low-points repair issue all depend on additions that aren't in the canonical library yet. While upstream review is pending, [`manifest.json`](custom_components/purpleair/manifest.json) pins to a temporary fork distribution published to PyPI as `aiopurpleair-ptr727==2026.8.0` (built from the [organization-endpoint-and-error-codes fork branch][aiopurpleair-fork-link]). The fork adds:\n\n- 19 new exception subclasses (one per documented API error code), wired into `ERROR_CODE_MAP` so callers can `except InvalidDataReadKeyError`, `except PaymentRequiredError`, etc. instead of pattern-matching on `str(err)`.\n- A `GET /v1/organization` endpoint exposed on `API` as `api.organizations`, with a `GetOrganizationResponse` Pydantic model carrying `remaining_points`, `consumption_rate`, `organization_id`, `organization_name`, `api_version`, and `timestamp_utc`.\n- 100 % test coverage for both additions, no breaking changes to the public API.\n\nThe fork is shipped under a distinct PyPI name (`aiopurpleair-ptr727`) so it doesn't collide with the canonical `aiopurpleair` distribution; `packages = [{ include = \"aiopurpleair\" }]` in the fork's `pyproject.toml` keeps the import path unchanged, so `import aiopurpleair` continues to resolve. Hassfest rejects PEP 508 git-URL requirements (\"contains a space\"), which is why a published artifact is needed rather than a `git+...@SHA` pin.\n\nA pull request against [bachya/aiopurpleair][bachya-aiopurpleair-link] is open. Once the maintainer merges and cuts a new canonical PyPI release, the pin in [`manifest.json`](custom_components/purpleair/manifest.json) and [`requirements-test.txt`](requirements-test.txt) flips back to `aiopurpleair==X.Y.Z`, the `aiopurpleair-ptr727` distribution gets yanked from PyPI, and this section can be removed.\n\nAll error codes and semantics in the fork are verified against the [official PurpleAir API documentation][purpleair-api-link].\n\n### Upstream Home Assistant PR\n\nAn earlier version of this integration was submitted for inclusion in Home Assistant core as [home-assistant/core#140901][ha-core-pr-link] (with accompanying docs at [home-assistant/home-assistant.io#38063][ha-docs-pr-link]). That PR has been pending review for some time.\n\nIn the meantime, this version has continued to move forward — it now **supersedes** the PR in functionality.\n\nThe original core PR will not be kept in lockstep with these changes, and may be abandoned. The HACS release stream may be the maintained path going forward.\n\n## Migration from the Built-in Integration\n\n### Upgrade: Built-in → Custom\n\n1. Install this custom integration via [HACS][hacs-xyz-link] or by copying `custom_components/purpleair/` into your Home Assistant config directory.\n1. Restart Home Assistant. The installation has no effect until HA restarts — integrations are loaded once at startup.\n1. On startup, HA's loader prefers the custom integration over the built-in one (they share the `purpleair` domain). Your existing PurpleAir config entry stays in place in `.storage/core.config_entries` and is migrated to the subentry layout. Entity IDs, devices, and long-term statistics are preserved. **You do not need to remove the built-in integration first — it is part of core, not a separate installation.**\n1. You will see this warning in the log:\n\n    ```text\n    We found a custom integration purpleair which has not been tested by Home Assistant\n    ```\n\n    HA emits it for every custom integration and it is not a problem.\n\nIf migration fails, the entry is marked `SETUP_ERROR`. Check **Settings → System → Repairs** and the log; empty v1 entries raise a targeted repair issue.\n\n### Switch an Existing Sensor to a Read Key\n\nThe built-in integration didn't support per-sensor Read Keys, so subentries migrated from it have only the sensor **Index** populated. If you own a sensor, switching it to use a per-sensor Read Key makes its API queries free — see [PurpleAir community: API points for sensor owners][free-points-link]. This is the recommended remediation when the [low-points repair issue](#api-points-and-field-selection) fires on a small account.\n\nIn **Settings → Devices \u0026 Services → PurpleAir**, click ⋮ next to the sensor → **Configure**, then enter the sensor's Read Key. The integration validates the key against PurpleAir before saving and reloads on success — long-term-statistics history, entity IDs, and devices are preserved (only the sensor's API authentication changes). The same flow can clear an existing Read Key by leaving the field blank, or replace one that's been rotated.\n\nThe Read Key can also be added at sensor-add time for new sensors — see [3. Add Sensors](#3-add-sensors).\n\n### Downgrade: Custom → Built-in\n\n**Downgrading requires manual work.** This custom integration uses config-entry schema **version 2** (one subentry per sensor). The built-in integration in Home Assistant core is still on schema **version 1**. If you simply delete `custom_components/purpleair/` and restart, the built-in cannot read v2 entries and the integration will fail to set up with `Config entry for purpleair is from a future version`.\n\nTwo recovery options:\n\n- **Wait for the built-in integration to support schema v2.** Once the built-in adopts the same subentry layout, it can read existing v2 entries and the downgrade works automatically.\n- **Rebuild the entry manually.** In Home Assistant go to **Settings → Devices \u0026 Services → PurpleAir → … → Delete**, then remove `custom_components/purpleair/`, restart, and re-add the built-in integration from scratch. Long-term-statistics history tied to the migrated entity IDs is lost.\n\nThere is no in-place downgrade until the built-in integration adopts schema v2. Plan accordingly before installing.\n\n## Credits\n\n- **API library:** [aiopurpleair][aiopurpleair-pypi-link], authored by [@bachya][bachya-link].\n- **License:** Apache 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).\n- **Credits:** Original PurpleAir integration author [@bachya][bachya-link]; subentry redesign reviewed and supported by [@joostlek][joostlek-link].\n\n## Issues and Discussions\n\nBug reports and feature requests are welcome on the [issue tracker][issues-link]. For questions, troubleshooting help, or ideas you're not sure are bugs, use [GitHub Discussions][discussions-link].\n\n## Development\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow (branching, lint/test commands, PR guidelines) and [AGENTS.md](AGENTS.md) for the deeper contributor/agent reference (release flow, code style, devcontainer notes).\n\nThe repo includes a VS Code devcontainer and helper scripts:\n\n```sh\nscripts/setup     # install dev requirements\nscripts/develop   # boot Home Assistant against ./config with this integration loaded\nscripts/fix       # apply ruff auto-fixes (format + check --fix)\nscripts/lint      # verify-only: ruff format --check + ruff check + mypy --strict (mirrors CI)\npytest            # run the test suite (after pip install -r requirements-test.txt)\n```\n\n`scripts/lint` is the CI gate — it fails non-zero on any ruff, format, or `mypy --strict` violation so \"green locally\" matches \"green on GitHub\". When it fails on an auto-fixable issue, run `scripts/fix` and re-run lint.\n\nIf you also run tests in the `aiopurpleair/` workspace folder, prefer a separate virtual environment for that repo. This integration targets Python 3.14, while aiopurpleair's Poetry lock may pin older C-extension builds that do not compile on 3.14 in all branches.\n\nRecommended approach:\n\n```sh\ncd aiopurpleair\npython3 -m venv .venv\n. .venv/bin/activate\n./script/setup\n```\n\n`scripts/setup` for this integration intentionally does not auto-run `aiopurpleair/script/setup`; it only ensures the minimal missing test dependency (`aresponses`) is installed in the current environment.\n\nIf you explicitly want to run the library's own setup from the integration bootstrap, opt in with:\n\n```sh\nRUN_AIOPURPLEAIR_SETUP=1 scripts/setup\n```\n\nEach script is also wired up as a VS Code task in [.vscode/tasks.json](.vscode/tasks.json) — open **Command Palette → Tasks: Run Task**, or use the shortcuts below:\n\n| Script | VS Code task | Shortcut |\n| --- | --- | --- |\n| `scripts/setup` | **Setup: Install dev requirements** | Tasks: Run Task |\n| `scripts/develop` | **Develop: Run Home Assistant** | Tasks: Run Task |\n| `scripts/fix` | **Fix: ruff format + check --fix** | Tasks: Run Task |\n| `scripts/lint` | **Lint: ruff + mypy (verify)** | `Ctrl+Shift+B` (default build task) |\n| `pytest` | **Test: pytest** | Tasks: Run Test Task (default test) |\n\nAdditional useful tasks in the same file:\n\n- **Test: pytest + branch coverage** — run CI-style branch coverage locally.\n- **Setup: aiopurpleair venv** — create `aiopurpleair/.venv` and upgrade `pip`.\n- **Setup: aiopurpleair deps (poetry)** — run `aiopurpleair/script/setup` inside that venv.\n- **Test: aiopurpleair pytest (venv)** — run aiopurpleair tests inside that venv.\n\n### Devcontainer Setup\n\nThe [`.devcontainer.json`](.devcontainer.json) bind-mounts host paths into the container so existing host credentials (the public half of your SSH signing key, plus GitHub CLI auth where the token is file-backed) work inside it without re-setup. The `gh` part is conditional. `gh auth login` always writes the per-host config (username, git protocol, etc.) to `~/.config/gh/hosts.yml`, but it stores the **token** in a credential store by default when one is available — Keychain on macOS, libsecret/Secret Service on Linux desktops — and only writes the token to the file when no store is found or you passed `--insecure-storage`. So on credential-store hosts, `~/.config/gh/hosts.yml` exists but has no `oauth_token` line; the bind-mount therefore carries no token, and container `gh` is unauthenticated until you opt into one of the trade-offs documented below.\n\n| Host path | Mounted at | Purpose |\n| --- | --- | --- |\n| `~/.ssh/id_ed25519.pub` | `/home/vscode/.ssh/id_ed25519.pub` (read-only) | Public half of your SSH commit-signing key |\n| `~/.config/git/allowed_signers` | `/home/vscode/.config/git/allowed_signers` (read-only) | Git config allowed signers |\n| `~/.config/gh` | `/home/vscode/.config/gh` | GitHub CLI config and auth tokens — bind-mounted read-write so `gh auth login` / token refresh inside the container persists back to the host |\n\n**All three paths must exist on the host before you reopen the folder in the devcontainer, otherwise the container build will fail with a bind-mount error.**\n\nIf you do not sign commits or use `gh` and don't want to set this up, delete the `\"mounts\"` block from [`.devcontainer.json`](.devcontainer.json) locally before opening, or simply don't use the devcontainer.\n\n`.devcontainer.json` also runs an `onCreateCommand` that fixes `~/.ssh` ownership inside the container (Docker creates the bind-mount parent dir as `root:root 755`, which prevents writing `known_hosts`). `onCreateCommand` only runs at container *creation*, so contributors with an already-built container who pull a branch that introduces or changes that command must rebuild the container (VS Code typically prompts) or run the equivalent `chown`/`chmod` manually. Fresh-checkout contributors are unaffected.\n\nThe host-side setup below covers Linux, WSL, and macOS as a single common set of instructions plus a small per-OS deltas section. WSL hosts have a one-time prerequisite for Docker Desktop integration; enabling systemd is recommended but not strictly required — the Linux/WSL Deltas section documents a `~/.bashrc` fallback for shells where `systemctl --user` isn't available.\n\n#### WSL Host Prep\n\nApply this configuration if you are running Linux distros from WSL on Windows.\n\nEnable `Use the WSL 2 based engine` in Docker Desktop under Settings / General.\\\nEnable `Enable integration with my default WSL distro` and `Enable integration with additional distros` in Docker Desktop under Settings / Resources / WSL integration.\n\nRecommended (but not strictly required — see the Linux/WSL Deltas `~/.bashrc` fallback below if you prefer not to enable systemd): edit `/etc/wsl.conf` and enable `systemd`, run from a WSL distro terminal:\n\n```ini\n[boot]\nsystemd = true\n```\n\nThen restart WSL from a Windows PowerShell terminal:\n\n```shell\nwsl --shutdown\n```\n\n#### Host Setup\n\nRun on the host that will run the devcontainer.\n\n```shell\n# Configure git identity\ngit config --global user.name \"[Your Name]\"\ngit config --global user.email \"[Your Email]\"\n\n# Create ~/.ssh/config file\nmkdir -p ~/.ssh \u0026\u0026 chmod 700 ~/.ssh\ntouch ~/.ssh/config \u0026\u0026 chmod 600 ~/.ssh/config\n\n# Generate a SSH signing key pair\nssh-keygen -t ed25519 -f ~/.ssh/id_ed25519\n\n# Create allowed_signers file\nSIGNER_LINE=\"$(git config --get user.email) namespaces=\\\"git\\\" $(cat ~/.ssh/id_ed25519.pub)\"\nmkdir -p ~/.config/git\ntouch ~/.config/git/allowed_signers\ngrep -qxF \"$SIGNER_LINE\" ~/.config/git/allowed_signers || echo \"$SIGNER_LINE\" \u003e\u003e ~/.config/git/allowed_signers\n\n# Use SSH for git signing\ngit config --global gpg.format ssh\ngit config --global user.signingkey '~/.ssh/id_ed25519.pub'\ngit config --global gpg.ssh.allowedSignersFile '~/.config/git/allowed_signers'\ngit config --global commit.gpgsign true\n\n# Login to GitHub\ngh auth login\n\n# Register SSH key with GitHub\ngh auth refresh -h github.com -s admin:public_key,admin:ssh_signing_key\ngh ssh-key add ~/.ssh/id_ed25519.pub --title \"$(hostname) auth\"\ngh ssh-key add ~/.ssh/id_ed25519.pub --title \"$(hostname) signing\" --type signing\n```\n\nEdit `~/.ssh/config` so the agent caches the key on first use:\n\n```ini\nHost *\n    AddKeysToAgent yes\n    IdentityFile ~/.ssh/id_ed25519\n```\n\n##### Linux/WSL Deltas\n\nEnable the user-level `ssh-agent` service so it's available across shells:\n\n```shell\nsystemctl --user enable --now ssh-agent.socket\n```\n\nIf `systemctl --user` isn't available in your shell (some minimal WSL distros), add this fallback to `~/.bashrc`:\n\n```shell\n# Reuse a shared ssh-agent if systemd's isn't available in this shell\nSSH_AGENT_ENV=\"$HOME/.ssh/agent.env\"\nif [ -z \"$SSH_AUTH_SOCK\" ]; then\n    if [ -r \"$SSH_AGENT_ENV\" ]; then\n        . \"$SSH_AGENT_ENV\" \u003e/dev/null\n    fi\n    if ! ssh-add -l \u003e/dev/null 2\u003e\u00261; then\n        ssh-agent -s \u003e \"$SSH_AGENT_ENV\"\n        chmod 600 \"$SSH_AGENT_ENV\"\n        . \"$SSH_AGENT_ENV\" \u003e/dev/null\n        ssh-add ~/.ssh/id_ed25519 \u003e/dev/null 2\u003e\u00261\n    fi\nfi\n```\n\n##### macOS Deltas\n\nmacOS uses launchd (not systemd) for `ssh-agent` and integrates SSH with the system Keychain. The `systemctl` line and the `~/.bashrc` agent fallback do not apply, and the SSH steps differ.\n\nUse this `~/.ssh/config` instead of the common one — `UseKeychain yes` caches the passphrase in Keychain:\n\n```ini\nHost *\n    AddKeysToAgent yes\n    UseKeychain yes\n    IdentityFile ~/.ssh/id_ed25519\n```\n\nLoad the key into the agent once so the passphrase persists across reboots:\n\n```shell\nssh-add --apple-use-keychain ~/.ssh/id_ed25519\n```\n\n##### `gh` Credential-Store Hosts\n\n`gh auth login` uses a credential store by default when one is available — Keychain on macOS, libsecret/Secret Service on Linux desktops with the relevant daemon running. `--insecure-storage` is the opt-out that forces file storage. When the credential store is used, the token never lands in `~/.config/gh/hosts.yml`, and the devcontainer bind-mount therefore carries no `oauth_token`. (On Linux servers, WSL distros without a desktop session, and any host where you ran `gh auth login --insecure-storage`, the token IS in `hosts.yml` and container `gh` is pre-authenticated — skip this section.)\n\nIf your host's `gh` is in a credential store, container `gh` is unauthenticated until you pick one of these trade-offs:\n\n- **Skip container `gh` entirely.** Run all `gh` invocations from the host (where the token stays in the credential store). Inside the container, `gh` will fail until you authenticate it. Pick this if you want the host's GitHub token to live only in the credential store.\n- **Authenticate `gh` once inside the devcontainer.** No credential store in the container, so `gh auth login` writes the token to `~/.config/gh/hosts.yml` — the bind-mount target — meaning the token now also exists on your host as a plaintext bearer token (mode 600). This is materially weaker than the credential-store entry: anyone or anything with read access to that file gets immediate GitHub auth, with no passphrase or unlock step. Your credential-store token is unchanged, and the host's `gh` will still prefer the credential-store one.\n\n```shell\n# Inside the devcontainer (one-time, only if you chose the second option).\n# Default scopes cover PR/issue work — `gh pr view`, `gh issue view`,\n# `gh api repos/.../pulls/N/comments`, `gh run list`, etc.\ngh auth login\n\n# Optional: only if you also want to manage SSH keys with `gh ssh-key add`\n# from the container, extend the token's scopes (note: `gh auth refresh`,\n# not `gh auth login -s`, which would re-do the whole login flow).\n# Most contributors don't need this; default scopes are fine.\ngh auth refresh -h github.com -s admin:public_key,admin:ssh_signing_key\n```\n\n#### Verify Host Setup\n\nOpen a new terminal and verify configuration:\n\n```shell\n# Test paths\nls -la ~/.ssh\nls -la ~/.config/git\nls -la ~/.config/gh\n\n# Show git config\ngit config --list --show-origin\n\n# Test github SSH login\ngh ssh-key list\nssh -T git@github.com\n\n# SSH socket and key should be available via the agent\necho $SSH_AUTH_SOCK\nssh-add -l\n\n# Confirm an ssh-agent process is running (any platform / any setup path)\nps aux | grep ssh-agent | grep -v grep\n\n# Linux/WSL only, and only if you used the systemd path (skip if you used\n# the ~/.bashrc fallback — that path doesn't register a systemd service):\nsystemctl --user status ssh-agent.socket\n```\n\nOn credential-store hosts that haven't been authenticated inside the container yet, container `gh` will be unauthenticated — that's expected at this point. The in-container verify section below covers the post-container-auth check.\n\n#### Open in Devcontainer\n\nConnect to the host from VS Code, direct, over SSH, or over WSL, and clone the repo to the local filesystem.\\\nOpen the directory, and then open the workspace in a devcontainer, **do not clone into a volume** as `${localEnv:HOME}` will not resolve and the container will fail to open.\n\nOpen a terminal in VS Code from the devcontainer, and test the configuration:\n\n```shell\n# Test paths\nls -la ~/.ssh\nls -la ~/.config/git\nls -la ~/.config/gh\n\n# Show git config\ngit config --list --show-origin\n\n# SSH socket and keys should be available via ssh-agent\necho $SSH_AUTH_SOCK\nssh-add -l\n\n# Test GitHub SSH connectivity (does not need `gh`)\nssh -T git@github.com\n\n# Test gh — only if container `gh` is authenticated. It is when the\n# host stored the token in `~/.config/gh/hosts.yml` (Linux servers,\n# minimal WSL, or any host where you ran `gh auth login\n# --insecure-storage`); it isn't when the host's `gh` is using a\n# credential store (Keychain on macOS, libsecret/Secret Service on\n# Linux desktops — both the default when the store is available)\n# unless you ran `gh auth login` once inside the container. Skip in\n# the unauthenticated case — `gh auth status` will fail by design.\n# `gh auth status` works with default scopes; `gh ssh-key list`\n# requires the `admin:public_key` scope, only granted when you extend\n# the token via `gh auth refresh -h github.com -s admin:public_key,admin:ssh_signing_key`.\ngh auth status\n```\n\n[actions-link]: https://github.com/ptr727/homeassistant-purpleair/actions\n[aiopurpleair-fork-link]: https://github.com/ptr727/bachya-aiopurpleair/tree/feat/organization-endpoint-and-error-codes\n[aiopurpleair-pypi-link]: https://pypi.org/project/aiopurpleair/\n[airnow-aqi-link]: https://www.airnow.gov/aqi/aqi-basics/\n[bachya-aiopurpleair-link]: https://github.com/bachya/aiopurpleair\n[bachya-link]: https://github.com/bachya\n[buildstatus-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/homeassistant-purpleair/test-pull-request.yml?logo=github\u0026label=Build%20Status\n[commits-link]: https://github.com/ptr727/homeassistant-purpleair/commits/main\n[coverage-link]: https://app.codecov.io/gh/ptr727/homeassistant-purpleair\n[coverage-shield]: https://img.shields.io/codecov/c/github/ptr727/homeassistant-purpleair?logo=codecov\u0026label=Coverage\n[discussions-link]: https://github.com/ptr727/homeassistant-purpleair/discussions\n[epa-pm25-link]: https://cfpub.epa.gov/si/si_public_record_report.cfm?dirEntryId=353088\u0026Lab=CEMM\n[free-points-link]: https://community.purpleair.com/t/api-points-for-sensor-owners/7525\n[ha-core-pr-link]: https://github.com/home-assistant/core/pull/140901\n[ha-custom-integration-link]: https://developers.home-assistant.io/docs/creating_integration_file_structure/\n[ha-docs-pr-link]: https://github.com/home-assistant/home-assistant.io/pull/38063\n[hacs-link]: https://github.com/hacs/integration\n[hacs-shield]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg?logo=homeassistantcommunitystore\u0026label=HACS\n[hacs-xyz-link]: https://hacs.xyz/\n[haversion-link]: https://www.home-assistant.io/blog/categories/release-notes/\n[haversion-shield]: https://img.shields.io/badge/Home_Assistant-2026.4.0%2B-41BDF5?logo=homeassistant\n[issues-link]: https://github.com/ptr727/homeassistant-purpleair/issues\n[joostlek-link]: https://github.com/joostlek\n[lastbuild-shield]: https://byob.yarr.is/ptr727/homeassistant-purpleair/lastbuild\n[lastcommit-shield]: https://img.shields.io/github/last-commit/ptr727/homeassistant-purpleair?logo=github\u0026label=Last%20Commit\n[license-link]: ./LICENSE\n[license-shield]: https://img.shields.io/github/license/ptr727/homeassistant-purpleair?label=License\n[migration-link]: #migration-from-the-built-in-integration\n[prereleaseversion-shield]: https://img.shields.io/github/v/release/ptr727/homeassistant-purpleair?include_prereleases\u0026label=GitHub%20Pre-Release\u0026logo=github\u0026color=orange\n[purpleair-api-link]: https://api.purpleair.com/\n[purpleair-api-pm25-link]: https://api.purpleair.com/#api-sensors-get-sensor-data\n[purpleair-api-pricing-link]: https://community.purpleair.com/t/api-pricing/4523\n[purpleair-developer-link]: https://develop.purpleair.com/\n[purpleair-keys-link]: https://develop.purpleair.com/dashboards/keys\n[purpleair-link]: https://www.purpleair.com/\n[purpleair-projects-link]: https://develop.purpleair.com/dashboards/projects\n[qualityscale-link]: ./custom_components/purpleair/quality_scale.yaml\n[qualityscale-rules-link]: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/\n[qualityscale-shield]: https://img.shields.io/badge/Quality_Scale-Platinum-9C27B0?logo=homeassistant\n[releases-link]: https://github.com/ptr727/homeassistant-purpleair/releases\n[releaseversion-shield]: https://img.shields.io/github/v/release/ptr727/homeassistant-purpleair?logo=github\u0026label=GitHub%20Release\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fptr727%2Fhomeassistant-purpleair","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fptr727%2Fhomeassistant-purpleair","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fptr727%2Fhomeassistant-purpleair/lists"}