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 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)
- WMO — World Meteorological Organization Severe Weather Information Centre (per-source RSS → CAP XML), covering ~100 national services without a dedicated provider
Additional providers (BoM, DWD, …) can be added behind the same AlertProvider protocol.
A companion Lovelace card lives at weather_alerts_card; its cap.ts adapter is a thin passthrough because normalization happens here.
- HACS → Integrations → ⋮ → Custom repositories
- Add this repo, category "Integration"
- Install CAP Alerts, restart Home Assistant
Copy custom_components/cap_alerts/ into your HA config's custom_components/ directory and restart.
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 |
| WMO | Source ID picked from the live SWIC registry (e.g. mx-smn-es; custom IDs accepted), country-wide or with optional GPS polygon filter |
- 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<cap:info>block. NWS and WMO have no language option (English-only / one language per source).
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: "<ref>"}
Both return a GeoJSON FeatureCollection. See
docs/frontend_hints.md for a card-side snippet.
Both reconfigure (identity/location/provider) and options (behavior) flows are supported.
Every config entry produces one device (named CAP Alerts <PROVIDER>, e.g. CAP Alerts ECCC) that groups these entities:
| Entity | Purpose | State |
|---|---|---|
sensor.cap_alerts_<provider>_alert_count |
Diagnostic. Number of active alerts. | integer |
sensor.cap_alerts_<provider>_last_updated |
Diagnostic. Last successful poll. | ISO timestamp |
sensor.cap_alert_<event_slug>_<hash> |
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.
This trips up new HA users, so worth stating explicitly:
- Integration domain (
cap_alerts) — identifies the integration itself, used inhass.data, config entries, device identifiers, fired event types (incident_created, etc.). - Entity platform domain (
sensor) — every entity this integration produces is a sensor, so itsentity_idstarts withsensor., nevercap_alerts..
So the integration is cap_alerts, but you refer to its entities as sensor.cap_alert_<event_slug>_<hash>, sensor.cap_alerts_<provider>_alert_count, sensor.cap_alerts_<provider>_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.
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.
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.
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
for a reference blueprint.
Data flow per poll:
Weather API → Provider.async_fetch() → list[CAPAlert]
↑ (NWS: GeoJSON, ECCC: Atom→CAP XML, MeteoAlarm: JSON, WMO: RSS→CAP XML)
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)
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, Buddhist-Era year fix, state truncation
store.py # inter-poll diffing, transition detection, HA event firing
providers/
__init__.py # AlertProvider protocol + get_provider() factory
cap.py # shared, provider-neutral CAP 1.2 XML parsing (used by eccc + wmo)
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
meteoalarm.py # EUMETNET per-country CAP JSON + region listing
wmo.py # WMO SWIC per-source RSS → CAP XML + source registry
Deeper reference: docs/architecture.md (alert identity hashing, field mappings, provider rationale, future providers). Planned work: docs/roadmap.md.
- ECCC fetches the linked CAP XML body (not just the Atom envelope), so alerts carry full
headline/description/instruction, accurate timestamps, and one card per alert series — revision chains (NEW → UPDATE → CANCEL) collapse to the current leaf via CAP<references>. - WMO reuses the same RSS-index → per-item CAP two-step (and the shared CAP parser), populates its source dropdown from the live SWIC registry, and pre-filters already-expired RSS items so high-volume feeds don't blow the poll timeout. EU/US users are better served by the dedicated MeteoAlarm/NWS providers.
See docs/architecture.md for the full ECCC and WMO sections — CAP body fetch, concurrency and caching, the expiry pre-filter, and per-field mappings.
CAPAlerthas all fields optional exceptid— tolerates providers with varying completeness.to_attributes()emits only non-empty fields (sparse attributes).- Dynamic entity lifecycle via
_sync_alert_entities()insensor.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(typedCAPAlertsConfigEntry) is used instead of the legacyhass.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.
This is a standard Home Assistant custom integration. It lives entirely under custom_components/cap_alerts/ and follows HA custom component conventions.
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/mainis protected; all changes go through PRs.- Branches:
feat/<slug>,fix/<slug>,chore/<slug>. - Commits:
type(scope): description(feat,fix,docs,refactor,test,chore). - Dependency order when modifying code: model → providers → coordinator → sensor → config_flow →
__init__.
- Implement the
AlertProviderprotocol inproviders/<name>.py— anasync_fetch()returninglist[CAPAlert]. - Register it in
providers/__init__.py::get_provider(). - Add a config-flow branch in
config_flow.py(a menu step plus one form per location mode). - Add translations under
translations/and matching keys instrings.json. - Normalization lives in
normalize.py; extend severity mapping there rather than in the provider.
See repository for license details.