mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
County-only civil CAP alerts (SAME geocode, no <polygon>) had no geometry, so the geometry-based region tagger could not place them: no region -> no region_routes match -> silently not broadcast. Many CEMs / 911 outages / some AMBER alerts are county-only. - Bundle work/meshai/county_centroids.py: Census 2023 national county gazetteer internal points (3,222 counties + DC/territories), FIPS->(lat,lon), with SAME PSSCCC -> 5-digit FIPS helpers. - env/ipaws.py: when a CAP alert has SAME geocode(s) but NO polygon, set a Point (single county) or MultiPoint (multi-county) geometry from the county centroid(s) so the EXISTING coverage/region tagger locates it and tags ALL matching regions. Real polygon geometry always wins (never overridden). - dashboard/server.py: call register_config_routes_hooks(app) in create_app so the toggle auto-refresh middleware is actually wired in prod (was test-only); saving a family toggle now takes effect live without POST /api/notifications/refresh-toggles. Tests: county-only alert tags SW Idaho + matches emergency route cell; multi-county tags all regions; polygon path unchanged; create_app wires the refresh middleware; panhandle coverage-gap documented. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
532 lines
22 KiB
Python
532 lines
22 KiB
Python
"""FEMA IPAWS-OPEN EAS civil-alert adapter.
|
|
|
|
Two-stage public CAP pipeline (keyless, no auth):
|
|
|
|
Stage 1 GET {base_url}/feed -> Atom index (~13 rolling national
|
|
entries). Each <entry> carries a statefips <category> and a <link>
|
|
to the full CAP document.
|
|
Stage 2 GET {base_url}/eas/<id> -> full CAP 1.2 <alert>, fetched ONLY for
|
|
entries that pass the coarse statefips gate (load reduction).
|
|
|
|
Scope (approved behaviour):
|
|
* Coarse region: keep only entries whose statefips term is in
|
|
``config.state_fips`` — applied BEFORE the stage-2 fetch.
|
|
* Fine region: optional ``config.same_codes`` SAME county filter.
|
|
* Non-weather only: DROP NWS/NOAA-originated CAP (``exclude_weather``) so we
|
|
never double-broadcast what the ``nws`` adapter already carries.
|
|
* Language: en-US <info> blocks only (skip es-US).
|
|
* Status: only ``status=Actual`` when ``status_actual_only`` (default True).
|
|
* Severity: mapped via the SHARED ``env.nws.map_cap_severity`` (Extreme ->
|
|
immediate, Severe -> priority, else routine).
|
|
|
|
The adapter is duck-typed identically to ``NWSAlertsAdapter`` (tick/_fetch/
|
|
get_events/to_event/health_status) and emits the canonical CAP ``data`` dict so
|
|
the formatter+gater architecture (formatters/ipaws.py, gating/ipaws.py) operates
|
|
on it exactly like NWS events. Its own dedup table is ``ipaws_alerts`` (NOT the
|
|
NWS ``nws_alerts`` table).
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, Optional
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
from meshai.county_centroids import centroid_for_same
|
|
from meshai.env.nws import _cfg_str, map_cap_severity
|
|
from meshai.notifications.events import Event, make_event
|
|
|
|
if TYPE_CHECKING:
|
|
from ..config import IPAWSConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_BASE_URL = "https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest"
|
|
|
|
_ATOM_NS = "http://www.w3.org/2005/Atom"
|
|
_CAP_NS = "urn:oasis:names:tc:emergency:cap:1.2"
|
|
|
|
# ── SAME eventCode -> meshai emergency category ──────────────────────────────
|
|
# Civil / public-safety EAS event codes we broadcast. Weather SAME codes are
|
|
# handled by the nws adapter and are additionally dropped by the weather-sender
|
|
# gate, so they are intentionally NOT mapped here.
|
|
_SAME_CATEGORY = {
|
|
"EVI": "emergency_evacuation", # Evacuation Immediate
|
|
"EVA": "emergency_evacuation", # Evacuation (Watch)
|
|
"CEM": "emergency_civil", # Civil Emergency Message
|
|
"CDW": "emergency_civil", # Civil Danger Warning
|
|
"CAE": "emergency_amber", # Child Abduction Emergency (AMBER)
|
|
"LAE": "emergency_amber", # Local Area Emergency (child/other) — treat as amber-ish
|
|
"LEW": "emergency_law", # Law Enforcement Warning
|
|
"SPW": "emergency_law", # Shelter in Place Warning
|
|
"TOE": "emergency_911_outage", # 911 Telephone Outage Emergency
|
|
"HMW": "emergency_hazmat", # Hazardous Materials Warning
|
|
"NUW": "emergency_hazmat", # Nuclear Power Plant Warning
|
|
"RHW": "emergency_hazmat", # Radiological Hazard Warning
|
|
"FRW": "emergency_hazmat", # Fire Warning
|
|
}
|
|
|
|
# Keyword fallback on the CAP <event> string when the SAME code is unknown.
|
|
_EVENT_KEYWORD_CATEGORY = [
|
|
("evacuat", "emergency_evacuation"),
|
|
("amber", "emergency_amber"),
|
|
("child abduction", "emergency_amber"),
|
|
("shelter in place", "emergency_law"),
|
|
("law enforcement", "emergency_law"),
|
|
("911", "emergency_911_outage"),
|
|
("telephone outage", "emergency_911_outage"),
|
|
("hazardous material", "emergency_hazmat"),
|
|
("hazmat", "emergency_hazmat"),
|
|
("radiolog", "emergency_hazmat"),
|
|
("nuclear", "emergency_hazmat"),
|
|
("civil danger", "emergency_civil"),
|
|
("civil emergency", "emergency_civil"),
|
|
]
|
|
|
|
_DEFAULT_CATEGORY = "emergency_civil"
|
|
|
|
|
|
def _norm_fips(value) -> str:
|
|
"""Normalise a state FIPS token to a zero-padded 2-char string.
|
|
|
|
'16' -> '16', '6' -> '06', 6 -> '06'. Non-numeric tokens are returned
|
|
stripped so an odd upstream value still compares by identity.
|
|
"""
|
|
s = str(value).strip()
|
|
try:
|
|
return str(int(s)).zfill(2)
|
|
except (TypeError, ValueError):
|
|
return s
|
|
|
|
|
|
class IPAWSAlertsAdapter:
|
|
"""FEMA IPAWS-OPEN EAS civil alerts — two-stage CAP poller."""
|
|
|
|
def __init__(self, config: "IPAWSConfig", coverage: dict = None):
|
|
self._base_url = _cfg_str(config, "base_url", DEFAULT_BASE_URL).rstrip("/")
|
|
self._user_agent = getattr(config, "user_agent", "") or "meshai-ipaws/1.0"
|
|
|
|
# Coarse region scope: prefer the universal-coverage-derived state_fips
|
|
# (bbox -> states), else the adapter's own config list.
|
|
derived = None
|
|
if coverage is not None:
|
|
derived = coverage.get("state_fips")
|
|
cfg_states = list(getattr(config, "state_fips", None) or [])
|
|
states = derived if derived else cfg_states
|
|
self._state_fips = {_norm_fips(s) for s in (states or [])}
|
|
|
|
self._same_codes = {str(c).strip() for c in (getattr(config, "same_codes", None) or [])}
|
|
self._exclude_weather = bool(getattr(config, "exclude_weather", True))
|
|
self._drop_senders = [
|
|
str(s).strip().lower()
|
|
for s in (getattr(config, "drop_senders", None) or [])
|
|
if str(s).strip()
|
|
]
|
|
self._status_actual_only = bool(getattr(config, "status_actual_only", True))
|
|
|
|
self._tick_interval = getattr(config, "tick_seconds", None) or 60
|
|
self._last_tick = 0.0
|
|
self._events = []
|
|
self._consecutive_errors = 0
|
|
self._last_error = None
|
|
self._backoff_until = 0.0
|
|
self._is_loaded = False
|
|
|
|
# ── Polling ──────────────────────────────────────────────────────────────
|
|
|
|
def tick(self) -> bool:
|
|
"""Execute one polling tick (self-throttled by tick_seconds)."""
|
|
now = time.time()
|
|
if now < self._backoff_until:
|
|
return False
|
|
if now - self._last_tick < self._tick_interval:
|
|
return False
|
|
self._last_tick = now
|
|
return self._fetch()
|
|
|
|
def _get(self, url: str) -> bytes:
|
|
headers = {"User-Agent": self._user_agent, "Accept": "application/xml"}
|
|
req = Request(url, headers=headers)
|
|
with urlopen(req, timeout=15) as resp:
|
|
return resp.read()
|
|
|
|
def _stage2_url(self, link_href: str) -> Optional[str]:
|
|
"""Rebuild a base_url-relative stage-2 URL from an absolute FEMA link.
|
|
|
|
The Atom <link href> is an ABSOLUTE FEMA URL; we extract the trailing
|
|
``eas/<id>`` and rebuild ``{base_url}/eas/<id>`` so the fetch routes
|
|
through whatever base_url points at (direct FEMA or the Conduit proxy).
|
|
Returns None if the href carries no ``eas/<id>`` segment.
|
|
"""
|
|
if not link_href:
|
|
return None
|
|
marker = "/eas/"
|
|
idx = link_href.rfind(marker)
|
|
if idx == -1:
|
|
# Some feeds use a bare "eas/<id>" without a leading slash.
|
|
if link_href.startswith("eas/"):
|
|
return f"{self._base_url}/{link_href}"
|
|
return None
|
|
tail = link_href[idx + 1:] # "eas/<id>"
|
|
return f"{self._base_url}/{tail}"
|
|
|
|
def _fetch(self) -> bool:
|
|
"""Fetch + filter the IPAWS feed. Returns True if the active set changed."""
|
|
# ── Stage 1: Atom index ──────────────────────────────────────────────
|
|
try:
|
|
raw = self._get(f"{self._base_url}/feed")
|
|
except HTTPError as e:
|
|
if e.code == 429:
|
|
self._backoff_until = time.time() + 5
|
|
logger.warning("IPAWS rate limited, backing off 5s")
|
|
else:
|
|
logger.warning("IPAWS HTTP error: %s", e.code)
|
|
self._last_error = f"HTTP {e.code}"
|
|
self._consecutive_errors += 1
|
|
return False
|
|
except URLError as e:
|
|
logger.warning("IPAWS connection error: %s", e.reason)
|
|
self._last_error = str(e.reason)
|
|
self._consecutive_errors += 1
|
|
return False
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("IPAWS feed fetch error: %s", e)
|
|
self._last_error = str(e)
|
|
self._consecutive_errors += 1
|
|
return False
|
|
|
|
try:
|
|
feed = ET.fromstring(raw)
|
|
except ET.ParseError as e:
|
|
logger.warning("IPAWS feed parse error: %s", e)
|
|
self._last_error = f"feed parse: {e}"
|
|
self._consecutive_errors += 1
|
|
return False
|
|
|
|
new_events = []
|
|
for entry in feed.findall(f"{{{_ATOM_NS}}}entry"):
|
|
statefips = None
|
|
for cat in entry.findall(f"{{{_ATOM_NS}}}category"):
|
|
if cat.get("label") == "statefips":
|
|
statefips = cat.get("term")
|
|
break
|
|
# ── Coarse region gate (BEFORE stage-2 fetch) ────────────────────
|
|
if self._state_fips and _norm_fips(statefips) not in self._state_fips:
|
|
continue
|
|
|
|
link_el = entry.find(f"{{{_ATOM_NS}}}link")
|
|
href = link_el.get("href") if link_el is not None else None
|
|
cap_url = self._stage2_url(href)
|
|
if not cap_url:
|
|
continue
|
|
|
|
# ── Stage 2: full CAP document ───────────────────────────────────
|
|
try:
|
|
cap_raw = self._get(cap_url)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("IPAWS stage-2 fetch failed for %s: %s", cap_url, e)
|
|
continue
|
|
|
|
parsed = self._parse_cap(cap_raw, statefips)
|
|
if parsed:
|
|
new_events.append(parsed)
|
|
|
|
# Change detection on the active identifier set.
|
|
old_ids = {e["event_id"] for e in self._events}
|
|
new_ids = {e["event_id"] for e in new_events}
|
|
changed = old_ids != new_ids
|
|
|
|
self._events = new_events
|
|
self._consecutive_errors = 0
|
|
self._last_error = None
|
|
self._is_loaded = True
|
|
if changed:
|
|
logger.info("IPAWS alerts updated: %d active", len(new_events))
|
|
return changed
|
|
|
|
# ── CAP parsing / filtering ──────────────────────────────────────────────
|
|
|
|
def _parse_cap(self, cap_raw: bytes, statefips: Optional[str]) -> Optional[dict]:
|
|
"""Parse a CAP 1.2 <alert> into an internal event dict, applying the
|
|
status / weather-sender / language / SAME filters. Returns None when the
|
|
alert is filtered out or unparseable."""
|
|
try:
|
|
alert = ET.fromstring(cap_raw)
|
|
except ET.ParseError as e:
|
|
logger.debug("IPAWS CAP parse error: %s", e)
|
|
return None
|
|
|
|
def _t(parent, tag):
|
|
el = parent.find(f"{{{_CAP_NS}}}{tag}")
|
|
return el.text.strip() if el is not None and el.text else ""
|
|
|
|
identifier = _t(alert, "identifier")
|
|
sender = _t(alert, "sender")
|
|
sent = _t(alert, "sent")
|
|
status = _t(alert, "status")
|
|
msg_type = _t(alert, "msgType") or "Alert"
|
|
|
|
# ── Status gate ──────────────────────────────────────────────────────
|
|
if self._status_actual_only and status and status.lower() != "actual":
|
|
return None
|
|
|
|
# ── Weather-sender exclusion (drop NWS/NOAA) ─────────────────────────
|
|
if self._exclude_weather and sender:
|
|
s = sender.lower()
|
|
if any(bad in s for bad in self._drop_senders):
|
|
return None
|
|
|
|
# ── en-US <info> block (skip es-US) ──────────────────────────────────
|
|
infos = alert.findall(f"{{{_CAP_NS}}}info")
|
|
info = None
|
|
for cand in infos:
|
|
lang_el = cand.find(f"{{{_CAP_NS}}}language")
|
|
lang = (lang_el.text.strip().lower() if lang_el is not None and lang_el.text
|
|
else "en-us")
|
|
if lang.startswith("en"):
|
|
info = cand
|
|
break
|
|
if info is None:
|
|
return None
|
|
|
|
event_type = _t(info, "event") or "Emergency Alert"
|
|
urgency = _t(info, "urgency")
|
|
cap_severity = _t(info, "severity") or "Unknown"
|
|
certainty = _t(info, "certainty")
|
|
headline = _t(info, "headline")
|
|
description = _t(info, "description")
|
|
|
|
# eventCode SAME value
|
|
same_value = ""
|
|
for ec in info.findall(f"{{{_CAP_NS}}}eventCode"):
|
|
vn = ec.find(f"{{{_CAP_NS}}}valueName")
|
|
vv = ec.find(f"{{{_CAP_NS}}}value")
|
|
if vn is not None and vn.text and vn.text.strip().upper() == "SAME":
|
|
same_value = (vv.text.strip() if vv is not None and vv.text else "")
|
|
break
|
|
|
|
# ── Area: areaDesc + SAME geocodes + optional geometry ───────────────
|
|
area_descs = []
|
|
area_same_codes = []
|
|
geometry = None
|
|
for area in info.findall(f"{{{_CAP_NS}}}area"):
|
|
ad = _t(area, "areaDesc")
|
|
if ad:
|
|
area_descs.append(ad)
|
|
for gc in area.findall(f"{{{_CAP_NS}}}geocode"):
|
|
vn = gc.find(f"{{{_CAP_NS}}}valueName")
|
|
vv = gc.find(f"{{{_CAP_NS}}}value")
|
|
if (vn is not None and vn.text and vn.text.strip().upper() == "SAME"
|
|
and vv is not None and vv.text):
|
|
area_same_codes.append(vv.text.strip())
|
|
if geometry is None:
|
|
geometry = self._polygon_geometry(area)
|
|
|
|
area_desc = "; ".join(area_descs)
|
|
|
|
# ── Fine region gate (optional SAME county filter) ───────────────────
|
|
if self._same_codes:
|
|
if not any(c in self._same_codes for c in area_same_codes):
|
|
return None
|
|
|
|
# ── County-centroid fallback for county-only (no-polygon) alerts ─────
|
|
# A civil CAP alert that carries only SAME county geocode(s) and NO
|
|
# <polygon> has no geometry, so the geometry-based coverage/region
|
|
# tagger (CoverageFilter -> event_region_names -> matching_area_names)
|
|
# cannot place it. With no region it never matches the region-routing
|
|
# matrix and is SILENTLY not broadcast — the exact gap this closes.
|
|
# Resolve each SAME county to its Census internal point so the EXISTING
|
|
# geometry tagger locates it. Multi-county alerts become a MultiPoint so
|
|
# EVERY county's region is tagged (Shapely intersects any point). A real
|
|
# <polygon> always wins and is never overridden.
|
|
if geometry is None and area_same_codes:
|
|
county_points = [] # [(lat, lon), ...] in resolution order
|
|
seen_points = set()
|
|
for code in area_same_codes:
|
|
pt = centroid_for_same(code)
|
|
if pt and pt not in seen_points:
|
|
seen_points.add(pt)
|
|
county_points.append(pt)
|
|
if len(county_points) == 1:
|
|
lat, lon = county_points[0]
|
|
geometry = {"type": "Point", "coordinates": [lon, lat]}
|
|
elif len(county_points) > 1:
|
|
geometry = {
|
|
"type": "MultiPoint",
|
|
"coordinates": [[lon, lat] for (lat, lon) in county_points],
|
|
}
|
|
|
|
# Stable per-alert id (also the dedup / group key).
|
|
event_id = identifier or f"ipaws:{same_value}:{sent}"
|
|
|
|
category = self._derive_category(same_value, event_type)
|
|
centroid = self._centroid(geometry)
|
|
|
|
return {
|
|
"source": "ipaws",
|
|
"event_id": event_id,
|
|
"cap_id": event_id,
|
|
"event_type": event_type,
|
|
"urgency": urgency,
|
|
"cap_severity": cap_severity,
|
|
"certainty": certainty,
|
|
"sender": sender,
|
|
"status": status,
|
|
"msgType": msg_type,
|
|
"headline": headline,
|
|
"description": description,
|
|
"same_code": same_value,
|
|
"area_desc": area_desc,
|
|
"area_same_codes": area_same_codes,
|
|
"statefips": statefips,
|
|
"category": category,
|
|
"sent": self._parse_iso(sent),
|
|
"expires": self._parse_iso(_t(info, "expires")),
|
|
"geometry": geometry,
|
|
"lat": centroid[0] if centroid else None,
|
|
"lon": centroid[1] if centroid else None,
|
|
"fetched_at": time.time(),
|
|
}
|
|
|
|
def _derive_category(self, same_value: str, event_type: str) -> str:
|
|
"""Map a CAP SAME eventCode (or the event string) to an emergency_* category."""
|
|
code = (same_value or "").strip().upper()
|
|
if code in _SAME_CATEGORY:
|
|
return _SAME_CATEGORY[code]
|
|
ev = (event_type or "").lower()
|
|
for kw, cat in _EVENT_KEYWORD_CATEGORY:
|
|
if kw in ev:
|
|
return cat
|
|
return _DEFAULT_CATEGORY
|
|
|
|
@staticmethod
|
|
def _polygon_geometry(area) -> Optional[dict]:
|
|
"""Build a GeoJSON Polygon from a CAP <polygon> (space-separated
|
|
'lat,lon' pairs). Returns None when the area has no polygon."""
|
|
poly_el = area.find(f"{{{_CAP_NS}}}polygon")
|
|
if poly_el is None or not (poly_el.text and poly_el.text.strip()):
|
|
return None
|
|
ring = []
|
|
for pair in poly_el.text.strip().split():
|
|
try:
|
|
lat_s, lon_s = pair.split(",")
|
|
ring.append([float(lon_s), float(lat_s)]) # GeoJSON is [lon,lat]
|
|
except (ValueError, IndexError):
|
|
continue
|
|
if len(ring) < 4:
|
|
return None
|
|
if ring[0] != ring[-1]:
|
|
ring.append(ring[0])
|
|
return {"type": "Polygon", "coordinates": [ring]}
|
|
|
|
@staticmethod
|
|
def _centroid(geometry: Optional[dict]) -> Optional[tuple]:
|
|
"""Best-effort (lat, lon) centroid of a GeoJSON Polygon / Point /
|
|
MultiPoint (or None). Point/MultiPoint arise from the county-centroid
|
|
fallback for county-only alerts; MultiPoint returns the mean point (a
|
|
reasonable display centroid — region tagging uses the full geometry, not
|
|
this value)."""
|
|
if not geometry:
|
|
return None
|
|
gtype = geometry.get("type")
|
|
coords = geometry.get("coordinates") or []
|
|
if gtype == "Point":
|
|
# GeoJSON Point coordinates are [lon, lat].
|
|
if len(coords) >= 2:
|
|
return (coords[1], coords[0])
|
|
return None
|
|
if gtype == "MultiPoint":
|
|
pts = [c for c in coords if len(c) >= 2]
|
|
if not pts:
|
|
return None
|
|
lats = [c[1] for c in pts]
|
|
lons = [c[0] for c in pts]
|
|
return (sum(lats) / len(lats), sum(lons) / len(lons))
|
|
if gtype != "Polygon":
|
|
return None
|
|
if not coords or not coords[0]:
|
|
return None
|
|
ring = coords[0]
|
|
lats = [c[1] for c in ring]
|
|
lons = [c[0] for c in ring]
|
|
if not lats:
|
|
return None
|
|
return (sum(lats) / len(lats), sum(lons) / len(lons))
|
|
|
|
@staticmethod
|
|
def _parse_iso(iso_str: str) -> float:
|
|
"""Parse a CAP/ISO-8601 timestamp to epoch float (0.0 on failure)."""
|
|
if not iso_str:
|
|
return 0.0
|
|
try:
|
|
s = iso_str.strip()
|
|
if s.endswith("Z"):
|
|
s = s[:-1] + "+00:00"
|
|
return datetime.fromisoformat(s).timestamp()
|
|
except Exception: # noqa: BLE001
|
|
return 0.0
|
|
|
|
# ── Event emission ───────────────────────────────────────────────────────
|
|
|
|
def to_event(self, raw: dict) -> Event:
|
|
"""Convert an internal event dict to a pipeline Event carrying the
|
|
canonical CAP ``data`` dict (same shape the NWS path emits), so
|
|
formatters/ipaws.py + gating/ipaws.py render/gate it identically."""
|
|
event_type = raw.get("event_type", "Emergency Alert")
|
|
category = raw.get("category") or _DEFAULT_CATEGORY
|
|
severity = map_cap_severity(raw.get("cap_severity", "Unknown"))
|
|
area_desc = raw.get("area_desc", "")
|
|
|
|
canonical = {
|
|
"cap_id": raw.get("cap_id") or raw.get("event_id", ""),
|
|
"event": event_type,
|
|
"same_code": raw.get("same_code", ""),
|
|
"cap_severity": raw.get("cap_severity", "Unknown"),
|
|
"certainty": raw.get("certainty", ""),
|
|
"urgency": raw.get("urgency", ""),
|
|
"sender": raw.get("sender", ""),
|
|
"expires_at": raw.get("expires") or None,
|
|
"area_desc": area_desc,
|
|
"geocoder": {"city": None, "county": area_desc, "state": None},
|
|
"description": raw.get("description", ""),
|
|
"parameters": {},
|
|
"msgType": raw.get("msgType", "Alert"),
|
|
"references": [],
|
|
"category": category,
|
|
"headline": raw.get("headline", ""),
|
|
"geometry": raw.get("geometry"),
|
|
}
|
|
|
|
return make_event(
|
|
source="ipaws",
|
|
category=category,
|
|
severity=severity,
|
|
title=raw.get("headline") or event_type,
|
|
summary=raw.get("headline", ""),
|
|
body=raw.get("description", ""),
|
|
effective=raw.get("sent") or None,
|
|
expires=raw.get("expires") or None,
|
|
lat=raw.get("lat"),
|
|
lon=raw.get("lon"),
|
|
group_key=raw.get("event_id", ""),
|
|
inhibit_keys=[],
|
|
data=canonical,
|
|
)
|
|
|
|
def get_events(self) -> list:
|
|
"""Return the current active events."""
|
|
return self._events
|
|
|
|
@property
|
|
def health_status(self) -> dict:
|
|
"""Adapter health snapshot (same shape as the NWS adapter)."""
|
|
return {
|
|
"source": "ipaws",
|
|
"is_loaded": self._is_loaded,
|
|
"last_error": str(self._last_error) if self._last_error else None,
|
|
"consecutive_errors": self._consecutive_errors,
|
|
"event_count": len(self._events),
|
|
"last_fetch": self._last_tick,
|
|
}
|