diff --git a/work/meshai/adapter_config/defaults.py b/work/meshai/adapter_config/defaults.py index 3cb5ff4..cb4cb9c 100644 --- a/work/meshai/adapter_config/defaults.py +++ b/work/meshai/adapter_config/defaults.py @@ -600,6 +600,22 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = { "description": "Maximum characters for a single NWS mesh packet. Budget enforced after all fields are assembled; only the locations town-list is trimmed to fit. main.py overrides this to the live transport max_chars at runtime.", }, + # ================================================================= + # IPAWS -- FEMA IPAWS-OPEN EAS civil-alert dedup/tombstone tunables + # (mirror of the NWS gating knobs; the ipaws decider owns its own + # ipaws_alerts table so these are independent of the nws.* values) + # ================================================================= + ("ipaws", "tombstone_msgtypes"): { + "default": ["Cancel", "Expire"], + "type": "json", + "description": "CAP msgType values that mark an IPAWS civil alert as gone (suppressed).", + }, + ("ipaws", "duplicate_allowed_after_seconds"): { + "default": 10800, # 3h, mirrors nws + "type": "int", + "description": "Allow re-broadcast of the same IPAWS CAP id after this many seconds (dedup-window relaxation; uses an 'Active' prefix past this point).", + }, + # ================================================================= # AVALANCHE -- 1 setting (min danger level broadcast floor) # ================================================================= @@ -700,6 +716,11 @@ ADAPTER_META: dict[str, dict[str, Any]] = { "include_in_llm_context": True, "description": "CAP-formatted severe-weather warnings/watches/advisories.", }, + "ipaws": { + "display_name": "FEMA IPAWS civil alerts", + "include_in_llm_context": True, + "description": "IPAWS-OPEN EAS non-weather civil alerts: evacuation, Civil Emergency Message, AMBER, 911 outage, law-enforcement, HazMat. Ships DISABLED.", + }, "usgs_quake": { "display_name": "USGS earthquakes", "include_in_llm_context": True, diff --git a/work/meshai/config.py b/work/meshai/config.py index 33dd67a..7df5a16 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -550,6 +550,48 @@ class SatpassConfig(_SourcedFeed): broadcast_lead_seconds: int = 3600 +@dataclass +class IPAWSConfig(_SourcedFeed): + """FEMA IPAWS-OPEN EAS civil-alert feed settings. + + Ingests the public IPAWS-OPEN Atom index + per-entry CAP 1.2 documents and + broadcasts NON-weather civil emergency alerts (evacuation orders, Civil + Emergency Messages, AMBER alerts, 911 outages, law-enforcement warnings, + HazMat, shelter-in-place). NWS/NOAA-originated CAP entries are DROPPED so + meshai never double-broadcasts weather the `nws` adapter already carries. + + Keyless: the FEMA IPAWS-OPEN EAS REST service needs no auth/API key. + + Two-stage fetch (both honour `base_url`): + 1. GET {base_url}/feed -> Atom index (~13 rolling national entries) + 2. GET {base_url}/eas/ -> full CAP 1.2 alert, per in-scope entry + The Atom values are ABSOLUTE FEMA URLs; the adapter extracts the + trailing ``eas/`` and rebuilds ``{base_url}/eas/`` so stage-2 routes + through whatever base_url points at (in prod: the Conduit proxy). + """ + + enabled: bool = False + tick_seconds: int = 60 + base_url: str = "https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest" + user_agent: str = "" + # Coarse region gate, applied BEFORE the stage-2 CAP fetch (the "reduce + # load / don't fetch every linked CAP" gate): keep only index entries whose + # statefips category term is in this list. Default = Idaho + neighbours + # (ID=16, WA=53, OR=41, NV=32, UT=49, WY=56, MT=30). + state_fips: list = field( + default_factory=lambda: ["16", "53", "41", "32", "49", "56", "30"]) + # Fine region gate (optional): SAME county codes (6-digit, e.g. "016021"). + # Empty = accept every alert within the state_fips states. + same_codes: list = field(default_factory=list) + # Drop NWS/NOAA-originated CAP so we never double-broadcast weather. + exclude_weather: bool = True + # Sender substrings (case-insensitive) that mark a CAP as weather-sourced. + drop_senders: list = field( + default_factory=lambda: ["noaa.gov", "nws", "weather.gov"]) + # Only broadcast status=Actual (skip Test/Exercise/System) when True. + status_actual_only: bool = True + + @dataclass class CentralConsumerConfig: """Connection settings for the Central NATS JetStream consumer (v0.4). @@ -596,6 +638,7 @@ class EnvironmentalConfig: wzdx: WZDxConfig = field(default_factory=WZDxConfig) firms: FIRMSConfig = field(default_factory=FIRMSConfig) satpass: SatpassConfig = field(default_factory=SatpassConfig) + ipaws: IPAWSConfig = field(default_factory=IPAWSConfig) central: CentralConsumerConfig = field(default_factory=CentralConsumerConfig) geocoder: GeocoderConfig = field(default_factory=GeocoderConfig) @@ -1294,6 +1337,8 @@ def _dict_to_dataclass(cls, data: dict): kwargs[key] = _dict_to_dataclass(FIRMSConfig, value) elif key == "satpass" and isinstance(value, dict): kwargs[key] = _dict_to_dataclass(SatpassConfig, value) + elif key == "ipaws" and isinstance(value, dict): + kwargs[key] = _dict_to_dataclass(IPAWSConfig, value) elif key == "environmental" and isinstance(value, dict): kwargs[key] = _dict_to_dataclass(EnvironmentalConfig, value) elif key == "dashboard" and isinstance(value, dict): diff --git a/work/meshai/coverage.py b/work/meshai/coverage.py index 74693ad..1ad93ce 100644 --- a/work/meshai/coverage.py +++ b/work/meshai/coverage.py @@ -237,6 +237,34 @@ def states_for_bbox(bbox) -> list[str]: return sorted(set(result)) +# USPS 2-letter -> 2-digit state FIPS. Covers the same keys as US_STATE_BBOXES +# (50 states + DC + territories) so states_for_bbox output maps cleanly to the +# statefips tokens the IPAWS Atom feed uses. +STATE_ABBR_TO_FIPS: dict[str, str] = { + "AL": "01", "AK": "02", "AZ": "04", "AR": "05", "CA": "06", "CO": "08", + "CT": "09", "DE": "10", "DC": "11", "FL": "12", "GA": "13", "HI": "15", + "ID": "16", "IL": "17", "IN": "18", "IA": "19", "KS": "20", "KY": "21", + "LA": "22", "ME": "23", "MD": "24", "MA": "25", "MI": "26", "MN": "27", + "MS": "28", "MO": "29", "MT": "30", "NE": "31", "NV": "32", "NH": "33", + "NJ": "34", "NM": "35", "NY": "36", "NC": "37", "ND": "38", "OH": "39", + "OK": "40", "OR": "41", "PA": "42", "RI": "44", "SC": "45", "SD": "46", + "TN": "47", "TX": "48", "UT": "49", "VT": "50", "VA": "51", "WA": "53", + "WV": "54", "WI": "55", "WY": "56", "AS": "60", "GU": "66", "MP": "69", + "PR": "72", "VI": "78", +} + + +def state_fips_for_bbox(bbox) -> list[str]: + """Return sorted 2-digit state FIPS codes whose bbox intersects the coverage + bbox (states_for_bbox mapped through STATE_ABBR_TO_FIPS). Used by the IPAWS + adapter's coarse region gate.""" + return sorted( + STATE_ABBR_TO_FIPS[c] + for c in states_for_bbox(bbox) + if c in STATE_ABBR_TO_FIPS + ) + + # --------------------------------------------------------------------------- # Avalanche center bbox table # --------------------------------------------------------------------------- @@ -372,6 +400,12 @@ def resolve_adapter_coverage( "bbox": bbox, } + if adapter == "ipaws": + return { + "state_fips": state_fips_for_bbox(bbox), + "bbox": bbox, + } + if adapter in ("usgs_quake", "firms", "roads511", "usgs"): return {"bbox": bbox} diff --git a/work/meshai/env/ipaws.py b/work/meshai/env/ipaws.py new file mode 100644 index 0000000..f607bc8 --- /dev/null +++ b/work/meshai/env/ipaws.py @@ -0,0 +1,485 @@ +"""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 carries a statefips and a + to the full CAP document. + Stage 2 GET {base_url}/eas/ -> full CAP 1.2 , 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 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.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 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 is an ABSOLUTE FEMA URL; we extract the trailing + ``eas/`` and rebuild ``{base_url}/eas/`` so the fetch routes + through whatever base_url points at (direct FEMA or the Conduit proxy). + Returns None if the href carries no ``eas/`` segment. + """ + if not link_href: + return None + marker = "/eas/" + idx = link_href.rfind(marker) + if idx == -1: + # Some feeds use a bare "eas/" without a leading slash. + if link_href.startswith("eas/"): + return f"{self._base_url}/{link_href}" + return None + tail = link_href[idx + 1:] # "eas/" + 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 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 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 + + # 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 (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 (or None).""" + if not geometry or geometry.get("type") != "Polygon": + return None + coords = geometry.get("coordinates") or [] + 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, + } diff --git a/work/meshai/env/nws.py b/work/meshai/env/nws.py index 17acb1b..6d3aa63 100644 --- a/work/meshai/env/nws.py +++ b/work/meshai/env/nws.py @@ -25,6 +25,24 @@ def _cfg_str(config, attr: str, default: str) -> str: return value if isinstance(value, str) and value else default +def map_cap_severity(cap_severity: str) -> str: + """Map a CAP severity string to meshai's 3-level system. + + Extreme -> immediate; Severe/Warning -> priority; everything else + (Moderate, Minor, Unknown) -> routine. Case-insensitive. + + Shared by the NWS adapter (``NWSAlertsAdapter._map_nws_severity``) and the + IPAWS civil-alert adapter (``env/ipaws.py``) so both map CAP severity + identically — do NOT reimplement this mapping elsewhere. + """ + sev = (cap_severity or "").lower() + if sev == "extreme": + return "immediate" + if sev in ("severe", "warning"): + return "priority" + return "routine" + + class NWSAlertsAdapter: """NWS Active Alerts -- polls api.weather.gov""" @@ -57,13 +75,13 @@ class NWSAlertsAdapter: def _map_nws_severity(self, nws_severity: str) -> str: - """Map NWS severity to 3-level system.""" - if nws_severity == "extreme": - return "immediate" - elif nws_severity in ("severe", "warning"): - return "priority" - else: # moderate, minor, unknown - return "routine" + """Map NWS severity to 3-level system. + + Thin wrapper over the shared module-level ``map_cap_severity`` (kept as + a method for the existing call sites/tests; behaviour unchanged). The + NWS adapter already lower-cases severity before calling this. + """ + return map_cap_severity(nws_severity) def _derive_category(self, event_type: str) -> str: """Derive notification category from NWS event type suffix. diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py index ffa4bd5..06109a1 100644 --- a/work/meshai/env/store.py +++ b/work/meshai/env/store.py @@ -234,6 +234,8 @@ class EnvironmentalStore: lambda cfg: (cfg, self._coverage_for("roads511"))), ("wzdx", "wzdx", ".wzdx", "WZDxAdapter", lambda cfg: (cfg, self._coverage_for("wzdx"))), + ("ipaws", "ipaws", ".ipaws", "IPAWSAlertsAdapter", + lambda cfg: (cfg, self._coverage_for("ipaws"))), # Native satpass TLE fetcher (storage-only: populates sat_tles, # emits no events). Gated on satpass.feed_source=="native" like # the rest. diff --git a/work/meshai/notifications/categories.py b/work/meshai/notifications/categories.py index c7d9cd9..6bf9d72 100644 --- a/work/meshai/notifications/categories.py +++ b/work/meshai/notifications/categories.py @@ -33,6 +33,11 @@ VALID_TOGGLES = frozenset({ "seismic", "tracking", "satpass", + # Civil / public-safety alerts from FEMA IPAWS-OPEN (env/ipaws.py): + # evacuation orders, Civil Emergency Messages, AMBER, 911 outages, + # law-enforcement warnings, HazMat. NON-weather (weather stays on the + # `weather` toggle via the nws adapter). + "emergency", }) @@ -116,6 +121,8 @@ _TOGGLE_PREFIX_FALLBACK = [ ("rf_", "rf_propagation"), ("avalanche", "avalanche"), ("sat", "satpass"), + # IPAWS civil-alert categories all share the emergency_ prefix. + ("emergency", "emergency"), ] @@ -597,6 +604,52 @@ ALERT_CATEGORIES = { "example_message": "🛰️ ISS Pass — 75° max\nAOS 19:32 · LOS 19:38\nBoise · NW→SE", "toggle": "satpass", }, + + # Civil / public-safety alerts (FEMA IPAWS-OPEN EAS, env/ipaws.py). + # NON-weather only: weather CAP is dropped by the adapter's weather-sender + # gate and stays on the `weather` toggle via the nws adapter. + "emergency_evacuation": { + "name": "Evacuation Order", + "description": "IPAWS evacuation order/warning (SAME EVI/EVA) — leave the area now or prepare to.", + "default_severity": "immediate", + "example_message": "🚨 Evacuation Immediate: Blaine County — leave now via ID-75 north. Wildfire threat to structures.", + "toggle": "emergency", + }, + "emergency_civil": { + "name": "Civil Emergency", + "description": "IPAWS Civil Emergency Message / Civil Danger Warning (SAME CEM/CDW) — significant in-progress or imminent threat to safety.", + "default_severity": "priority", + "example_message": "⚠️ Civil Emergency: Ada County — boil-water order in effect for the city of Kuna until further notice.", + "toggle": "emergency", + }, + "emergency_amber": { + "name": "AMBER Alert", + "description": "IPAWS Child Abduction Emergency / AMBER alert (SAME CAE/LAE).", + "default_severity": "immediate", + "example_message": "🚨 AMBER Alert: Canyon County — silver Honda Civic, plate 1A-23456. Call 911 with sightings.", + "toggle": "emergency", + }, + "emergency_law": { + "name": "Law Enforcement / Shelter-in-Place", + "description": "IPAWS Law Enforcement Warning or Shelter in Place Warning (SAME LEW/SPW).", + "default_severity": "priority", + "example_message": "⚠️ Shelter in Place: Twin Falls — active police incident near Blue Lakes Blvd. Stay indoors, lock doors.", + "toggle": "emergency", + }, + "emergency_911_outage": { + "name": "911 Outage", + "description": "IPAWS 911 Telephone Outage Emergency (SAME TOE) — 911 unreachable; use the published alternate number.", + "default_severity": "priority", + "example_message": "⚠️ 911 Outage: Jerome County — 911 down. For emergencies call (208) 555-0111 until restored.", + "toggle": "emergency", + }, + "emergency_hazmat": { + "name": "Hazardous Materials", + "description": "IPAWS Hazardous Materials / Radiological / Nuclear / Fire Warning (SAME HMW/RHW/NUW/FRW).", + "default_severity": "immediate", + "example_message": "🚨 HazMat Warning: Nampa — chlorine leak near rail yard. Evacuate 1-mile radius, avoid downwind areas.", + "toggle": "emergency", + }, } diff --git a/work/meshai/notifications/formatters/__init__.py b/work/meshai/notifications/formatters/__init__.py index 75810ab..a2bfb87 100644 --- a/work/meshai/notifications/formatters/__init__.py +++ b/work/meshai/notifications/formatters/__init__.py @@ -129,3 +129,13 @@ register("wildfire_growth", _fire_fmt_mod.format) from meshai.notifications.formatters import firms as _firms_fmt_mod # noqa: E402,F401 register("wildfire_spotting", _firms_fmt_mod.format) register("wildfire_halted", _firms_fmt_mod.format) + +# IPAWS civil alerts (env/ipaws.py). One headline-forward formatter handles all +# six emergency_* categories; event.category selects only the leading emoji. +from meshai.notifications.formatters import ipaws as _ipaws_fmt_mod # noqa: E402,F401 +register("emergency_evacuation", _ipaws_fmt_mod.format) +register("emergency_civil", _ipaws_fmt_mod.format) +register("emergency_amber", _ipaws_fmt_mod.format) +register("emergency_law", _ipaws_fmt_mod.format) +register("emergency_911_outage", _ipaws_fmt_mod.format) +register("emergency_hazmat", _ipaws_fmt_mod.format) diff --git a/work/meshai/notifications/formatters/ipaws.py b/work/meshai/notifications/formatters/ipaws.py new file mode 100644 index 0000000..91a695d --- /dev/null +++ b/work/meshai/notifications/formatters/ipaws.py @@ -0,0 +1,86 @@ +"""IPAWS civil-alert formatter. + +Renders NON-weather civil emergency alerts (evacuation, Civil Emergency +Message, AMBER, 911 outage, law enforcement, HazMat) from the canonical CAP +``event.data`` dict the IPAWS adapter emits (same shape as the NWS path). + +Civil alerts carry their signal in the CAP ``headline`` (a plain human +sentence), so — unlike the weather formatter, which parses structured +HAZARD.../IMPACT... blocks — this formatter is headline-forward: + + Line 1: {emoji} {prefix}{event} e.g. "🚨 Evacuation Immediate" + Line 2: {area}[ · Until {t} {tz}] areaDesc (first area) + expiry + Line 3: {headline} the operator's message + +Reuses ``event.data`` (canonical) + ``_budget.fit_to_budget``; does NOT touch +the NWS formatter. ``now`` is a structural seam (not used — expiry is absolute). +""" +from __future__ import annotations + +import zoneinfo +from datetime import datetime +from typing import TYPE_CHECKING + +from meshai.notifications.formatters._budget import fit_to_budget + +if TYPE_CHECKING: + from meshai.notifications.events import Event + + +# Category → leading emoji. Immediate-severity civil hazards get 🚨; the rest ⚠️. +_CATEGORY_EMOJI = { + "emergency_evacuation": "🚨", + "emergency_amber": "🚨", + "emergency_hazmat": "🚨", + "emergency_911_outage": "⚠️", + "emergency_law": "⚠️", + "emergency_civil": "⚠️", +} + + +def format(event: "Event", *, now: float, budget: int) -> str: + """Render the IPAWS civil-alert wire string from canonical event.data. + + Args: + event: Pipeline Event — reads event.data (canonical CAP schema). + now: Frozen-clock epoch (structural seam; expiry is absolute). + budget: Mesh-packet character budget. + + Returns: + UTF-8 string fitting within *budget* characters. + """ + d = event.data or {} + + event_type = d.get("event") or "Emergency Alert" + area_desc = d.get("area_desc") or "" + headline = (d.get("headline") or "").strip() + expires_epoch = d.get("expires_at") + prefix = d.get("_ipaws_prefix") or "" + category = d.get("category") or event.category or "emergency_civil" + + emoji = _CATEGORY_EMOJI.get(category, "⚠️") + prefix_seg = f"{prefix}: " if prefix else "" + + # Line 1: emoji + prefix + event type + line1 = f"{emoji} {prefix_seg}{event_type}" + + # Line 2: first area + optional expiry ("Until 4:54 PM MDT") + area = (area_desc or "").split(";")[0].strip() + if len(area) > 60: + cut = area[:60].rsplit(" ", 1)[0] or area[:60] + area = cut + "…" + time_seg = "" + if expires_epoch: + tz = zoneinfo.ZoneInfo("America/Boise") + exp_local = datetime.fromtimestamp(expires_epoch, tz=tz) + time_seg = f"Until {exp_local.strftime('%-I:%M %p %Z')}" + if area and time_seg: + line2 = f"{area} · {time_seg}" + else: + line2 = area or time_seg + + # Line 3: the headline (the actual civil message) + line3 = headline + + msg = "\n".join(ln for ln in (line1, line2, line3) if ln) + return fit_to_budget(msg, budget) diff --git a/work/meshai/notifications/gating/__init__.py b/work/meshai/notifications/gating/__init__.py index 4c938fc..550e5af 100644 --- a/work/meshai/notifications/gating/__init__.py +++ b/work/meshai/notifications/gating/__init__.py @@ -119,3 +119,13 @@ from meshai.notifications.gating import firms as _firms_gate_mod # noqa: E402,F register("wildfire_growth", _firms_gate_mod.decide) register("wildfire_spotting", _firms_gate_mod.decide) register("wildfire_halted", _firms_gate_mod.decide) + +# IPAWS civil alerts (env/ipaws.py). Own dedup table (ipaws_alerts) — mirrors +# the NWS decider's first-sighting / Update / dedup-window / tombstone logic. +from meshai.notifications.gating import ipaws as _ipaws_gate_mod # noqa: E402,F401 +register("emergency_evacuation", _ipaws_gate_mod.decide) +register("emergency_civil", _ipaws_gate_mod.decide) +register("emergency_amber", _ipaws_gate_mod.decide) +register("emergency_law", _ipaws_gate_mod.decide) +register("emergency_911_outage", _ipaws_gate_mod.decide) +register("emergency_hazmat", _ipaws_gate_mod.decide) diff --git a/work/meshai/notifications/gating/ipaws.py b/work/meshai/notifications/gating/ipaws.py new file mode 100644 index 0000000..674c34c --- /dev/null +++ b/work/meshai/notifications/gating/ipaws.py @@ -0,0 +1,170 @@ +"""IPAWS civil-alert gating decider. + +Mirrors the NWS decider (gating/nws.py) EXACTLY in shape, but keys on the +IPAWS-owned ``ipaws_alerts`` table (NOT ``nws_alerts``) so IPAWS and NWS CAP +lifecycles never collide: + + - Tombstone: msgType in {Cancel, Expire} -> suppress + - First-sighting (ipaws_alerts row is None): broadcast, prefix=""/"Update" + - Cold-start race (row exists, last_broadcast_at IS NULL): broadcast + - Dedup-window re-broadcast (>= duplicate_allowed_after_seconds): + broadcast, prefix="Active" + - Within dedup window: suppress + +decide(data, *, source, now) -> GateResult + + Canonical data schema consumed: + cap_id, msgType, references, event, area_desc, geocoder, + cap_severity, expires_at, description, category, headline + + Emitted data_patch keys: + _ipaws_prefix str — "", "Update", or "Active" + + commit(now: float) -> None: + Idempotent UPDATE of last_broadcast_at + first_broadcast_at. +""" +from __future__ import annotations + +import logging + +from meshai.adapter_config import adapter_config +from meshai.notifications.gating.base import GateResult +from meshai.persistence import get_db + +logger = logging.getLogger(__name__) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _is_update(conn, references: list) -> bool: + """True if any CAP id in `references` was previously broadcast (Update).""" + if not references: + return False + ref_ids = [r["identifier"] for r in references + if isinstance(r, dict) and r.get("identifier")] + if not ref_ids: + return False + placeholders = ",".join("?" * len(ref_ids)) + row = conn.execute( + f"SELECT 1 FROM ipaws_alerts WHERE event_id IN ({placeholders}) " + "AND last_broadcast_at IS NOT NULL LIMIT 1", + ref_ids, + ).fetchone() + return row is not None + + +def _make_commit(cap_id: str): + """Return an idempotent commit closure that arms last_broadcast_at.""" + def _commit(committed_at: float) -> None: + try: + c = get_db() + c.execute( + "UPDATE ipaws_alerts SET last_broadcast_at=?, " + "first_broadcast_at=COALESCE(first_broadcast_at, ?) " + "WHERE event_id=?", + (int(committed_at), int(committed_at), cap_id), + ) + except Exception: + logger.exception("ipaws commit: persistence update failed for %s", cap_id) + return _commit + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def decide(data: dict, *, source: str, now: float) -> GateResult: + """Gate + first-sighting decision for IPAWS civil alerts (ipaws_alerts table).""" + cap_id = data.get("cap_id") + if not cap_id: + return GateResult( + broadcast=False, lifecycle="suppress", + reason="no cap_id in canonical data", + ) + + msg_type = data.get("msgType") or "" + references = data.get("references") or [] + expires_at = data.get("expires_at") + area_desc = data.get("area_desc") or "" + cap_severity = data.get("cap_severity") or "" + event_type = data.get("event") or "" + geocoder = data.get("geocoder") or {} + county = geocoder.get("county") or area_desc + state = geocoder.get("state") or "" + description = data.get("description") or "" + headline = data.get("headline") or "" + + # ── Tombstone: Cancel/Expire → suppress ─────────────────────────────────── + try: + tombstone_types = set(adapter_config.ipaws.tombstone_msgtypes) + except Exception: + tombstone_types = {"Cancel", "Expire"} + if msg_type in tombstone_types: + return GateResult( + broadcast=False, lifecycle="tombstone", + reason=f"msgType={msg_type!r} is a tombstone", + ) + + # ── Persistence ─────────────────────────────────────────────────────────── + try: + conn = get_db() + except Exception: + logger.exception("ipaws decide: persistence unavailable") + return GateResult( + broadcast=False, lifecycle="suppress", + reason="persistence unavailable", + ) + + row = conn.execute( + "SELECT last_broadcast_at FROM ipaws_alerts WHERE event_id=?", + (cap_id,), + ).fetchone() + + # ── First sighting ──────────────────────────────────────────────────────── + if row is None: + _prefix = "Update" if _is_update(conn, references) else "" + conn.execute( + "INSERT INTO ipaws_alerts(event_id, alert_type, severity, county, " + "state, headline, description, expires_at, first_seen_at, " + "last_broadcast_at) VALUES (?,?,?,?,?,?,?,?,?,?)", + (cap_id, event_type, cap_severity, county, state, + headline, description, + int(expires_at) if expires_at is not None else None, + int(now), None), + ) + return GateResult( + broadcast=True, + lifecycle="new", + reason=f"first sighting cap_id={cap_id}", + data_patch={"_ipaws_prefix": _prefix}, + commit=_make_commit(cap_id), + ) + + # ── Cold-start race: row exists but broadcast was previously dropped ─────── + if row["last_broadcast_at"] is None: + _prefix = "Update" if _is_update(conn, references) else "" + return GateResult( + broadcast=True, + lifecycle="cold_start", + reason=f"cold-start race cap_id={cap_id}", + data_patch={"_ipaws_prefix": _prefix}, + commit=_make_commit(cap_id), + ) + + # ── Dedup-window check ──────────────────────────────────────────────────── + last_bcast = float(row["last_broadcast_at"]) + try: + window_s = int(adapter_config.ipaws.duplicate_allowed_after_seconds) + except Exception: + window_s = 10800 # 3 hours default + if window_s > 0 and (now - last_bcast) >= window_s: + return GateResult( + broadcast=True, + lifecycle="rebroadcast", + reason=f"dedup window expired ({window_s}s) for cap_id={cap_id}", + data_patch={"_ipaws_prefix": "Active"}, + commit=_make_commit(cap_id), + ) + + return GateResult( + broadcast=False, lifecycle="suppress", + reason=f"within {window_s}s dedup window for cap_id={cap_id}", + ) diff --git a/work/meshai/persistence/migrations/v29.sql b/work/meshai/persistence/migrations/v29.sql new file mode 100644 index 0000000..f3129ab --- /dev/null +++ b/work/meshai/persistence/migrations/v29.sql @@ -0,0 +1,31 @@ +-- v29 IPAWS civil-alert dedup table (ipaws adapter). +-- +-- The IPAWS-OPEN EAS adapter (env/ipaws.py) broadcasts NON-weather civil +-- alerts (evacuation, Civil Emergency Message, AMBER, 911 outage, law +-- enforcement, HazMat). Its gating decider (notifications/gating/ipaws.py) +-- mirrors the NWS decider's first-sighting / Update / dedup-window / +-- tombstone logic but MUST NOT share the NWS nws_alerts table -- an IPAWS CAP +-- id and an NWS CAP id could otherwise collide, and their lifecycles are +-- independent. This is the IPAWS-owned equivalent of nws_alerts (v1.sql), +-- same column shape so the decider code is a near-verbatim parametrisation. +-- +-- PK is the CAP (urn-style), falling back to a synthetic +-- ipaws:: key when a document omits one. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ipaws_alerts ( + event_id TEXT PRIMARY KEY, + alert_type TEXT, + severity TEXT, + county TEXT, + state TEXT, + headline TEXT, + description TEXT, + expires_at INTEGER, + first_seen_at INTEGER NOT NULL, + last_broadcast_at INTEGER, + -- first_broadcast_at was added to nws_alerts et al. in v11; the ipaws + -- decider's commit COALESCEs it, so this fresh table carries it inline. + first_broadcast_at REAL +); +CREATE INDEX IF NOT EXISTS idx_ipaws_expires ON ipaws_alerts(expires_at); +CREATE INDEX IF NOT EXISTS idx_ipaws_state_severity ON ipaws_alerts(state, severity); diff --git a/work/tests/fixtures/ipaws/eas_idaho_cem.xml b/work/tests/fixtures/ipaws/eas_idaho_cem.xml new file mode 100644 index 0000000..f979608 --- /dev/null +++ b/work/tests/fixtures/ipaws/eas_idaho_cem.xml @@ -0,0 +1 @@ +AS-ID-de78a02d-8bf9-4528-8f84-fd417bbcaa5bshelby.blackburn@dhw.idaho.gov2026-07-15T22:54:41-06:00ActualUpdateAlertSensePublicIPAWSv1.0shelby.blackburn@dhw.idaho.gov,AS-ID-f99c29ec-6485-4946-bad1-f4c679204f91,2026-07-15T22:18:49-06:00en-USSafetyCivil Emergency MessageImmediateExtremeObservedSAMECEM2026-07-15T22:54:41-06:002026-07-16T02:54:41-06:00200033, Idaho State Communications Center, Idaho Office of Emergency ManagementWildfire Immediate Evacuation AlertThe Boundary County Sheriff's Office has issued an immediate wildfire evacuation order for the following areas in Bonners Ferry.: Turner's Hill Road from the 3000 block to highway 95. All Holmes Rd. Addresses. All Kings row road addresses and any side roads. All Fleming Creek Road addresses and any side roads. Contact Boundary County Sheriff's Office for further information.EAS-ORGCIVBLOCKCHANNELCMAStimezoneMDTEAS Broadcast Contentaudio/x-ipaws-audio-mp3https://storage-tts.alertsense.com/tts-generated-audio/2d7fa5dd3d901a593adc727326fd79cb.mp3Boundary County48.9192,-116.4488 48.9210,-116.3039 48.8190,-116.2298 48.6971,-116.2799 48.7035,-116.4324 48.7429,-116.4632 48.8944,-116.4557 48.9192,-116.4488SAME016021zLQmJ/Mz8ncm8/nwX/ddeaP9AyfzUykvg79CH0UJk2I=J4ZCQXqPC5naEVREtr/N0unNK9vvg2bQk/D/CdCB5UFWaPZAfEDkKaxPBlk7hl6xxHL2874bmYdRzk219WX9zOr+C9i42WSfchcLsx9p8QCx/3jE/pN7IBBfMzmGhNWaUzzMjh4OuPMso5MSFKLoO0SYpBN/vhUt0OWIyHy4HZ41g8cbWhNKxFVSyZUTm9CMbsMFqz9euQEqMhdhuwQhwlEWKhLAS7FvuHkrishUcWfuYdOoDJMR6zleeKZPB/hideSVQqUDOKutuE0T5wNbmtkJzeBgxVBGcOmKz89/jjM4OLC/UteJ3tp8ELEHTd2HfeY94uAlOB1kxEKjlo8sqw==CN=IPAWSOPEN200033, OU=A01410C0000019A0BA535660010EA64, OU=Devices IPAWS, OU=National Continuity Programs, O=FEMA IPAWS, C=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 \ No newline at end of file diff --git a/work/tests/fixtures/ipaws/eas_oregon_evi.xml b/work/tests/fixtures/ipaws/eas_oregon_evi.xml new file mode 100644 index 0000000..9f6aa20 --- /dev/null +++ b/work/tests/fixtures/ipaws/eas_oregon_evi.xml @@ -0,0 +1 @@ +2625772816759627200545_Jackson_Alerts_Jackson_County_OR2026-07-15T19:50:31-07:00ActualAlertJackson CountyPublicIPAWSv1.0en-USSafetyEvacuation ImmediateImmediateExtremeObservedSAMEEVI2026-07-16T07:50:31-07:00200545,Jackson Alerts,Jackson County ORJackson County Sheriff's OfficeJackson County Sheriff's Office- Level 3 GO NOW evacuation notice UPGRADED for JAC-126 near the area of Evans Creek Rd and W Fork Trail Creek Rd due to Evans Creek Road Fire. Continue to monitor official news sources for evacuation and incident information.BLOCKCHANNELCAPEXCHBLOCKCHANNELNWEMEAS-ORGCIVCMAMtextWildfire Alert- Level 3 GO NOW evacuation notice is UPGRADED for JAC-126CMAMlongtextWildfire Alert from Jackson County Sheriff's Office- Level 3 GO NOW evacuation notice UPGRADED for JAC-126 near the area of Evans Creek Rd and W Fork Trail Creek Rd. Continue to monitor official news sources for evacuation and incident information.WEAHandlingImminent ThreattimezonePSTJackson County42.6683,-122.9082 42.6707,-122.907 42.672,-122.9058 42.6727,-122.9048 42.6732,-122.9049 42.674,-122.9045 42.675,-122.9046 42.6754,-122.9044 42.6761,-122.9035 42.6767,-122.9032 42.677,-122.9021 42.6772,-122.9018 42.6776,-122.9015 42.6788,-122.9013 42.6791,-122.9009 42.6796,-122.8996 42.6805,-122.899 42.6811,-122.8976 42.6818,-122.8966 42.6821,-122.8959 42.6824,-122.8948 42.6823,-122.8932 42.6824,-122.8927 42.6835,-122.8905 42.6834,-122.8894 42.684,-122.8889 42.684,-122.8878 42.6845,-122.8869 42.6843,-122.8862 42.685,-122.8849 42.6852,-122.8832 42.6856,-122.8829 42.6864,-122.8816 42.6875,-122.8806 42.688,-122.8794 42.6881,-122.8793 42.6892,-122.8794 42.69,-122.8789 42.6908,-122.8775 42.6912,-122.876 42.6918,-122.8748 42.6925,-122.8739 42.6928,-122.8742 42.6934,-122.8743 42.6938,-122.8749 42.6948,-122.8746 42.6953,-122.8748 42.6963,-122.8738 42.6983,-122.8712 42.6984,-122.8682 42.6998,-122.8656 42.7,-122.8648 42.6979,-122.8623 42.6968,-122.8589 42.6941,-122.8577 42.6941,-122.8557 42.6859,-122.8518 42.6831,-122.849 42.6831,-122.8457 42.6653,-122.8462 42.6653,-122.8363 42.6617,-122.8364 42.6617,-122.8314 42.6544,-122.8315 42.6544,-122.8266 42.6508,-122.8267 42.6508,-122.8316 42.6478,-122.8315 42.6475,-122.8336 42.6465,-122.8356 42.6452,-122.8378 42.6448,-122.8419 42.639,-122.8425 42.6368,-122.8415 42.6363,-122.84 42.6363,-122.8758 42.6381,-122.8863 42.6403,-122.8927 42.6403,-122.8998 42.6413,-122.906 42.6493,-122.9012 42.6533,-122.9024 42.6608,-122.9097 42.667,-122.9104 42.6683,-122.9082SAME041029es-USSafetyEvacuation ImmediateImmediateExtremeObservedSAMEEVI2026-07-16T07:50:31-07:00200545,Jackson Alerts,Jackson County ORJackson County Sheriff's OfficeJackson County Sheriff's Office- Level 3 GO NOW evacuation notice UPGRADED for JAC-126 near the area of Evans Creek Rd and W Fork Trail Creek Rd due to Evans Creek Road Fire. Continue to monitor official news sources for evacuation and incident information.BLOCKCHANNELCAPEXCHBLOCKCHANNELNWEMEAS-ORGCIVCMAMtextVAYASE AHORA Evacue JAC-126 near the area of Evans Creek Rd and W Fork Trail CreeCMAMlongtextAlerta de Incendio Forestal de Jackson County Sheriff's Office. Nivel 3 - VAYASE AHORA - Evacue inmediatamente de JAC-126 near the area of Evans Creek Rd and W Fork Trail Creek RdWEAHandlingImminent ThreattimezonePSTJackson County42.6683,-122.9082 42.6707,-122.907 42.672,-122.9058 42.6727,-122.9048 42.6732,-122.9049 42.674,-122.9045 42.675,-122.9046 42.6754,-122.9044 42.6761,-122.9035 42.6767,-122.9032 42.677,-122.9021 42.6772,-122.9018 42.6776,-122.9015 42.6788,-122.9013 42.6791,-122.9009 42.6796,-122.8996 42.6805,-122.899 42.6811,-122.8976 42.6818,-122.8966 42.6821,-122.8959 42.6824,-122.8948 42.6823,-122.8932 42.6824,-122.8927 42.6835,-122.8905 42.6834,-122.8894 42.684,-122.8889 42.684,-122.8878 42.6845,-122.8869 42.6843,-122.8862 42.685,-122.8849 42.6852,-122.8832 42.6856,-122.8829 42.6864,-122.8816 42.6875,-122.8806 42.688,-122.8794 42.6881,-122.8793 42.6892,-122.8794 42.69,-122.8789 42.6908,-122.8775 42.6912,-122.876 42.6918,-122.8748 42.6925,-122.8739 42.6928,-122.8742 42.6934,-122.8743 42.6938,-122.8749 42.6948,-122.8746 42.6953,-122.8748 42.6963,-122.8738 42.6983,-122.8712 42.6984,-122.8682 42.6998,-122.8656 42.7,-122.8648 42.6979,-122.8623 42.6968,-122.8589 42.6941,-122.8577 42.6941,-122.8557 42.6859,-122.8518 42.6831,-122.849 42.6831,-122.8457 42.6653,-122.8462 42.6653,-122.8363 42.6617,-122.8364 42.6617,-122.8314 42.6544,-122.8315 42.6544,-122.8266 42.6508,-122.8267 42.6508,-122.8316 42.6478,-122.8315 42.6475,-122.8336 42.6465,-122.8356 42.6452,-122.8378 42.6448,-122.8419 42.639,-122.8425 42.6368,-122.8415 42.6363,-122.84 42.6363,-122.8758 42.6381,-122.8863 42.6403,-122.8927 42.6403,-122.8998 42.6413,-122.906 42.6493,-122.9012 42.6533,-122.9024 42.6608,-122.9097 42.667,-122.9104 42.6683,-122.9082SAME041029JjL4x3JmTRu+tRhxLzE7gHUHpi+Lm++6ShXZecn4BeE=dPtPh9fiS6H/KDF2tWV+n28nNtSRfCQYXE7aR76aoCKvn40bfR25V7kJkeC6eou9F5G5DLkBs91ca7ge6fD/537BuSHaE53OjMtyNcqondGwUaS7wfkPdhsiKxx7H/A0wYNU+ucJr5T4OSEgFIMnhe1rYWcr39f+uFRzJTfKxWMsRZEoPFuZAN7IKXQkeDjkDo9XySF8Ho0sI7rSV0CRm0cKVQHj+9W2ooxiSwhKCKrdBew3vQxbhzlWtkr1ZFyiUzswV0Qy/pwzakERKRFJfdix9xu+EPdfMGSFtAC9T72IZYxKVCmkxTST86hVw0AjqODDUFjE7SYdYK9hcZatmQ==CN=IPAWSOPEN200545,OU=A01410D000001997B92017A00065AEC,OU=Devices IPAWS,OU=National Continuity Programs,O=FEMA IPAWS,C=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 \ No newline at end of file diff --git a/work/tests/fixtures/ipaws/eas_weather_noaa.xml b/work/tests/fixtures/ipaws/eas_weather_noaa.xml new file mode 100644 index 0000000..d1a9771 --- /dev/null +++ b/work/tests/fixtures/ipaws/eas_weather_noaa.xml @@ -0,0 +1 @@ +NWS-IDAHO-SYNTH-0001w-nws.webmaster@noaa.gov2026-07-16T04:00:00-06:00ActualAlertPublicen-USMetSevere Thunderstorm WarningImmediateSevereObservedSAMESVR2026-07-16T05:00:00-06:00Severe Thunderstorm Warning for Ada CountyThe National Weather Service has issued a severe thunderstorm warning.Ada CountySAME016001 diff --git a/work/tests/fixtures/ipaws/feed.xml b/work/tests/fixtures/ipaws/feed.xml new file mode 100644 index 0000000..a53ac56 --- /dev/null +++ b/work/tests/fixtures/ipaws/feed.xml @@ -0,0 +1 @@ +IPAWS EAS FEED2026-07-16T05:09:57.311Zhttps://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/feedCEMhttps://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/eas/3001308595422026-07-16T04:54:44.000ZEVIhttps://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/eas/3001308567562026-07-16T03:00:00.000ZCEMhttps://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/eas/9999999999992026-07-16T02:00:00.000Z diff --git a/work/tests/test_adapter_config_foundation.py b/work/tests/test_adapter_config_foundation.py index 5800303..71ce557 100644 --- a/work/tests/test_adapter_config_foundation.py +++ b/work/tests/test_adapter_config_foundation.py @@ -153,7 +153,9 @@ def test_registry_has_no_duplicate_keys(): def test_adapter_meta_at_19(fresh_db): - assert len(ADAPTER_META) == 23 + # Count sentinel — bump when an adapter row is added. 23 -> 24 with the + # IPAWS civil-alert adapter (adapter_config/defaults.py ADAPTER_META["ipaws"]). + assert len(ADAPTER_META) == 24 # ---------- seed ---------------------------------------------------------- diff --git a/work/tests/test_adapter_ipaws.py b/work/tests/test_adapter_ipaws.py new file mode 100644 index 0000000..891f060 --- /dev/null +++ b/work/tests/test_adapter_ipaws.py @@ -0,0 +1,388 @@ +"""Tests for the FEMA IPAWS-OPEN EAS civil-alert adapter (env/ipaws.py), +its gating decider (gating/ipaws.py) and formatter (formatters/ipaws.py). + +Covers: two-stage fetch + base_url rebuild, statefips coarse filter, en-US +filter, status filter, NWS/NOAA-sender weather exclusion, SAME same_codes fine +filter, severity mapping reuse, category derivation, polygon geometry, to_event +canonical dict, the ipaws_alerts dedup/Update/tombstone decider (its OWN table, +not nws_alerts), and a dry-run formatter render of a real Idaho alert. + +Fixtures under tests/fixtures/ipaws/ are REAL captured IPAWS documents (Idaho +CEM w/ polygon, Oregon EVI evacuation) plus a synthetic index + a synthetic +NWS/NOAA weather CAP for the exclusion test. +""" +from __future__ import annotations + +import pathlib +from unittest.mock import MagicMock, patch + +import pytest + +from meshai.config import IPAWSConfig +from meshai.env.ipaws import IPAWSAlertsAdapter, _norm_fips +from meshai.env.nws import map_cap_severity +from meshai.notifications.events import Event + +FIX = pathlib.Path(__file__).parent / "fixtures" / "ipaws" + + +def _fx(name: str) -> bytes: + return (FIX / name).read_bytes() + + +# URL suffix -> fixture bytes. Keyed by the trailing path so the same map works +# for direct FEMA and proxied (Conduit) base_urls. +_ROUTES = { + "/feed": "feed.xml", + "/eas/300130859542": "eas_idaho_cem.xml", + "/eas/300130856756": "eas_oregon_evi.xml", + "/eas/016weather": "eas_weather_noaa.xml", + # 999999999999 (CA / statefips 06) intentionally has NO route: it must be + # dropped by the coarse statefips gate BEFORE any stage-2 fetch. +} + + +class _FakeResp: + def __init__(self, data: bytes): + self._data = data + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def _make_urlopen(requested: list): + """Return a fake urlopen that records requested URLs and serves fixtures by + trailing-path suffix (raising for any URL not explicitly routed).""" + def _urlopen(req, timeout=None): + url = req.full_url + requested.append(url) + for suffix, fname in _ROUTES.items(): + if url.endswith(suffix): + return _FakeResp(_fx(fname)) + raise AssertionError(f"unexpected stage-2 fetch: {url}") + return _urlopen + + +def _config(**over) -> IPAWSConfig: + cfg = IPAWSConfig() + for k, v in over.items(): + setattr(cfg, k, v) + return cfg + + +# ============================================================ +# helpers / severity / fips +# ============================================================ + +def test_defaults_disabled_and_native(): + cfg = IPAWSConfig() + assert cfg.enabled is False # HARD CONSTRAINT: ships disabled + assert cfg.feed_source == "native" + assert cfg.exclude_weather is True + assert cfg.status_actual_only is True + assert "16" in cfg.state_fips # Idaho in default scope + + +def test_norm_fips(): + assert _norm_fips("16") == "16" + assert _norm_fips("6") == "06" + assert _norm_fips(6) == "06" + assert _norm_fips("41") == "41" + + +def test_severity_mapping_reuses_nws(): + assert map_cap_severity("Extreme") == "immediate" + assert map_cap_severity("Severe") == "priority" + assert map_cap_severity("Moderate") == "routine" + assert map_cap_severity("Minor") == "routine" + assert map_cap_severity("Unknown") == "routine" + + +# ============================================================ +# stage-2 URL rebuild (base_url honoured for BOTH stages) +# ============================================================ + +def test_stage2_url_rebuilds_from_absolute_fema_link(): + a = IPAWSAlertsAdapter(_config()) + abs_link = "https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/eas/300130859542" + assert a._stage2_url(abs_link) == ( + "https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/eas/300130859542" + ) + + +def test_stage2_url_routes_through_proxy_base_url(): + # In prod base_url points at the Conduit proxy; the absolute FEMA link in + # the feed must be rewritten to go through it. + a = IPAWSAlertsAdapter(_config(base_url="http://100.64.0.12:8010/up/ipaws")) + abs_link = "https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest/eas/300130859542" + assert a._stage2_url(abs_link) == "http://100.64.0.12:8010/up/ipaws/eas/300130859542" + + +def test_stage2_url_none_when_no_eas_segment(): + a = IPAWSAlertsAdapter(_config()) + assert a._stage2_url("https://apps.fema.gov/somethingelse") is None + assert a._stage2_url("") is None + + +# ============================================================ +# category derivation +# ============================================================ + +def test_derive_category_same_codes(): + a = IPAWSAlertsAdapter(_config()) + assert a._derive_category("EVI", "Evacuation Immediate") == "emergency_evacuation" + assert a._derive_category("CEM", "Civil Emergency Message") == "emergency_civil" + assert a._derive_category("CAE", "Child Abduction Emergency") == "emergency_amber" + assert a._derive_category("LEW", "Law Enforcement Warning") == "emergency_law" + assert a._derive_category("TOE", "911 Telephone Outage Emergency") == "emergency_911_outage" + assert a._derive_category("HMW", "Hazardous Materials Warning") == "emergency_hazmat" + + +def test_derive_category_keyword_fallback(): + a = IPAWSAlertsAdapter(_config()) + # Unknown SAME code -> keyword match on the event string. + assert a._derive_category("ZZZ", "Mandatory Evacuation Ordered") == "emergency_evacuation" + assert a._derive_category("", "AMBER Alert") == "emergency_amber" + assert a._derive_category("", "Something Unclassifiable") == "emergency_civil" + + +# ============================================================ +# polygon geometry +# ============================================================ + +def test_polygon_geometry_from_real_cem(): + a = IPAWSAlertsAdapter(_config()) + parsed = a._parse_cap(_fx("eas_idaho_cem.xml"), "16") + geom = parsed["geometry"] + assert geom is not None + assert geom["type"] == "Polygon" + ring = geom["coordinates"][0] + assert ring[0] == ring[-1] # closed ring + # GeoJSON order is [lon, lat]; Boundary County ID is ~ -116, +48. + assert ring[0][0] < -115 and ring[0][1] > 48 + + +# ============================================================ +# CAP parse / filters (via the private single-parse helper) +# ============================================================ + +def test_status_actual_only_drops_test(): + a = IPAWSAlertsAdapter(_config()) + xml = _fx("eas_idaho_cem.xml").replace(b"Actual", + b"Test") + assert a._parse_cap(xml, "16") is None + + +def test_weather_sender_excluded(): + a = IPAWSAlertsAdapter(_config()) + # NOAA-originated CAP must be dropped so we never double-broadcast weather. + assert a._parse_cap(_fx("eas_weather_noaa.xml"), "16") is None + + +def test_weather_sender_kept_when_exclude_off(): + a = IPAWSAlertsAdapter(_config(exclude_weather=False)) + parsed = a._parse_cap(_fx("eas_weather_noaa.xml"), "16") + assert parsed is not None + assert parsed["sender"].endswith("noaa.gov") + + +def test_same_codes_fine_filter(): + # same_codes gate: keep only alerts whose area SAME is in the list. + keep = IPAWSAlertsAdapter(_config(same_codes=["016021"])) + assert keep._parse_cap(_fx("eas_idaho_cem.xml"), "16") is not None + drop = IPAWSAlertsAdapter(_config(same_codes=["016099"])) + assert drop._parse_cap(_fx("eas_idaho_cem.xml"), "16") is None + + +def test_parse_cap_fields_idaho_cem(): + a = IPAWSAlertsAdapter(_config()) + p = a._parse_cap(_fx("eas_idaho_cem.xml"), "16") + assert p["source"] == "ipaws" + assert p["event_id"] == "AS-ID-de78a02d-8bf9-4528-8f84-fd417bbcaa5b" + assert p["same_code"] == "CEM" + assert p["category"] == "emergency_civil" + assert p["cap_severity"] == "Extreme" + assert p["msgType"] == "Update" + assert "016021" in p["area_same_codes"] + assert p["area_desc"] == "Boundary County" + assert "evacuation" in p["description"].lower() + + +# ============================================================ +# two-stage fetch (mocked HTTP) — coarse statefips gate +# ============================================================ + +def test_fetch_coarse_statefips_filter_and_base_url(): + requested = [] + a = IPAWSAlertsAdapter(_config()) # default state_fips includes 16 & 41, not 06 + with patch("meshai.env.ipaws.urlopen", _make_urlopen(requested)): + changed = a._fetch() + assert changed is True + events = a.get_events() + ids = {e["same_code"] for e in events} + assert ids == {"CEM", "EVI"} # Idaho CEM + Oregon EVI kept + # CA (statefips 06) was dropped BEFORE stage-2: its eas URL was never fetched. + assert not any("999999999999" in u for u in requested) + # stage-1 + two in-scope stage-2 fetches only. + assert sum(1 for u in requested if "/eas/" in u) == 2 + + +def test_fetch_routes_all_stages_through_proxy_base_url(): + requested = [] + a = IPAWSAlertsAdapter(_config(base_url="http://100.64.0.12:8010/up/ipaws")) + with patch("meshai.env.ipaws.urlopen", _make_urlopen(requested)): + a._fetch() + assert all(u.startswith("http://100.64.0.12:8010/up/ipaws") for u in requested) + + +def test_coverage_derived_state_fips_overrides_config(): + # A coverage dict (bbox->state_fips) narrows scope; here only Idaho (16), + # so the Oregon (41) EVI is dropped by the coarse gate. + requested = [] + a = IPAWSAlertsAdapter(_config(), coverage={"state_fips": ["16"]}) + with patch("meshai.env.ipaws.urlopen", _make_urlopen(requested)): + a._fetch() + assert {e["same_code"] for e in a.get_events()} == {"CEM"} + + +# ============================================================ +# to_event canonical dict +# ============================================================ + +def test_to_event_canonical_dict(): + a = IPAWSAlertsAdapter(_config()) + raw = a._parse_cap(_fx("eas_oregon_evi.xml"), "41") + ev = a.to_event(raw) + assert isinstance(ev, Event) + assert ev.source == "ipaws" + assert ev.category == "emergency_evacuation" + assert ev.severity == "immediate" # Extreme -> immediate + assert ev.group_key == raw["event_id"] + d = ev.data + assert d["cap_id"] == raw["event_id"] + assert d["same_code"] == "EVI" + assert d["category"] == "emergency_evacuation" + assert d["geometry"] is not None + assert d["geometry"]["type"] == "Polygon" + assert d["geocoder"]["county"] == d["area_desc"] + + +def test_health_status_shape(): + a = IPAWSAlertsAdapter(_config()) + hs = a.health_status + assert hs["source"] == "ipaws" + assert set(hs) >= {"is_loaded", "last_error", "consecutive_errors", + "event_count", "last_fetch"} + + +# ============================================================ +# gating decider — ipaws_alerts table (NOT nws_alerts) +# ============================================================ + +def _canonical(**over): + base = { + "cap_id": "IPAWS-TEST-1", + "msgType": "Alert", + "references": [], + "event": "Civil Emergency Message", + "area_desc": "Boundary County", + "geocoder": {"city": None, "county": "Boundary County", "state": None}, + "cap_severity": "Extreme", + "expires_at": 1_800_000_000, + "description": "test", + "category": "emergency_civil", + "headline": "Test civil alert", + } + base.update(over) + return base + + +def test_decider_first_sighting_broadcasts(_isolate_meshai_db): + from meshai.notifications.gating.ipaws import decide + r = decide(_canonical(), source="ipaws", now=1000.0) + assert r.broadcast is True + assert r.lifecycle == "new" + assert r.data_patch.get("_ipaws_prefix") == "" + + +def test_decider_dedup_window(_isolate_meshai_db): + from meshai.notifications.gating.ipaws import decide + r1 = decide(_canonical(), source="ipaws", now=1000.0) + r1.commit(1000.0) + # within window -> suppress + r2 = decide(_canonical(), source="ipaws", now=1000.0 + 60) + assert r2.broadcast is False + assert r2.lifecycle == "suppress" + # past the 3h dedup window -> re-broadcast with Active prefix + r3 = decide(_canonical(), source="ipaws", now=1000.0 + 10800 + 1) + assert r3.broadcast is True + assert r3.data_patch.get("_ipaws_prefix") == "Active" + + +def test_decider_tombstone_suppresses(_isolate_meshai_db): + from meshai.notifications.gating.ipaws import decide + for mt in ("Cancel", "Expire"): + r = decide(_canonical(msgType=mt), source="ipaws", now=1000.0) + assert r.broadcast is False + assert r.lifecycle == "tombstone" + + +def test_decider_uses_own_table_not_nws(_isolate_meshai_db): + from meshai.notifications.gating.ipaws import decide + from meshai.persistence import get_db + decide(_canonical(), source="ipaws", now=1000.0) + conn = get_db() + ipaws_n = conn.execute("SELECT COUNT(*) FROM ipaws_alerts").fetchone()[0] + nws_n = conn.execute("SELECT COUNT(*) FROM nws_alerts").fetchone()[0] + assert ipaws_n == 1 + assert nws_n == 0 + + +# ============================================================ +# formatter (dry-run render — NO transmit) +# ============================================================ + +def test_formatter_renders_civil_alert(): + from meshai.notifications.formatters.ipaws import format as ipaws_format + a = IPAWSAlertsAdapter(_config()) + raw = a._parse_cap(_fx("eas_idaho_cem.xml"), "16") + ev = a.to_event(raw) + ev.data["_ipaws_prefix"] = "" + wire = ipaws_format(ev, now=1000.0, budget=200) + assert len(wire) <= 200 + lines = wire.split("\n") + assert lines[0].startswith("⚠️") # civil -> warning emoji + assert "Civil Emergency Message" in lines[0] + assert "Boundary County" in wire + assert "evacuation" in wire.lower() # headline carried the signal + + +def test_formatter_evacuation_emoji(): + from meshai.notifications.formatters.ipaws import format as ipaws_format + a = IPAWSAlertsAdapter(_config()) + raw = a._parse_cap(_fx("eas_oregon_evi.xml"), "41") + ev = a.to_event(raw) + wire = ipaws_format(ev, now=1000.0, budget=200) + assert wire.startswith("🚨") # evacuation -> alarm emoji + assert "Evacuation Immediate" in wire + + +# ============================================================ +# coverage bbox -> state_fips +# ============================================================ + +def test_coverage_state_fips_for_bbox(): + from meshai.coverage import state_fips_for_bbox, resolve_adapter_coverage + # An Idaho bbox must resolve to include FIPS 16. + idaho_bbox = [-117.0, 42.0, -111.0, 49.0] + fips = state_fips_for_bbox(idaho_bbox) + assert "16" in fips + resolved = resolve_adapter_coverage("ipaws", idaho_bbox, "native") + assert "16" in resolved["state_fips"] + assert resolved["bbox"] == [round(c, 6) for c in idaho_bbox]