mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(dashboard): bring ipaws adapter + emergency family to full GUI parity
The IPAWS civil-alert adapter shipped backend-complete but frontend-partial: it rendered only in the generic adapter-config page and the Advanced (raw) Data Feeds tab, and the `emergency` family was absent from the curated Data Feeds panel, the family-settings toggles, and the MeshCore routing matrix. Adapter (ipaws), benchmarked against firms/nws: - Environment.tsx: add `ipaws` to AdapterKey union, EnvConfig interface, META (native-only, keyless), a new `emergency` FAMILIES group, PANEL_META_KEY (LLM toggle), and a hand-written renderSettings panel exposing base_url, user_agent, tick_seconds, state_fips, same_codes, exclude_weather, status_actual_only — with coverage-scope handling like the other adapters. - Environment.tsx: IPAWS_DEFAULT backfill so pre-ipaws GET payloads don't crash. - Dashboard.tsx: SOURCE_ICONS entry (Siren/IPAWS) so ipaws events aren't a slug. - ActivityLog.tsx: TABLE_LABELS + CATEGORIES + text-hint so ipaws_alerts rows show labeled "Emergency" and honor the category filter. - dispatcher.py: _SOURCE_TO_TABLE fallback ipaws -> ipaws_alerts so region-routed emergency sends land labeled in the audit feed (not NULL). Family (emergency), benchmarked against fire: - Notifications.tsx: add `emergency` to TOGGLE_FAMILY_META (Siren icon). This cascades to Family Settings, the Meshtastic delivery matrix, and the MeshCore routing matrix (the last was hardcoded to the static list and previously omitted emergency entirely). Backend VALID_TOGGLES/gating/categories were already complete — no backend family change needed. Tests: update the _SOURCE_TO_TABLE exact-match guard and add an ipaws audit-row parity test. Full suite 2407 passed / 6 pre-existing unrelated failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c04daa6e4d
commit
47d7ef7e78
8 changed files with 207 additions and 82 deletions
|
|
@ -52,6 +52,7 @@ const TABLE_LABELS: Record<string, string> = {
|
||||||
swpc_events: 'Space Wx',
|
swpc_events: 'Space Wx',
|
||||||
gauge_readings: 'Hydro',
|
gauge_readings: 'Hydro',
|
||||||
event_log: 'Avalanche',
|
event_log: 'Avalanche',
|
||||||
|
ipaws_alerts: 'Emergency',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text-prefix / emoji heuristics for legacy NULL-source rows.
|
// Text-prefix / emoji heuristics for legacy NULL-source rows.
|
||||||
|
|
@ -66,6 +67,7 @@ const TABLE_LABELS: Record<string, string> = {
|
||||||
// incident_handler.py (legacy) → "⚠️ Road Incident …" | "🚫 Road Closed …"
|
// incident_handler.py (legacy) → "⚠️ Road Incident …" | "🚫 Road Closed …"
|
||||||
// NOTE: more-specific prefixes must appear before shorter ones that share a leading char.
|
// NOTE: more-specific prefixes must appear before shorter ones that share a leading char.
|
||||||
const TEXT_HINTS: Array<[string, string]> = [
|
const TEXT_HINTS: Array<[string, string]> = [
|
||||||
|
['🚨', 'Emergency'], // ipaws.py immediate civil alerts (evac/AMBER/HazMat)
|
||||||
['🔥', 'Fire'],
|
['🔥', 'Fire'],
|
||||||
['🚧', 'Traffic'],
|
['🚧', 'Traffic'],
|
||||||
['⚠️ Road Incident', 'Traffic'], // legacy road-incident rows (⚠️ = U+26A0+FE0F)
|
['⚠️ Road Incident', 'Traffic'], // legacy road-incident rows (⚠️ = U+26A0+FE0F)
|
||||||
|
|
@ -115,6 +117,7 @@ const CATEGORIES = [
|
||||||
{ value: 'satpass_events', label: 'Satellite' },
|
{ value: 'satpass_events', label: 'Satellite' },
|
||||||
{ value: 'band_conditions_broadcasts', label: 'Band' },
|
{ value: 'band_conditions_broadcasts', label: 'Band' },
|
||||||
{ value: 'traffic_events', label: 'Traffic' },
|
{ value: 'traffic_events', label: 'Traffic' },
|
||||||
|
{ value: 'ipaws_alerts', label: 'Emergency' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PAGE = 100
|
const PAGE = 100
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
Construction,
|
Construction,
|
||||||
Satellite,
|
Satellite,
|
||||||
Sun,
|
Sun,
|
||||||
|
Siren,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -357,6 +358,7 @@ const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: s
|
||||||
usgs: { icon: Droplets, color: 'text-sky-400', label: 'USGS' },
|
usgs: { icon: Droplets, color: 'text-sky-400', label: 'USGS' },
|
||||||
traffic: { icon: Car, color: 'text-[#777]', label: 'Traffic' },
|
traffic: { icon: Car, color: 'text-[#777]', label: 'Traffic' },
|
||||||
roads: { icon: Construction, color: 'text-accent-dim', label: '511' },
|
roads: { icon: Construction, color: 'text-accent-dim', label: '511' },
|
||||||
|
ipaws: { icon: Siren, color: 'text-red-500', label: 'IPAWS' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Severity badge colors (3-level system + legacy support)
|
// Severity badge colors (3-level system + legacy support)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
||||||
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
|
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
|
||||||
Sliders, ChevronRight,
|
Sliders, ChevronRight, Siren,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
||||||
|
|
@ -33,6 +33,10 @@ interface EnvConfig {
|
||||||
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; feed_source?: FeedSource }
|
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; feed_source?: FeedSource }
|
||||||
wzdx: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; states: string[]; registry_url: string; registry_ttl?: number; feed_source?: FeedSource }
|
wzdx: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; states: string[]; registry_url: string; registry_ttl?: number; feed_source?: FeedSource }
|
||||||
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number; feed_source?: FeedSource }
|
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number; feed_source?: FeedSource }
|
||||||
|
// FEMA IPAWS-OPEN civil-alert feed (native only). Two-stage Atom+CAP fetch;
|
||||||
|
// NON-weather emergencies (evacuation, AMBER, HazMat, 911 outage, …) → the
|
||||||
|
// `emergency` notification family.
|
||||||
|
ipaws: { enabled: boolean; tick_seconds: number; base_url: string; user_agent: string; state_fips: string[]; same_codes: string[]; exclude_weather: boolean; drop_senders: string[]; status_actual_only: boolean; feed_source?: FeedSource }
|
||||||
// Native satpass (SGP4) YAML layer — drives env/satpass.py + env/tle_fetch.py.
|
// Native satpass (SGP4) YAML layer — drives env/satpass.py + env/tle_fetch.py.
|
||||||
// Distinct from the Central adapter_config/satpass layer (see SatpassConfig
|
// Distinct from the Central adapter_config/satpass layer (see SatpassConfig
|
||||||
// interface + satpassConfig state below). `observers` seeds observer_locations.
|
// interface + satpassConfig state below). `observers` seeds observer_locations.
|
||||||
|
|
@ -65,6 +69,22 @@ const SATPASS_NATIVE_DEFAULT: EnvConfig['satpass'] = {
|
||||||
feed_source: 'central',
|
feed_source: 'central',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sane defaults for the ipaws block so a GET payload predating the IPAWS
|
||||||
|
// adapter (no `environmental.ipaws`) doesn't crash the editor. Mirrors
|
||||||
|
// config.py::IPAWSConfig defaults (Idaho + neighbouring-state FIPS).
|
||||||
|
const IPAWS_DEFAULT: EnvConfig['ipaws'] = {
|
||||||
|
enabled: false,
|
||||||
|
tick_seconds: 60,
|
||||||
|
base_url: 'https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest',
|
||||||
|
user_agent: '',
|
||||||
|
state_fips: ['16', '53', '41', '32', '49', '56', '30'],
|
||||||
|
same_codes: [],
|
||||||
|
exclude_weather: true,
|
||||||
|
drop_senders: ['noaa.gov', 'nws', 'weather.gov'],
|
||||||
|
status_actual_only: true,
|
||||||
|
feed_source: 'native',
|
||||||
|
}
|
||||||
|
|
||||||
// WFIGS adapter config shape
|
// WFIGS adapter config shape
|
||||||
interface WfigsConfig {
|
interface WfigsConfig {
|
||||||
allowed_incident_types: string[]
|
allowed_incident_types: string[]
|
||||||
|
|
@ -262,7 +282,7 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- families
|
// ---------------------------------------------------------------- families
|
||||||
type AdapterKey = 'nws' | 'fires' | 'firms' | 'swpc' | 'ducting' | 'traffic' | 'roads511' | 'wzdx' | 'usgs_quake' | 'usgs' | 'avalanche' | 'satpass'
|
type AdapterKey = 'nws' | 'fires' | 'firms' | 'swpc' | 'ducting' | 'traffic' | 'roads511' | 'wzdx' | 'usgs_quake' | 'usgs' | 'avalanche' | 'satpass' | 'ipaws'
|
||||||
|
|
||||||
interface AdapterMeta { label: string; subtitle: string; health: string; hasCentral: boolean; nativeOnly: boolean; hasKey: boolean }
|
interface AdapterMeta { label: string; subtitle: string; health: string; hasCentral: boolean; nativeOnly: boolean; hasKey: boolean }
|
||||||
|
|
||||||
|
|
@ -279,6 +299,7 @@ const META: Record<AdapterKey, AdapterMeta> = {
|
||||||
usgs: { label: 'USGS Stream Gauges', subtitle: 'River and stream water levels', health: 'usgs', hasCentral: true, nativeOnly: false, hasKey: true },
|
usgs: { label: 'USGS Stream Gauges', subtitle: 'River and stream water levels', health: 'usgs', hasCentral: true, nativeOnly: false, hasKey: true },
|
||||||
avalanche: { label: 'Avalanche Advisories', subtitle: 'Backcountry avalanche danger ratings', health: 'avalanche', hasCentral: true, nativeOnly: false, hasKey: true },
|
avalanche: { label: 'Avalanche Advisories', subtitle: 'Backcountry avalanche danger ratings', health: 'avalanche', hasCentral: true, nativeOnly: false, hasKey: true },
|
||||||
satpass: { label: 'Satellite Passes', subtitle: 'Observer pass alerts via Central', health: 'satpass', hasCentral: true, nativeOnly: false, hasKey: true },
|
satpass: { label: 'Satellite Passes', subtitle: 'Observer pass alerts via Central', health: 'satpass', hasCentral: true, nativeOnly: false, hasKey: true },
|
||||||
|
ipaws: { label: 'FEMA IPAWS civil alerts', subtitle: 'Evacuations, AMBER, HazMat, 911 outages (non-weather)', health: 'ipaws', hasCentral: false, nativeOnly: true, hasKey: false },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyed adapters → their secret env var (matches secrets_store.SECRET_LABELS).
|
// Keyed adapters → their secret env var (matches secrets_store.SECRET_LABELS).
|
||||||
|
|
@ -299,6 +320,7 @@ const FAMILIES: { key: string; label: string; icon: typeof Cloud; adapters: Adap
|
||||||
{ key: 'rf', label: 'RF Propagation', icon: Radio, adapters: ['swpc', 'ducting'] },
|
{ key: 'rf', label: 'RF Propagation', icon: Radio, adapters: ['swpc', 'ducting'] },
|
||||||
{ key: 'roads', label: 'Roads', icon: Car, adapters: ['traffic', 'roads511', 'wzdx'] },
|
{ key: 'roads', label: 'Roads', icon: Car, adapters: ['traffic', 'roads511', 'wzdx'] },
|
||||||
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
|
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
|
||||||
|
{ key: 'emergency', label: 'Emergency', icon: Siren, adapters: ['ipaws'] },
|
||||||
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
|
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
|
||||||
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
|
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
|
||||||
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
|
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
|
||||||
|
|
@ -411,6 +433,7 @@ export default function Environment() {
|
||||||
// round-trip PUT restores them rather than dropping them).
|
// round-trip PUT restores them rather than dropping them).
|
||||||
data.satpass = { ...SATPASS_NATIVE_DEFAULT, ...(data.satpass ?? {}) }
|
data.satpass = { ...SATPASS_NATIVE_DEFAULT, ...(data.satpass ?? {}) }
|
||||||
data.wzdx = { states: ['ID'], registry_url: '', ...(data.wzdx ?? {}) }
|
data.wzdx = { states: ['ID'], registry_url: '', ...(data.wzdx ?? {}) }
|
||||||
|
data.ipaws = { ...IPAWS_DEFAULT, ...(data.ipaws ?? {}) }
|
||||||
setEnv(data)
|
setEnv(data)
|
||||||
setOriginal(JSON.stringify(data))
|
setOriginal(JSON.stringify(data))
|
||||||
|
|
||||||
|
|
@ -855,6 +878,7 @@ const save = async () => {
|
||||||
usgs_quake: 'usgs_quake',
|
usgs_quake: 'usgs_quake',
|
||||||
avalanche: 'avalanche',
|
avalanche: 'avalanche',
|
||||||
satpass: 'satpass',
|
satpass: 'satpass',
|
||||||
|
ipaws: 'ipaws',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notification family gating helpers ────────────────────────────────────
|
// ── Notification family gating helpers ────────────────────────────────────
|
||||||
|
|
@ -1450,6 +1474,49 @@ const save = async () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>)
|
</>)
|
||||||
|
case 'ipaws': return (<>
|
||||||
|
<div className="text-[11px] text-[#666]">
|
||||||
|
Keyless FEMA IPAWS-OPEN EAS feed. Broadcasts NON-weather civil emergencies
|
||||||
|
(evacuation orders, AMBER, HazMat, 911 outages, law-enforcement/shelter-in-place).
|
||||||
|
Weather CAP is dropped so it never double-broadcasts the NWS adapter.
|
||||||
|
</div>
|
||||||
|
<TextInput label="Base URL" value={env.ipaws.base_url} onChange={(v) => up({ ipaws: { ...env.ipaws, base_url: v } })}
|
||||||
|
placeholder="https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest"
|
||||||
|
helper="IPAWS-OPEN EAS REST root — Atom index at /feed, per-alert CAP at /eas/<id>. Point at the Conduit proxy in prod." />
|
||||||
|
<TextInput label="User Agent" value={env.ipaws.user_agent} onChange={(v) => up({ ipaws: { ...env.ipaws, user_agent: v } })}
|
||||||
|
placeholder="meshai-ipaws/1.0 (you@email.com)" helper="Sent on every FEMA request. Blank uses the built-in default." />
|
||||||
|
<NumberInput label="Tick Seconds" value={env.ipaws.tick_seconds} onChange={(v) => up({ ipaws: { ...env.ipaws, tick_seconds: v } })} min={30} />
|
||||||
|
{scopedByCoverage('ipaws') ? (
|
||||||
|
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
|
||||||
|
Region scope (state FIPS / SAME codes) is set by the{' '}
|
||||||
|
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
|
||||||
|
</div>
|
||||||
|
) : (<>
|
||||||
|
<ListInput label="State FIPS" value={env.ipaws.state_fips} onChange={(v) => up({ ipaws: { ...env.ipaws, state_fips: v } })}
|
||||||
|
helper="Coarse pre-fetch gate — 2-digit state FIPS to keep, e.g. 16 (ID), 41 (OR), 53 (WA)" />
|
||||||
|
<ListInput label="SAME Codes" value={env.ipaws.same_codes} onChange={(v) => up({ ipaws: { ...env.ipaws, same_codes: v } })}
|
||||||
|
helper="Optional fine gate — 6-digit SAME county codes, e.g. 016001. Empty = all counties in the FIPS states." />
|
||||||
|
</>)}
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Filters</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-sans text-[#e0e0e0]">Exclude weather-sourced alerts</span>
|
||||||
|
<input type="checkbox" checked={env.ipaws.exclude_weather}
|
||||||
|
onChange={(e) => up({ ipaws: { ...env.ipaws, exclude_weather: e.target.checked } })}
|
||||||
|
className="w-4 h-4 accent-[#f59e0b]" />
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[#666]">Drop NWS/NOAA-originated CAP so weather stays on the NWS adapter (no double-broadcast).</p>
|
||||||
|
<label className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm font-sans text-[#e0e0e0]">Actual status only</span>
|
||||||
|
<input type="checkbox" checked={env.ipaws.status_actual_only}
|
||||||
|
onChange={(e) => up({ ipaws: { ...env.ipaws, status_actual_only: e.target.checked } })}
|
||||||
|
className="w-4 h-4 accent-[#f59e0b]" />
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[#666]">Skip Test / Exercise / System messages — broadcast only status=Actual alerts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
case 'satpass': {
|
case 'satpass': {
|
||||||
// Armed state keys off the NATIVE enable (environmental.satpass.enabled),
|
// Armed state keys off the NATIVE enable (environmental.satpass.enabled),
|
||||||
// which is what actually gates the native SGP4 broadcaster — consistent
|
// which is what actually gates the native SGP4 broadcaster — consistent
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Save, RotateCcw, RefreshCw, Check,
|
Save, RotateCcw, RefreshCw, Check,
|
||||||
Eye as EyeIcon, EyeOff, Plus, X, Radio,
|
Eye as EyeIcon, EyeOff, Plus, X, Radio,
|
||||||
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin, Satellite, Layers,
|
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin, Satellite, Layers, Siren,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
|
||||||
|
|
@ -395,6 +395,7 @@ export const TOGGLE_FAMILY_META: FamilyMeta[] = [
|
||||||
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
|
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
|
||||||
{ key: 'weather', label: 'Weather', Icon: Cloud },
|
{ key: 'weather', label: 'Weather', Icon: Cloud },
|
||||||
{ key: 'fire', label: 'Fire', Icon: Flame },
|
{ key: 'fire', label: 'Fire', Icon: Flame },
|
||||||
|
{ key: 'emergency', label: 'Emergency', Icon: Siren },
|
||||||
{ key: 'rf_propagation', label: 'RF Propagation', Icon: Radio },
|
{ key: 'rf_propagation', label: 'RF Propagation', Icon: Radio },
|
||||||
{ key: 'roads', label: 'Roads', Icon: Car },
|
{ key: 'roads', label: 'Roads', Icon: Car },
|
||||||
{ key: 'avalanche', label: 'Avalanche', Icon: Snowflake },
|
{ key: 'avalanche', label: 'Avalanche', Icon: Snowflake },
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-dwX3LKqm.js"></script>
|
<script type="module" crossorigin src="/assets/index-FgUiz9s3.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CTVGSJxQ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CTVGSJxQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1354,6 +1354,10 @@ class Dispatcher:
|
||||||
"wzdx": "traffic_events",
|
"wzdx": "traffic_events",
|
||||||
"traffic": "traffic_events",
|
"traffic": "traffic_events",
|
||||||
"511": "traffic_events",
|
"511": "traffic_events",
|
||||||
|
# IPAWS civil alerts (env/ipaws.py, source="ipaws"). Own dedup table
|
||||||
|
# ipaws_alerts — so region-routed emergency sends land in the audit
|
||||||
|
# feed labeled "Emergency" instead of NULL/unlabeled.
|
||||||
|
"ipaws": "ipaws_alerts",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _post_broadcast_commit(self, event, payload, rule, ch_type: str,
|
def _post_broadcast_commit(self, event, payload, rule, ch_type: str,
|
||||||
|
|
|
||||||
|
|
@ -554,8 +554,50 @@ def test_source_to_table_fallback_stamps_audit_row(db_path):
|
||||||
assert row["source_event_pk"] is None, "pk should be NULL for fallback path"
|
assert row["source_event_pk"] is None, "pk should be NULL for fallback path"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_to_table_fallback_stamps_ipaws_audit_row(db_path):
|
||||||
|
"""Parity guard for the IPAWS civil-alert adapter: a native ipaws event
|
||||||
|
with no _broadcast_audit must stamp source_event_table='ipaws_alerts' so
|
||||||
|
emergency sends show labeled (not NULL/unlabeled) in the Activity Log.
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from meshai.notifications.events import make_event
|
||||||
|
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
|
||||||
|
cfg = _build_config(cold_start_grace=0)
|
||||||
|
factory, _ = _mk_channel_factory()
|
||||||
|
d = Dispatcher(cfg, factory)
|
||||||
|
|
||||||
|
ev = make_event(
|
||||||
|
source="ipaws",
|
||||||
|
category="emergency_evacuation",
|
||||||
|
severity="immediate",
|
||||||
|
region="US-ID",
|
||||||
|
title="🚨 Evacuation Immediate",
|
||||||
|
lat=43.6, lon=-116.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
rule = MagicMock()
|
||||||
|
rule.broadcast_channel = 1
|
||||||
|
rule.delivery_types = ["mesh_broadcast"]
|
||||||
|
|
||||||
|
payload = MagicMock()
|
||||||
|
payload.message = "🚨 Evacuation Immediate — test"
|
||||||
|
|
||||||
|
d._post_broadcast_commit(ev, payload, rule, "mesh_broadcast", success=True)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT source_event_table FROM mesh_broadcasts_out ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None, "No audit row was written"
|
||||||
|
assert row["source_event_table"] == "ipaws_alerts", (
|
||||||
|
f"Expected 'ipaws_alerts', got {row['source_event_table']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_source_to_table_fallback_all_native_sources(db_path):
|
def test_source_to_table_fallback_all_native_sources(db_path):
|
||||||
"""Verify _SOURCE_TO_TABLE covers all five native adapter sources."""
|
"""Verify _SOURCE_TO_TABLE covers all native adapter sources."""
|
||||||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||||
expected = {
|
expected = {
|
||||||
"nws": "nws_alerts",
|
"nws": "nws_alerts",
|
||||||
|
|
@ -563,6 +605,7 @@ def test_source_to_table_fallback_all_native_sources(db_path):
|
||||||
"wzdx": "traffic_events",
|
"wzdx": "traffic_events",
|
||||||
"traffic": "traffic_events",
|
"traffic": "traffic_events",
|
||||||
"511": "traffic_events",
|
"511": "traffic_events",
|
||||||
|
"ipaws": "ipaws_alerts",
|
||||||
}
|
}
|
||||||
cfg = _build_config()
|
cfg = _build_config()
|
||||||
factory, _ = _mk_channel_factory()
|
factory, _ = _mk_channel_factory()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue