An open API service indexing awesome lists of open source software.

https://github.com/seevee/cap_alerts

Proof-of-concept HA weather alerts integration exploring an alert entity pattern: one entity per active weather alert, modeled on CAP fields. Solves the 16KB attribute limit.
https://github.com/seevee/cap_alerts

alerts cap eccc home-assistant nws weather

Last synced: 27 days ago
JSON representation

Proof-of-concept HA weather alerts integration exploring an alert entity pattern: one entity per active weather alert, modeled on CAP fields. Solves the 16KB attribute limit.

Awesome Lists containing this project

README

          

# CAP Alerts

A Home Assistant custom integration that creates **one entity per active weather alert**, solving the 16 KB attribute limit that affects single-entity alert integrations.

Alert data is modeled using [CAP (Common Alerting Protocol) 1.2](https://docs.oasis-open.org/emergency/cap/v1.2/CAP-v1.2.html) field names via a `CAPAlert` frozen dataclass. Ships with providers for:

- **NWS** — U.S. National Weather Service (GeoJSON API)
- **ECCC** — Environment and Climate Change Canada (NAAD Atom feed)
- **MeteoAlarm** — EUMETNET European aggregator (per-country CAP JSON, ~37 member services)

Additional providers (BoM, DWD, WMO CAP, …) can be added behind the same `AlertProvider` protocol.

A companion Lovelace card lives at [`weather_alerts_card`](../weather_alerts_card); its `cap.ts` adapter is a thin passthrough because normalization happens here.

---

## Installation

### HACS (custom repository)

1. HACS → Integrations → ⋮ → Custom repositories
2. Add this repo, category "Integration"
3. Install **CAP Alerts**, restart Home Assistant

### Manual

Copy `custom_components/cap_alerts/` into your HA config's `custom_components/` directory and restart.

---

## Configuration

Settings → Devices & Services → **Add Integration** → *CAP Alerts*.

Pick a provider, then a location mode:

| Provider | Modes |
|---|---|
| NWS | Zone ID (e.g. `ILZ014`, or comma-separated), GPS (`lat,lon`), `device_tracker` entity |
| ECCC | Province code (`AB`, `BC`, `ON`, …), GPS (`lat,lon`) |
| MeteoAlarm | Country (ISO 3166-1 alpha-2, e.g. `DE`), with optional GPS polygon filter or `EMMA_ID` region multi-select |

### Options (per entry)

- **Scan interval** — 60–3600 s, default 300
- **Timeout** — 5–120 s, default 30
- **Language** — ECCC: `auto` / `en-CA` / `fr-CA`. MeteoAlarm: 2-letter prefix (`en`, `de`, `fr`, …) used to pick the primary `` block.

Polygons are **never** emitted in entity attributes — instead, each alert
carries a `geometry_ref` handle plus a `bbox`. Fetch the full GeoJSON via:

- REST: `GET /api/cap_alerts/geometry/{geometry_ref}` (HA auth required)
- Websocket: `{type: "cap_alerts/geometry", geometry_ref: ""}`

Both return a GeoJSON `FeatureCollection`. See
[`docs/frontend_hints.md`](docs/frontend_hints.md) for a card-side snippet.

Both **reconfigure** (identity/location/provider) and **options** (behavior) flows are supported.

---

## Entities

Every config entry produces one **device** (named `CAP Alerts `, e.g. `CAP Alerts ECCC`) that groups these entities:

| Entity | Purpose | State |
|---|---|---|
| `sensor.cap_alerts__alert_count` | Diagnostic. Number of active alerts. | integer |
| `sensor.cap_alerts__last_updated` | Diagnostic. Last successful poll. | ISO timestamp |
| `sensor.cap_alert__` | One per active alert; created/removed dynamically each poll. | normalized severity (`minor` \| `moderate` \| `severe` \| `extreme` \| `unknown`) |

The device name is intentionally stable across reconfigures so entity_ids don't drift when you change GPS, zone, or region. The per-entry friendly label (with location detail) remains visible in the integrations list as the entry title; users running multiple entries of the same provider can set `name_by_user` on the device for a personalized label.

Alert entity `extra_state_attributes` is a sparse dict of CAP fields — only populated fields are included. See `model.py::CAPAlert` for the full schema.

### Integration domain vs. entity IDs

This trips up new HA users, so worth stating explicitly:

- **Integration domain** (`cap_alerts`) — identifies the integration itself, used in `hass.data`, config entries, device identifiers, fired event types (`incident_created`, etc.).
- **Entity platform domain** (`sensor`) — every entity this integration produces is a *sensor*, so its `entity_id` starts with `sensor.`, never `cap_alerts.`.

So the integration is `cap_alerts`, but you refer to its entities as `sensor.cap_alert_`, `sensor.cap_alerts_count`, `sensor.cap_alerts_last_updated` in automations, templates, and the frontend.

Per-alert entity IDs are derived from the alert's `event` text (e.g. `sensor.cap_alert_tornado_warning`). If multiple active alerts share an event name, HA appends `_2`, `_3`, … Unique IDs are stable across restarts (`{entry_id}_{provider}_{alert_id}`), so the registry keeps identity even when the entity_id suffix shifts.

---

## Events

For automation use, the integration fires three event types on the HA bus:

| Event | When |
|---|---|
| `incident_created` | A new alert ID appears. |
| `incident_updated` | An existing alert's lifecycle **phase** or other tracked fields changed. |
| `incident_removed` | An alert moved to a terminal phase (`cancel` / `expired`) or disappeared from the feed. |

Full payload schema and semantics are documented in [`docs/events.md`](docs/events.md).
`incident_removed` payloads carry the terminal `phase` (`cancel` or `expired`)
so automations can distinguish an upstream cancel from a natural expiry
without re-deriving it from timestamps.

### History UI tradeoff

Once an alert ends, its entity is removed from the entity registry. This
means Home Assistant's **History** dashboard renders past alerts with only
a slugified `entity_id` rather than a friendly name. Recorder rows are
preserved at the database level, but the UI has no friendly-name context
to paint. Wire up an automation that listens for `incident_removed` and
forwards the payload to your archival store of choice (InfluxDB,
Postgres, a notify service) — see
[`blueprints/cap_alerts_archive_incident_removed.yaml`](blueprints/cap_alerts_archive_incident_removed.yaml)
for a reference blueprint.

---

## Architecture

Data flow per poll:

```
Weather API → Provider.async_fetch() → list[CAPAlert]
↑ (NWS: GeoJSON, ECCC: Atom XML, future: varies)
Coordinator._async_update_data()
normalize_alerts() → sets severity_normalized, phase
store.process() → diffs vs previous, sets phase_changed, fires HA events
├─ CountSensor (state = len)
└─ coordinator listener → diffs alert IDs vs tracked entities
→ async_add_entities / registry remove
└─ AlertEntity (finds own CAPAlert by ID in coordinator.data)
```

### Files

```
custom_components/cap_alerts/
__init__.py # entry setup, coordinator wiring, platform forwarding
const.py # domain, defaults, user-agent format
config_flow.py # setup + reconfigure + options flows
coordinator.py # orchestrates provider, feeds list[CAPAlert] to entities
sensor.py # CountSensor, LastUpdatedSensor, AlertEntity, dynamic lifecycle
model.py # CAPAlert dataclass + to_attributes()
normalize.py # shared normalization: severity, phase, state truncation
store.py # inter-poll diffing, transition detection, HA event firing
providers/
__init__.py # AlertProvider protocol + get_provider() factory
cap_content_cache.py # LRU cache for immutable CAP XML bodies
nws.py # NWS GeoJSON API — zone / GPS / tracker
eccc.py # Environment Canada NAAD Atom feed
```

Deeper reference: [`docs/architecture.md`](docs/architecture.md) (alert identity hashing, field mappings, provider rationale, future providers). Planned work: [`docs/roadmap.md`](docs/roadmap.md).

### ECCC — CAP body fetch

ECCC alerts now fetch the linked CAP XML (``) to provide the full alert body. This eliminates the empty `description`/`headline`/`instruction`, wrong/stale timestamps, and duplicate cards for the same alert series.

**What changed:**
- `headline`, `description`, `instruction` are populated from the CAP `` block.
- `sent`, `effective`, `onset`, `expires` reflect CAP-body values. Past-`expires` alerts are correctly phased as `expired` and filtered from the active set.
- Revision chains (NEW → UPDATE → CANCEL) collapse to the current leaf via CAP ``, so one card per alert series is shown.
- `event` uses the CAP title-case form (e.g. `"Freezing Drizzle Advisory"` instead of the lowercase Atom category term). Icon dispatch is unaffected.
- ECCC CAP-CP eventCodes (e.g. `profile:CAP-CP:Event:0.4 → freezing-drizzle`) are exposed via `attributes.parameters`.

**Operational details:** CAP files are fetched with bounded concurrency (`asyncio.Semaphore(5)`) and cached in a shared LRU-256 `CAPContentCache` on `hass.data[DOMAIN]`. Since each CAP revision has a unique URL, the cache requires no TTL. On fetch failure the alert surfaces with Atom-only metadata (empty long-form text) rather than being dropped.

### Key design decisions

- `CAPAlert` has all fields optional except `id` — tolerates providers with varying completeness.
- `to_attributes()` emits only non-empty fields (sparse attributes).
- Dynamic entity lifecycle via `_sync_alert_entities()` in `sensor.py`: add on new ID, remove from entity registry on disappearance.
- Severity, zones, and phase are normalized at the integration level, not in the card.
- `entry.runtime_data` (typed `CAPAlertsConfigEntry`) is used instead of the legacy `hass.data[DOMAIN]` dict.
- `async_config_entry_first_refresh()` gates setup so startup surfaces connection errors properly.
- No `CONF_NAME` — entry title is derived programmatically from provider + location.

---

## Development

This is a standard Home Assistant custom integration. It lives entirely under `custom_components/cap_alerts/` and follows [HA custom component conventions](https://developers.home-assistant.io/docs/creating_integration_manifest).

```bash
pytest # run all tests
pytest tests/test_coordinator.py # single file
pytest -k test_parse_alerts # pattern

mypy custom_components/cap_alerts/
ruff check custom_components/cap_alerts/
ruff format custom_components/cap_alerts/
```

### Workflow

- `main` is protected; all changes go through PRs.
- Branches: `feat/`, `fix/`, `chore/`.
- Commits: `type(scope): description` (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`).
- Dependency order when modifying code: **model → providers → coordinator → sensor → config_flow → `__init__`**.

### Adding a provider

1. Implement the `AlertProvider` protocol in `providers/.py` — an `async_fetch()` returning `list[CAPAlert]`.
2. Register it in `providers/__init__.py::get_provider()`.
3. Add a config-flow branch in `config_flow.py` (a menu step plus one form per location mode).
4. Add translations under `translations/` and matching keys in `strings.json`.
5. Normalization lives in `normalize.py`; extend severity mapping there rather than in the provider.

---

## License

See repository for license details.