diff --git a/work/Dockerfile b/work/Dockerfile
index f62c587..49df00e 100644
--- a/work/Dockerfile
+++ b/work/Dockerfile
@@ -91,8 +91,8 @@ VOLUME ["/data"]
EXPOSE 8080
# Health check - verify bot process is alive via PID file
-HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
- CMD test -f /tmp/meshai.pid && kill -0 $(cat /tmp/meshai.pid) 2>/dev/null && [ "$(cat /tmp/meshai.link 2>/dev/null)" = up ] || exit 1
+HEALTHCHECK --interval=30s --timeout=10s --start-period=240s --retries=3 \
+ CMD curl -f -s -o /dev/null http://localhost:8080/ || exit 1
# Entrypoint writes default config on first run, then starts the bot
ENTRYPOINT ["/app/docker-entrypoint.sh"]
diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx
index a0a8785..253e9a3 100644
--- a/work/dashboard-frontend/src/App.tsx
+++ b/work/dashboard-frontend/src/App.tsx
@@ -18,12 +18,14 @@ import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
import Coverage from './pages/Coverage'
import { ToastProvider } from './components/ToastProvider'
import { DirtyProvider } from './context/DirtyContext'
+import ErrorBoundary from './components/ErrorBoundary'
function App() {
return (
+
{/* Core routes */}
} />
@@ -66,6 +68,7 @@ function App() {
} />
} />
+
diff --git a/work/dashboard-frontend/src/components/ErrorBoundary.tsx b/work/dashboard-frontend/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..9b56ad3
--- /dev/null
+++ b/work/dashboard-frontend/src/components/ErrorBoundary.tsx
@@ -0,0 +1,49 @@
+// App-wide render-error safety net. Wraps in App.tsx so an
+// unhandled error thrown while rendering any page degrades to a recoverable
+// "Something went wrong" card instead of a blank white screen.
+import { Component, type ErrorInfo, type ReactNode } from 'react'
+import { AlertTriangle } from 'lucide-react'
+
+interface Props {
+ children: ReactNode
+}
+
+interface State {
+ hasError: boolean
+ error: Error | null
+}
+
+export default class ErrorBoundary extends Component {
+ state: State = { hasError: false, error: null }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error }
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('MeshAI dashboard render error:', error, errorInfo)
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
Something went wrong
+
+ {this.state.error?.message ?? 'An unexpected error occurred while rendering this page.'}
+
+
window.location.reload()}
+ className="px-4 py-2 bg-accent hover:bg-accent/80 rounded text-white text-sm transition-colors"
+ >
+ Reload
+
+
+
+ )
+ }
+ return this.props.children
+ }
+}
diff --git a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
index ac6646f..700201d 100644
--- a/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
+++ b/work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
-import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy } from 'lucide-react'
+import { Save, RotateCcw, RefreshCw, Check, ChevronRight, Trash2, Eye, EyeOff, Copy, X } from 'lucide-react'
import { TextInput, NumberInput, Toggle, ListInput, SelectInput } from './Config'
import SerialPortPicker from '@/components/SerialPortPicker'
import { notifyRestartRequired } from '@/components/RestartBanner'
@@ -58,6 +58,9 @@ export default function MeshCoreConnection() {
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const [hasChanges, setHasChanges] = useState(false)
+ // Set when one or both config fetches fail; the form still renders (using
+ // safe defaults for whatever didn't load) instead of blanking the page.
+ const [loadError, setLoadError] = useState(null)
// Test send state
const [channelsActive, setChannelsActive] = useState(false)
@@ -81,22 +84,41 @@ export default function MeshCoreConnection() {
const fetchConfig = useCallback(async () => {
setLoading(true)
- try {
- const [data, mcCtx] = await Promise.all([
- apiFetchConfig('connection') as Promise,
- apiFetchConfig('meshcore_context') as Promise,
- ])
- setConfig(data)
- setOriginalConfig(JSON.parse(JSON.stringify(data)))
- setMcContext(mcCtx)
- setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx)))
- setHasChanges(false)
- setError(null)
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Unknown error')
- } finally {
- setLoading(false)
+ // Promise.allSettled so one section failing to load can't blank the
+ // whole page — each section is set independently from its own result,
+ // and any failure(s) are surfaced as a dismissible banner alongside the
+ // still-usable form (see the `!config` dead-end this replaces).
+ const [connResult, mcResult] = await Promise.allSettled([
+ apiFetchConfig('connection') as Promise,
+ apiFetchConfig('meshcore_context') as Promise,
+ ])
+
+ const errors: string[] = []
+
+ if (connResult.status === 'fulfilled') {
+ setConfig(connResult.value)
+ setOriginalConfig(JSON.parse(JSON.stringify(connResult.value)))
+ } else {
+ errors.push(`connection (${connResult.reason instanceof Error ? connResult.reason.message : String(connResult.reason)})`)
+ // Fall back to an empty object (never null) so the form below always
+ // has something to render safe defaults from, and so the dirty-check
+ // diff below still has a baseline to compare edits against.
+ setConfig((c) => c ?? {})
+ setOriginalConfig((c) => c ?? {})
}
+
+ if (mcResult.status === 'fulfilled') {
+ setMcContext(mcResult.value)
+ setOriginalMcContext(JSON.parse(JSON.stringify(mcResult.value)))
+ } else {
+ errors.push(`bot behavior (${mcResult.reason instanceof Error ? mcResult.reason.message : String(mcResult.reason)})`)
+ // Leave mcContext null — the "Bot behavior" card only renders when it
+ // is present, so a null value just hides that card cleanly.
+ }
+
+ setLoadError(errors.length ? `Couldn't load ${errors.join(' and ')}. Showing defaults — edits below are still safe to make and save.` : null)
+ setHasChanges(false)
+ setLoading(false)
}, [])
useEffect(() => {
@@ -207,10 +229,13 @@ export default function MeshCoreConnection() {
}
useEffect(() => {
- if (config && originalConfig && mcContext && originalMcContext) {
+ // mcContext may be null (its fetch failed) — don't let that block
+ // detecting changes to the connection fields, which always load or
+ // fall back to an empty-object baseline in fetchConfig.
+ if (config && originalConfig) {
const changed =
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
- JSON.stringify(mcContext) !== JSON.stringify(originalMcContext)
+ (!!mcContext && !!originalMcContext && JSON.stringify(mcContext) !== JSON.stringify(originalMcContext))
setHasChanges(changed)
}
}, [config, originalConfig, mcContext, originalMcContext])
@@ -221,22 +246,24 @@ export default function MeshCoreConnection() {
}, [hasChanges, setDirty])
const upd = (patch: Partial) =>
- setConfig((c) => (c ? { ...c, ...patch } : c))
+ // Build off an empty object rather than bailing when config is still
+ // null (e.g. mid-retry) — edits should never be silently dropped.
+ setConfig((c) => ({ ...(c ?? {}), ...patch }))
const saveConfig = async () => {
- if (!config || !mcContext) return
+ if (!config) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole objects so sibling fields (Meshtastic connection fields,
- // any other meshcore_context keys) are preserved.
- const results = await Promise.all([
- apiUpdateConfig('connection', config),
- apiUpdateConfig('meshcore_context', mcContext),
- ])
+ // any other meshcore_context keys) are preserved. Only PUT
+ // meshcore_context if it actually loaded — its fetch may have failed.
+ const puts: Promise<{ restart_required?: boolean }>[] = [apiUpdateConfig('connection', config)]
+ if (mcContext) puts.push(apiUpdateConfig('meshcore_context', mcContext))
+ const results = await Promise.all(puts)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
- setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
+ if (mcContext) setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
setHasChanges(false)
setDirty(false)
setSuccess('MeshCore connection saved successfully')
@@ -276,13 +303,10 @@ export default function MeshCoreConnection() {
)
}
- if (!config) {
- return (
-
-
Failed to load connection config
-
- )
- }
+ // Always render the form below, even if the fetch failed — `cfg` supplies
+ // safe defaults so the connection-type/host/port fields and Save/Discard
+ // stay usable. The failure itself is surfaced by the loadError banner.
+ const cfg: ConnectionConfig = config ?? {}
return (
@@ -320,6 +344,30 @@ export default function MeshCoreConnection() {
+ {/* Load-error banner — dismissible, with its own Retry so the page
+ never dead-ends when a config section fails to fetch. The form
+ below stays fully editable regardless. */}
+ {loadError && (
+
+
{loadError}
+
+
+ Retry
+
+
setLoadError(null)}
+ title="Dismiss"
+ aria-label="Dismiss load error"
+ className="text-red-400 hover:text-red-200 shrink-0"
+ >
+
+
+
+ )}
+
{/* Status messages */}
{error && (
{error}
@@ -341,7 +389,7 @@ export default function MeshCoreConnection() {
upd({ meshcore_conn_type: v })}
options={[
{ value: 'tcp', label: 'TCP (companion)' },
@@ -350,11 +398,11 @@ export default function MeshCoreConnection() {
]}
helper="TCP for a companion frame server, Serial for a USB node, BLE for Bluetooth"
/>
- {(config.meshcore_conn_type ?? 'tcp') === 'tcp' && (
+ {(cfg.meshcore_conn_type ?? 'tcp') === 'tcp' && (
upd({ meshcore_host: v })}
placeholder="192.168.1.100"
helper="IP or hostname of the companion frame server"
@@ -362,7 +410,7 @@ export default function MeshCoreConnection() {
/>
upd({ meshcore_port: v })}
min={1}
max={65535}
@@ -370,27 +418,27 @@ export default function MeshCoreConnection() {
/>
)}
- {(config.meshcore_conn_type ?? 'tcp') === 'serial' && (
+ {(cfg.meshcore_conn_type ?? 'tcp') === 'serial' && (
<>
upd({ meshcore_serial_port: v })}
helper="USB-attached MeshCore node — Detect fills a stable by-id path"
/>
upd({ meshcore_baud: v })}
min={1200}
helper="Serial baud rate (default 115200)"
/>
>
)}
- {(config.meshcore_conn_type ?? 'tcp') === 'ble' && (
+ {(cfg.meshcore_conn_type ?? 'tcp') === 'ble' && (
upd({ meshcore_ble_address: v })}
placeholder="AA:BB:CC:DD:EE:FF"
helper="Leave blank to scan/pair the first available device"
@@ -406,7 +454,7 @@ export default function MeshCoreConnection() {
upd({ meshcore_auto_add_contacts: v })}
helper="Enables firmware CMD 58 (set_autoadd_config) at connect so AIDA automatically adds every node it hears an advert from as a contact, enabling DM send/decrypt without manual contact exchange"
/>
@@ -418,13 +466,13 @@ export default function MeshCoreConnection() {
upd({ meshcore_auto_reconnect: v })}
helper="Automatically reconnect to the MeshCore companion if the link drops"
/>
upd({ meshcore_max_reconnect_attempts: v })}
min={0}
helper="Maximum reconnect attempts before giving up (0 = unlimited)"
diff --git a/work/docker-compose.yml b/work/docker-compose.yml
index e2b1e3a..a30a665 100644
--- a/work/docker-compose.yml
+++ b/work/docker-compose.yml
@@ -75,11 +75,11 @@ services:
memory: 64M
healthcheck:
- test: ["CMD-SHELL", "test -f /tmp/meshai.pid && kill -0 $(cat /tmp/meshai.pid) 2>/dev/null && [ \"$(cat /tmp/meshai.link 2>/dev/null)\" = up ] || exit 1"]
+ test: ["CMD-SHELL", "curl -f -s -o /dev/null http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
- start_period: 15s
+ start_period: 240s
logging:
driver: "json-file"
diff --git a/work/meshai/adapter_config/defaults.py b/work/meshai/adapter_config/defaults.py
index b1dec70..f91c07a 100644
--- a/work/meshai/adapter_config/defaults.py
+++ b/work/meshai/adapter_config/defaults.py
@@ -85,7 +85,8 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
},
# =================================================================
- # USGS_QUAKE -- 6 settings (regional geography + 3 mag floors + PAGER set)
+ # USGS_QUAKE -- 7 settings (regional geography + 3 mag floors + PAGER set
+ # + freshness gate)
# =================================================================
("usgs_quake", "regional_centroid"): {
"default": [44.36, -114.61], # quake_handler.py:36-37 (Idaho centroid)
@@ -117,6 +118,11 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
"type": "float",
"description": "Magnitude floor for the visual escalation emoji.",
},
+ ("usgs_quake", "freshness_seconds"): {
+ "default": 3600,
+ "type": "int",
+ "description": "Staleness gate for earthquake_event broadcasts (age = now - quake origin time), read by the dispatcher instead of the generic per-toggle freshness. The upstream feed is a rolling PAST-DAY feed, so quakes are routinely detected 10min-20h after origin; 1 hour keeps quakes broadcast-worthy (still situationally relevant) while excluding the day-feed's stale backlog. 0 = disabled (not recommended -- would readmit day-old quakes).",
+ },
# =================================================================
# SWPC -- 3 settings (three storm-tier broadcast floors)
diff --git a/work/meshai/config.py b/work/meshai/config.py
index 8e1dab6..35e7c56 100644
--- a/work/meshai/config.py
+++ b/work/meshai/config.py
@@ -51,7 +51,7 @@ class ConnectionConfig:
meshcore_host: str = "" # pyMC companion frame server host
meshcore_port: int = 5050 # pyMC companion frame server port
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
- meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
+ meshcore_max_reconnect_attempts: int = 0 # max reconnect attempts (0 = unlimited)
meshcore_advert_interval_seconds: int = 86400 # periodic self-advert interval, 24h (0 = disabled)
# MeshCore connection type: tcp | serial | ble (default tcp for back-compat)
meshcore_conn_type: str = "tcp"
diff --git a/work/meshai/env/fire_render.py b/work/meshai/env/fire_render.py
index 04028fb..3bd0f27 100644
--- a/work/meshai/env/fire_render.py
+++ b/work/meshai/env/fire_render.py
@@ -248,7 +248,7 @@ def _location_anchor(n: dict) -> str:
try:
from meshai.persistence import get_db
rows = get_db().execute(
- "SELECT name, lat, lon FROM town_anchors WHERE lat IS NOT NULL AND lon IS NOT NULL"
+ "SELECT name, lat, lon FROM town_anchors WHERE lat IS NOT NULL AND lon IS NOT NULL AND enabled = 1"
).fetchall()
best = None
best_d = float("inf")
diff --git a/work/meshai/env/ipaws.py b/work/meshai/env/ipaws.py
index f5db495..e7b2284 100644
--- a/work/meshai/env/ipaws.py
+++ b/work/meshai/env/ipaws.py
@@ -104,6 +104,13 @@ def _norm_fips(value) -> str:
class IPAWSAlertsAdapter:
"""FEMA IPAWS-OPEN EAS civil alerts — two-stage CAP poller."""
+ # Stage-2 retry suppression. 401/403/404/410 are treated as durable (the
+ # alert body will not become fetchable), so we wait out the feed's rolling
+ # window; everything else (timeout, 5xx, 429, connection) is transient.
+ _STAGE2_FORBIDDEN_COOLDOWN = 21600 # 6h
+ _STAGE2_TRANSIENT_COOLDOWN = 300 # 5m
+ _STAGE2_FORBIDDEN_CODES = (401, 403, 404, 410)
+
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"
@@ -133,6 +140,11 @@ class IPAWSAlertsAdapter:
self._last_error = None
self._backoff_until = 0.0
self._is_loaded = False
+ # Negative cache: stage-2 CAP URL -> epoch after which a retry is
+ # allowed. Stops re-hammering FEMA for an alert listed in the feed whose
+ # detail endpoint keeps failing (e.g. a durable 403 on a COG-restricted
+ # alert). Pruned to the current feed each pass so it can't grow unbounded.
+ self._stage2_cooldown = {}
# ── Polling ──────────────────────────────────────────────────────────────
@@ -205,6 +217,8 @@ class IPAWSAlertsAdapter:
self._consecutive_errors += 1
return False
+ now = time.time()
+ seen_urls = set()
new_events = []
for entry in feed.findall(f"{{{_ATOM_NS}}}entry"):
statefips = None
@@ -221,18 +235,43 @@ class IPAWSAlertsAdapter:
cap_url = self._stage2_url(href)
if not cap_url:
continue
+ seen_urls.add(cap_url)
+
+ # ── Negative cache: skip URLs still within their failure cooldown ─
+ retry_after = self._stage2_cooldown.get(cap_url)
+ if retry_after is not None and now < retry_after:
+ 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)
+ except HTTPError as e:
+ cooldown = (self._STAGE2_FORBIDDEN_COOLDOWN
+ if e.code in self._STAGE2_FORBIDDEN_CODES
+ else self._STAGE2_TRANSIENT_COOLDOWN)
+ self._stage2_cooldown[cap_url] = now + cooldown
+ logger.debug("IPAWS stage-2 fetch failed for %s: HTTP %s "
+ "(cooldown %ds)", cap_url, e.code, cooldown)
continue
+ except Exception as e: # noqa: BLE001
+ self._stage2_cooldown[cap_url] = now + self._STAGE2_TRANSIENT_COOLDOWN
+ logger.debug("IPAWS stage-2 fetch failed for %s: %s (cooldown %ds)",
+ cap_url, e, self._STAGE2_TRANSIENT_COOLDOWN)
+ continue
+
+ # Success — clear any stale cooldown for this URL.
+ self._stage2_cooldown.pop(cap_url, None)
parsed = self._parse_cap(cap_raw, statefips)
if parsed:
new_events.append(parsed)
+ # Prune the negative cache to URLs still present in the feed, so it
+ # tracks the rolling window and never grows without bound.
+ self._stage2_cooldown = {
+ u: t for u, t in self._stage2_cooldown.items() if u in seen_urls
+ }
+
# 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}
@@ -297,6 +336,30 @@ class IPAWSAlertsAdapter:
certainty = _t(info, "certainty")
headline = _t(info, "headline")
description = _t(info, "description")
+ instruction = _t(info, "instruction")
+
+ # ── blocks: valueName -> value ────────────────────────────
+ # Civil alerts carry their real public-facing text here (CMAMtext /
+ # CMAMlongtext are the agency's own purpose-written WEA copy) — this was
+ # previously discarded entirely. A repeated valueName (e.g. multiple
+ # BLOCKCHANNEL entries) becomes a list; defensive against malformed/
+ # missing valueName or value children (skipped, never raises).
+ parameters: dict = {}
+ for param in info.findall(f"{{{_CAP_NS}}}parameter"):
+ vn = param.find(f"{{{_CAP_NS}}}valueName")
+ vv = param.find(f"{{{_CAP_NS}}}value")
+ name = vn.text.strip() if vn is not None and vn.text else ""
+ if not name:
+ continue
+ value = vv.text.strip() if vv is not None and vv.text else ""
+ if name in parameters:
+ existing = parameters[name]
+ if isinstance(existing, list):
+ existing.append(value)
+ else:
+ parameters[name] = [existing, value]
+ else:
+ parameters[name] = value
# eventCode SAME value
same_value = ""
@@ -377,6 +440,8 @@ class IPAWSAlertsAdapter:
"msgType": msg_type,
"headline": headline,
"description": description,
+ "instruction": instruction,
+ "parameters": parameters,
"same_code": same_value,
"area_desc": area_desc,
"area_same_codes": area_same_codes,
@@ -491,7 +556,8 @@ class IPAWSAlertsAdapter:
"area_desc": area_desc,
"geocoder": {"city": None, "county": area_desc, "state": None},
"description": raw.get("description", ""),
- "parameters": {},
+ "instruction": raw.get("instruction", ""),
+ "parameters": raw.get("parameters") or {},
"msgType": raw.get("msgType", "Alert"),
"references": [],
"category": category,
diff --git a/work/meshai/env/roads511.py b/work/meshai/env/roads511.py
index ba90bde..a57e5b4 100644
--- a/work/meshai/env/roads511.py
+++ b/work/meshai/env/roads511.py
@@ -21,6 +21,53 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+# Explicit full-road-closure language, matched case-insensitively. Used
+# ONLY by to_event()'s sub_type mapping (branch a) to decide "road_closed"
+# vs. "road_works" — distinct from (and stricter than) the adapter's
+# existing `is_closure` property, which loosely matches any "closed"
+# substring and therefore also flags partial-restriction wording (e.g.
+# "All Shoulders Closed") that the upstream owner explicitly does not want
+# broadcast as a closure.
+_FULL_CLOSURE_PHRASES = (
+ "all lanes closed",
+ "road closed",
+ "highway closed",
+ "fully closed",
+ "closed in both directions",
+)
+
+# Partial-restriction phrases that must NEVER be counted as a full closure.
+# Checked first so one of these vetoes a match even if closure wording
+# happens to appear elsewhere in the same description — e.g. "all
+# shoulders closed" must not fall through to a naive "closed" test, and
+# "one/right/left lane closed" must not be confused with "all lanes
+# closed" (note the singular "lane closed" vs. plural "lanes closed").
+_PARTIAL_RESTRICTION_PHRASES = (
+ "shoulder closed",
+ "shoulders closed",
+ "lane closed",
+ "ramp closed",
+ "alternating",
+ "lane restriction",
+)
+
+
+def _has_full_closure_language(description: str) -> bool:
+ """True if `description` contains explicit full-closure phrasing.
+
+ Case-insensitive. Partial-restriction phrases (shoulder/lane/ramp
+ closures, alternating traffic) are checked first and veto a match, so
+ they never register as a full closure regardless of other wording
+ present.
+ """
+ text = (description or "").lower()
+
+ if any(phrase in text for phrase in _PARTIAL_RESTRICTION_PHRASES):
+ return False
+
+ return any(phrase in text for phrase in _FULL_CLOSURE_PHRASES)
+
+
class Roads511Adapter:
"""511 road conditions polling adapter."""
@@ -284,12 +331,21 @@ class Roads511Adapter:
lat = loc.get("latitude") or loc.get("lat")
lon = loc.get("longitude") or loc.get("lon") or loc.get("lng")
- # Check closure status
- is_closure = (
+ # Raw upstream full-closure signal, captured on its own (no
+ # description heuristic mixed in) so to_event() can build a
+ # stricter full-closure determination for sub_type mapping
+ # without touching is_closure below, which severity/summary
+ # logic elsewhere already depends on.
+ is_full_closure_flag = bool(
item.get("IsFullClosure") or
item.get("is_full_closure") or
item.get("fullClosure") or
- item.get("closed") or
+ item.get("closed")
+ )
+
+ # Check closure status
+ is_closure = (
+ is_full_closure_flag or
"closure" in str(event_type).lower() or
"closed" in str(description).lower()
)
@@ -323,6 +379,19 @@ class Roads511Adapter:
None
)
+ # Granular ITD 511 v2 fields (roadwork vs. crash vs. closure
+ # discrimination). Defensive: absent on other states' 511 feeds.
+ event_sub_type = (
+ item.get("EventSubType") or
+ item.get("event_sub_type") or
+ ""
+ )
+ cause = (
+ item.get("Cause") or
+ item.get("cause") or
+ None
+ )
+
# Default 6 hour TTL, refreshed every tick
expires = now + 21600
@@ -346,7 +415,10 @@ class Roads511Adapter:
"properties": {
"roadway": roadway,
"is_closure": bool(is_closure),
+ "is_full_closure_flag": is_full_closure_flag,
"last_updated": last_updated,
+ "event_sub_type": event_sub_type,
+ "cause": cause,
},
}
@@ -411,10 +483,49 @@ class Roads511Adapter:
# NOTE: Central-era rows used source 'itd_511' with 'idaho_511:event:*'
# ids — a different keyspace the pre-seed intentionally does not cover.
_external_id = evt.get("external_id") or event_id
+
+ # sub_type mapping: distinguish routine roadwork (suppressed by
+ # gating/incident.py's work-zone rule) from genuine news (crashes,
+ # closures, hazards). A construction-caused FULL closure is still
+ # real news and must broadcast, so a full closure wins regardless
+ # of EventType. Precedence:
+ # 1. full closure (raw flag OR explicit closure wording,
+ # see _has_full_closure_language) -> road_closed (always)
+ # 2. EventType roadwork /
+ # EventSubType has "construction" -> road_works (suppressed)
+ # 3. EventType accidentsAndIncidents -> incident
+ # 4. EventType closures -> road_closed
+ # 5. else (specialEvents, unknown) -> incident (fail open)
+ #
+ # NOTE: branch 1 deliberately does NOT reuse `_is_closure` above.
+ # `_is_closure` is a loose OR-chain (also used for severity and
+ # the summary text) that matches any "closed" substring in the
+ # description, including partial-restriction wording like "All
+ # Shoulders Closed" — routine construction the owner explicitly
+ # does not want broadcast. `_full_closure_for_mapping` is a
+ # stricter, mapping-only determination.
+ _event_type_val = str(evt.get("event_type") or "").strip().lower()
+ _event_sub_type_val = str(props.get("event_sub_type") or "").strip().lower()
+ _cause = props.get("cause")
+ _full_closure_for_mapping = bool(
+ props.get("is_full_closure_flag")
+ ) or _has_full_closure_language(_desc)
+
+ if _full_closure_for_mapping:
+ _sub_type = "road_closed"
+ elif _event_type_val == "roadwork" or "construction" in _event_sub_type_val:
+ _sub_type = "road_works"
+ elif _event_type_val == "accidentsandincidents":
+ _sub_type = "incident"
+ elif _event_type_val == "closures":
+ _sub_type = "road_closed"
+ else:
+ _sub_type = "incident"
+
canonical_data = {
"external_id": _external_id, # 511_{itd_id}; enables durable pre-seed
"source": "511",
- "sub_type": "road_closed" if _is_closure else "incident",
+ "sub_type": _sub_type,
"road": _roadway or None,
"direction": None, # not structured in native 511 feed
"from_loc": None,
@@ -423,7 +534,7 @@ class Roads511Adapter:
"mile_end": None,
"mile_marker": None,
"lanes_affected": None,
- "cause": None,
+ "cause": _cause,
"comment": _desc[:200] if _desc else title,
"impact": "all lanes closed" if _is_closure else None,
"county": None,
diff --git a/work/meshai/env/satellite/pass_format.py b/work/meshai/env/satellite/pass_format.py
index 12900f6..1fb3fed 100644
--- a/work/meshai/env/satellite/pass_format.py
+++ b/work/meshai/env/satellite/pass_format.py
@@ -373,7 +373,20 @@ def gate_consolidated_pass(consolidated: dict, *,
# Prepare data dict with callbacks
severity_word = _map_severity(max_el)
- data = {"_meshai_precomposed": True, "_severity_override": severity_word}
+ data = {
+ "_meshai_precomposed": True,
+ "_severity_override": severity_word,
+ # Region tagging: coverage_area.observer_region_names() reads
+ # event.data["observer_list"] as a comma-joined string of observer
+ # slugs (the same shape already computed above for the audit
+ # column). Satpass events carry no lat/lon/geometry, so this is the
+ # ONLY path that lets CoverageFilter region-tag a satpass event for
+ # region_routes matching. `observer_list` is already defensive
+ # (falls back to entry_obs, or "") so this never raises; an empty
+ # string is fail-open in observer_region_names() (-> no region tag,
+ # not an exception).
+ "observer_list": observer_list,
+ }
_attach_commit(data, event_id=consolidated_id, event_log_row_id=None)
return wire, data
diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py
index 06109a1..15f295b 100644
--- a/work/meshai/env/store.py
+++ b/work/meshai/env/store.py
@@ -1,5 +1,6 @@
"""Environmental data store with tick-based adapter polling."""
+import asyncio
import hashlib
import json
import logging
@@ -438,20 +439,38 @@ class EnvironmentalStore:
from meshai import coverage as _cov
return _cov.resolve_adapter_coverage(adapter, self._coverage_bbox, "native")
- def refresh(self) -> bool:
+ async def refresh(self) -> bool:
"""Called every second from main loop. Ticks each adapter.
+ Adapter tick() calls (blocking network I/O) run concurrently in
+ worker threads and are AWAITED to completion before ingest, so the
+ event loop stays responsive while fetches are in flight. Ingest
+ (DB/EventBus work) then runs on the loop thread once all ticks are
+ done, exactly as before, so no thread ever overlaps ingest.
+
Returns:
True if any data changed
"""
changed = False
- for name, adapter in self._adapters.items():
- try:
- if adapter.tick():
- changed = True
+ adapters = list(self._adapters.items())
+ if not adapters:
+ self._purge_expired()
+ return changed
+
+ results = await asyncio.gather(
+ *(asyncio.to_thread(adapter.tick) for _, adapter in adapters),
+ return_exceptions=True,
+ )
+ for (name, adapter), result in zip(adapters, results):
+ if isinstance(result, Exception):
+ logger.warning("Env adapter %s error: %s", name, result)
+ continue
+ if result:
+ changed = True
+ try:
self._ingest(name, adapter)
- except Exception as e:
- logger.warning("Env adapter %s error: %s", name, e)
+ except Exception as e:
+ logger.warning("Env adapter %s error: %s", name, e)
self._purge_expired()
return changed
diff --git a/work/meshai/env/usgs_quake.py b/work/meshai/env/usgs_quake.py
index 943e1af..264841d 100644
--- a/work/meshai/env/usgs_quake.py
+++ b/work/meshai/env/usgs_quake.py
@@ -246,7 +246,14 @@ class USGSQuakeAdapter:
expires=evt.get("expires"),
lat=lat,
lon=lon,
- region=evt.get("region"),
+ # Deliberately NOT passing region=evt.get("region") here: the
+ # raw dict's region is a fixed config default (self._region,
+ # "magic_valley"), which doesn't match any region_routes cell
+ # key. Event.region is documented as "set by region tagger"
+ # (notifications/events.py) -- leaving it unset lets
+ # CoverageFilter's geometry-based tagging (event.lat/lon vs
+ # the named coverage areas) populate the real region name
+ # from the quake's actual coordinates instead.
group_key=event_id,
inhibit_keys=[event_id],
data=canonical_data,
diff --git a/work/meshai/main.py b/work/meshai/main.py
index 33866bf..cffc049 100644
--- a/work/meshai/main.py
+++ b/work/meshai/main.py
@@ -2,6 +2,7 @@
import argparse
import asyncio
+import concurrent.futures
import logging
import os
import signal
@@ -146,13 +147,46 @@ class MeshAI:
while self._running:
await asyncio.sleep(1)
- # Periodic MeshMonitor refresh
- if self.meshmonitor_sync:
- self.meshmonitor_sync.maybe_refresh()
-
- # Periodic data store refresh and health computation
+ # Run the mesh/env/meshmonitor pollers concurrently so blocking
+ # network I/O (tick() fetches) never starves this loop — and
+ # therefore never starves the dashboard, which shares this same
+ # asyncio loop. Each refresh() internally awaits its own due
+ # ticks in worker threads; meshmonitor_sync.maybe_refresh is
+ # synchronous, so it is offloaded to a thread here directly.
+ # We await the WHOLE cycle before the next iteration, so no
+ # tick() thread ever overlaps the next cycle's bookkeeping.
+ _refresh_tasks = {}
+ if self.data_store:
+ _refresh_tasks['data'] = self.data_store.refresh()
+ if self.env_store:
+ _refresh_tasks['env'] = self.env_store.refresh()
+ if self.meshmonitor_sync:
+ _refresh_tasks['mm'] = asyncio.to_thread(self.meshmonitor_sync.maybe_refresh)
+
+ if _refresh_tasks:
+ _refresh_results = dict(zip(
+ _refresh_tasks.keys(),
+ await asyncio.gather(*_refresh_tasks.values(), return_exceptions=True),
+ ))
+ else:
+ _refresh_results = {}
+
+ refreshed = _refresh_results.get('data')
+ if isinstance(refreshed, Exception):
+ logger.warning("Data store refresh error: %s", refreshed)
+ refreshed = False
+
+ env_changed = _refresh_results.get('env')
+ if isinstance(env_changed, Exception):
+ logger.debug("Env refresh error: %s", env_changed)
+ env_changed = False
+
+ _mm_result = _refresh_results.get('mm')
+ if isinstance(_mm_result, Exception):
+ logger.warning("MeshMonitor sync refresh error: %s", _mm_result)
+
+ # Periodic data store health computation
if self.data_store:
- refreshed = self.data_store.refresh()
# Recompute health after refresh
if refreshed and self.health_engine:
self.health_engine.compute(self.data_store)
@@ -202,10 +236,9 @@ class MeshAI:
except Exception:
pass
- # Environmental feed refresh
+ # Environmental feed alerting/broadcast (refresh already ran above)
if self.env_store:
try:
- env_changed = self.env_store.refresh()
if env_changed and self.alert_engine:
env_alerts = self.alert_engine.check_environmental(self.env_store)
if env_alerts:
@@ -930,6 +963,14 @@ def main() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
+ # Size the default executor generously: mesh sources + env adapters
+ # (~7 sources, ~15 env adapters) now fetch concurrently via
+ # asyncio.to_thread() every tick, so they need thread headroom to avoid
+ # queuing behind each other on the default executor's small pool.
+ loop.set_default_executor(
+ concurrent.futures.ThreadPoolExecutor(max_workers=24, thread_name_prefix="meshai-io")
+ )
+
def signal_handler(sig, frame):
logger.info(f"Received signal {sig}")
loop.create_task(bot.stop())
diff --git a/work/meshai/mesh_data_store.py b/work/meshai/mesh_data_store.py
index 44047db..97c1592 100644
--- a/work/meshai/mesh_data_store.py
+++ b/work/meshai/mesh_data_store.py
@@ -6,6 +6,7 @@ This module replaces mesh_sources.py with a clean three-layer architecture:
- Layer 3: Consumers read unified model (no field guessing)
"""
+import asyncio
import json
import logging
import sqlite3
@@ -441,12 +442,16 @@ class MeshDataStore:
if stale_nums:
logger.info(f"Purged {len(stale_nums)} stale nodes (not heard in {STALE_NODE_THRESHOLD_DAYS} days)")
- def refresh(self) -> bool:
+ async def refresh(self) -> bool:
"""Tick-based refresh. Called every second from the main loop.
- Delegates to source tick() for sources that support it.
- Only does a full rebuild when nodes/edges/topology change.
- Only does a lightweight update when only packets change.
+ Delegates to source tick() for sources that support it. Due sources'
+ tick() calls (blocking network I/O) run concurrently in worker
+ threads and are AWAITED to completion before any bookkeeping, so the
+ event loop (and therefore the dashboard) stays responsive while
+ fetches are in flight. Only does a full rebuild when nodes/edges/
+ topology change. Only does a lightweight update when only packets
+ change.
Returns:
True if any data changed
@@ -456,26 +461,38 @@ class MeshDataStore:
needs_rebuild = False
needs_packet_update = False
+ due: list[tuple[str, object]] = []
for name, source in self._sources.items():
# Check if this source supports tick-based polling
if hasattr(source, 'tick') and hasattr(source, '_tick_interval'):
if now - source._last_tick >= source._tick_interval:
- endpoint = source.tick()
- if endpoint:
- any_changed = True
- # Major changes require full rebuild
- if endpoint in ("nodes", "edges", "traceroutes", "topology", "telemetry"):
- needs_rebuild = True
- # Packet-only changes are lightweight
- elif endpoint in ("packets",):
- needs_packet_update = True
- # stats, counts, channels, solar, network just update cached data
+ due.append((name, source))
else:
# Legacy fallback for sources without tick support
if source.maybe_refresh():
any_changed = True
needs_rebuild = True
+ if due:
+ results = await asyncio.gather(
+ *(asyncio.to_thread(source.tick) for _, source in due),
+ return_exceptions=True,
+ )
+ for (name, source), result in zip(due, results):
+ if isinstance(result, Exception):
+ logger.warning(f"Source {name} tick failed: {result}")
+ continue
+ endpoint = result
+ if endpoint:
+ any_changed = True
+ # Major changes require full rebuild
+ if endpoint in ("nodes", "edges", "traceroutes", "topology", "telemetry"):
+ needs_rebuild = True
+ # Packet-only changes are lightweight
+ elif endpoint in ("packets",):
+ needs_packet_update = True
+ # stats, counts, channels, solar, network just update cached data
+
if needs_rebuild:
self._rebuild()
self._purge_stale_nodes()
diff --git a/work/meshai/notifications/evac_phase.py b/work/meshai/notifications/evac_phase.py
new file mode 100644
index 0000000..ca92cef
--- /dev/null
+++ b/work/meshai/notifications/evac_phase.py
@@ -0,0 +1,95 @@
+"""Idaho READY / SET / GO evacuation-phase detection from free-text CAP content.
+
+Real-world FEMA IPAWS civil alerts do NOT carry a machine-readable phase —
+CAP ```` does not exist in the wild. The phase (READY / SET /
+GO) instead shows up as free text inside ``headline``, ``description``, and
+the ``CMAMtext``/``CMAMlongtext`` ```` values, phrased however the
+issuing agency happened to write it ("Level 3 GO NOW", "Set to GO",
+"Evacuation Warning", ...). This module scans that free text for the phrases
+agencies actually use and returns the phase, or ``None`` when nothing
+matches — callers must never guess a phase, since a false GO would broadcast
+an evacuation order that was never issued.
+"""
+from __future__ import annotations
+
+import re
+
+# ── Strong multi-word phrases (checked case-insensitively) ───────────────────
+# ANY match sets that phase's hit flag. Order within a list is irrelevant —
+# the final result is decided purely by GO > SET > READY precedence below,
+# never by which phrase or text argument matched first.
+_GO_PHRASES = [
+ r"\bGO\s+NOW\b",
+ r"\bLEVEL\s+3\s+GO\b",
+ r"\bLEVEL\s+3\b",
+ r"\bLEVEL\s+III\b",
+ r"\bGO\s+EVACUATION\b",
+ r"\bIMMEDIATE\s+EVACUATION\b",
+ r"\bEVACUATE\s+NOW\b",
+ r"\bEVACUATION\s+ORDER\b",
+ r"\bSET\s+TO\s+GO\b", # "Set to GO" — the standalone-GO idiom, spelled out
+]
+
+_SET_PHRASES = [
+ r"\bLEVEL\s+2\b",
+ r"\bLEVEL\s+II\b",
+ r"\bPREPARE\s+TO\s+EVACUATE\b",
+ r"\bEVACUATION\s+WARNING\b",
+ r"\bBE\s+READY\s+TO\s+LEAVE\b",
+]
+
+_READY_PHRASES = [
+ r"\bLEVEL\s+1\b",
+ r"\bLEVEL\s+I\b",
+ r"\bEVACUATION\s+ADVISORY\b",
+]
+
+_PRECEDENCE = ("GO", "SET", "READY")
+
+_PHRASE_RE = {
+ "GO": re.compile("|".join(_GO_PHRASES), re.IGNORECASE),
+ "SET": re.compile("|".join(_SET_PHRASES), re.IGNORECASE),
+ "READY": re.compile("|".join(_READY_PHRASES), re.IGNORECASE),
+}
+
+# A bare level-word (GO/SET/READY) counts ONLY when it is:
+# 1. standalone (word-boundaried) AND written in the exact uppercase form
+# (narrative prose never shouts a whole word in caps: "go to the
+# fairgrounds", "set to arrive", "via Go Creek Road" all fail this), AND
+# 2. accompanied elsewhere in the same text by other alert/evacuation
+# vocabulary, so a bare "GO"/"SET"/"READY" floating in unrelated text
+# can't fire on its own.
+# This is what lets "LEVEL I SET Alert" resolve as SET (word beats numeral —
+# err upward) even though "LEVEL I" alone would read as READY.
+_STANDALONE_TOKEN_RE = {
+ "GO": re.compile(r"\bGO\b"),
+ "SET": re.compile(r"\bSET\b"),
+ "READY": re.compile(r"\bREADY\b"),
+}
+_CONTEXT_CUE_RE = re.compile(
+ r"evacuat|level|alert|status|notice|prepar|leave|order|warning|advisory",
+ re.IGNORECASE,
+)
+
+
+def detect_phase(*texts: "str | None") -> "str | None":
+ """Scan the given texts for Idaho READY/SET/GO evacuation-phase language.
+
+ All provided texts are combined and scanned together (case-insensitive
+ for the strong phrases). The HIGHEST phase found wins — GO > SET > READY
+ — never the first match. Returns None when nothing matches; never
+ guesses.
+ """
+ combined = "\n".join(t for t in texts if t)
+ if not combined:
+ return None
+
+ has_cue = bool(_CONTEXT_CUE_RE.search(combined))
+
+ for phase in _PRECEDENCE:
+ if _PHRASE_RE[phase].search(combined):
+ return phase
+ if has_cue and _STANDALONE_TOKEN_RE[phase].search(combined):
+ return phase
+
+ return None
diff --git a/work/meshai/notifications/formatters/_anchor.py b/work/meshai/notifications/formatters/_anchor.py
index 10960e1..d6fb7ee 100644
--- a/work/meshai/notifications/formatters/_anchor.py
+++ b/work/meshai/notifications/formatters/_anchor.py
@@ -96,7 +96,7 @@ def resolve_anchor(
from meshai.persistence import get_db
rows = get_db().execute(
"SELECT name, lat, lon FROM town_anchors "
- "WHERE lat IS NOT NULL AND lon IS NOT NULL"
+ "WHERE lat IS NOT NULL AND lon IS NOT NULL AND enabled = 1"
).fetchall()
best = None
best_d = float("inf")
diff --git a/work/meshai/notifications/formatters/ipaws.py b/work/meshai/notifications/formatters/ipaws.py
index 91a695d..9293214 100644
--- a/work/meshai/notifications/formatters/ipaws.py
+++ b/work/meshai/notifications/formatters/ipaws.py
@@ -6,11 +6,19 @@ Message, AMBER, 911 outage, law enforcement, HazMat) from the canonical CAP
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:
+HAZARD.../IMPACT... blocks — this formatter is headline-forward. Real CAP
+data has no machine-readable Idaho READY/SET/GO evacuation phase (there is
+no ```` in the wild); ``evac_phase.detect_phase`` scans the
+headline/CMAMtext/description free text for the phrases agencies actually
+use ("Level 3 GO NOW", "Evacuation Warning", ...) so the wire message can
+lead with the phase instead of raw CAP jargon:
- Line 1: {emoji} {prefix}{event} e.g. "🚨 Evacuation Immediate"
+ Line 1 (phase detected): {emoji} {prefix}{PHASE} — {action}
+ e.g. "🚨 GO — Leave now"
+ Line 1 (no phase found — unchanged fallback):
+ {emoji} {prefix}{event} e.g. "🚨 Evacuation Immediate"
Line 2: {area}[ · Until {t} {tz}] areaDesc (first area) + expiry
- Line 3: {headline} the operator's message
+ Line 3: {CMAMtext or headline} the agency's own public alert text
Reuses ``event.data`` (canonical) + ``_budget.fit_to_budget``; does NOT touch
the NWS formatter. ``now`` is a structural seam (not used — expiry is absolute).
@@ -21,6 +29,7 @@ import zoneinfo
from datetime import datetime
from typing import TYPE_CHECKING
+from meshai.notifications.evac_phase import detect_phase
from meshai.notifications.formatters._budget import fit_to_budget
if TYPE_CHECKING:
@@ -37,6 +46,13 @@ _CATEGORY_EMOJI = {
"emergency_civil": "⚠️",
}
+# Idaho READY/SET/GO — the one-line action tied to each detected phase.
+_PHASE_ACTION = {
+ "READY": "Get prepared",
+ "SET": "Be ready to leave",
+ "GO": "Leave now",
+}
+
def format(event: "Event", *, now: float, budget: int) -> str:
"""Render the IPAWS civil-alert wire string from canonical event.data.
@@ -54,6 +70,11 @@ def format(event: "Event", *, now: float, budget: int) -> str:
event_type = d.get("event") or "Emergency Alert"
area_desc = d.get("area_desc") or ""
headline = (d.get("headline") or "").strip()
+ description = d.get("description") or ""
+ parameters = d.get("parameters") or {}
+ cmam_text = parameters.get("CMAMtext") or ""
+ if isinstance(cmam_text, list): # defensive: a repeated valueName collapses to a list
+ cmam_text = cmam_text[0] if cmam_text else ""
expires_epoch = d.get("expires_at")
prefix = d.get("_ipaws_prefix") or ""
category = d.get("category") or event.category or "emergency_civil"
@@ -61,8 +82,20 @@ def format(event: "Event", *, now: float, budget: int) -> str:
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 1: lead with the Idaho READY/SET/GO phase when the free text
+ # actually names one; otherwise keep the raw CAP event string (safe
+ # fallback — never invent a phase that isn't there).
+ phase = detect_phase(headline, cmam_text, description)
+ if phase:
+ # A detected GO always gets the alarm emoji, regardless of CAP
+ # category — a confirmed "leave now" evacuation must never render
+ # with the softer category-based ⚠️ (e.g. emergency_civil).
+ if phase == "GO":
+ emoji = "🚨"
+ action = _PHASE_ACTION[phase]
+ line1 = f"{emoji} {prefix_seg}{phase} — {action}"
+ else:
+ 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()
@@ -79,8 +112,9 @@ def format(event: "Event", *, now: float, budget: int) -> str:
else:
line2 = area or time_seg
- # Line 3: the headline (the actual civil message)
- line3 = headline
+ # Line 3: the agency's own public alert text (CMAMtext — purpose-written
+ # for WEA, ~90 chars) when present; else fall back to the headline as before.
+ line3 = cmam_text.strip() or 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/fire.py b/work/meshai/notifications/gating/fire.py
index 0ee5c8e..471afd4 100644
--- a/work/meshai/notifications/gating/fire.py
+++ b/work/meshai/notifications/gating/fire.py
@@ -151,10 +151,38 @@ def decide(data: dict, *, source: str, now: float) -> GateResult:
contained_pct = data.get("contained_pct")
declared_at_epoch = data.get("declared_at_epoch")
- row = conn.execute(
- "SELECT current_acres, current_contained_pct, last_broadcast_at, "
- "last_broadcast_acres, last_broadcast_contained "
- "FROM fires WHERE irwin_id = ?", (irwin_id,)).fetchone()
+ try:
+ row = conn.execute(
+ "SELECT current_acres, current_contained_pct, last_broadcast_at, "
+ "last_broadcast_acres, last_broadcast_contained, tombstoned_at "
+ "FROM fires WHERE irwin_id = ?", (irwin_id,)).fetchone()
+ except Exception:
+ # Defensive: an un-migrated schema without the tombstoned_at column
+ # (pre-v12) must not crash the decider. Fall back to the narrower
+ # column set; the tombstone gate below then reads as "not tombstoned"
+ # for this row rather than raising.
+ row = conn.execute(
+ "SELECT current_acres, current_contained_pct, last_broadcast_at, "
+ "last_broadcast_acres, last_broadcast_contained "
+ "FROM fires WHERE irwin_id = ?", (irwin_id,)).fetchone()
+
+ # ── Tombstone mute — durable operator kill-switch ───────────────────────
+ # A non-null fires.tombstoned_at (the SAME column the dashboard's active
+ # views already filter on -- /api/env/active WHERE tombstoned_at IS NULL)
+ # means this incident is closed out / muted. It must never broadcast
+ # again through ANY path below (new, update, or cooldown-suppress is a
+ # no-op here anyway), so this check runs BEFORE the row-missing / growth
+ # / cooldown branches. Defensive: a row without the column, or missing
+ # the key for any other reason, reads as "not tombstoned" rather than
+ # raising -- it must never crash the decider.
+ if row is not None:
+ try:
+ tombstoned_at = row["tombstoned_at"]
+ except (IndexError, KeyError, TypeError):
+ tombstoned_at = None
+ if tombstoned_at is not None:
+ return GateResult(broadcast=False, lifecycle="suppress",
+ reason=f"incident tombstoned irwin={irwin_id}")
# Stamps every active broadcast (New + Update) carries. category override
# is added only for New (Update keeps the envelope-derived wildfire_incident).
diff --git a/work/meshai/notifications/pipeline/dispatcher.py b/work/meshai/notifications/pipeline/dispatcher.py
index aafc5f6..3034af2 100644
--- a/work/meshai/notifications/pipeline/dispatcher.py
+++ b/work/meshai/notifications/pipeline/dispatcher.py
@@ -359,6 +359,18 @@ class Dispatcher:
# v0.6-3b: fire toggle uses wfigs adapter_config freshness (0 = disabled)
if fam == "fire":
freshness_s = int(adapter_config.wfigs.freshness_seconds)
+ elif event.category == "earthquake_event":
+ # The upstream USGS feed is a rolling PAST-DAY feed
+ # (2.5_day.geojson), so a quake's age-at-ingest routinely exceeds
+ # the generic 600s toggle window even for a genuinely first-seen
+ # event -- every native quake was being silently dropped here
+ # (measured age-at-ingest 548s-72169s across 12 sampled quakes,
+ # confirmed against quake_events: 12/12 first-sighted rows with
+ # last_broadcast_at=NULL). Scoped to the earthquake_event
+ # CATEGORY, not the "seismic" family/toggle, because that family
+ # also carries stream_flood_warning/stream_high_water (hydro),
+ # which must keep using the generic per-toggle freshness_seconds.
+ freshness_s = int(adapter_config.usgs_quake.freshness_seconds)
else:
freshness_s = int(getattr(tog, "freshness_seconds", 600) or 600)
if event.timestamp and freshness_s > 0:
diff --git a/work/meshai/persistence/curation.py b/work/meshai/persistence/curation.py
index 0c69430..84978dd 100644
--- a/work/meshai/persistence/curation.py
+++ b/work/meshai/persistence/curation.py
@@ -85,35 +85,192 @@ _GAUGE_SITES_SEED: dict[str, dict[str, Any]] = {
# Idaho + neighbor towns, originally from a hardcoded _TOWN_COORDS table in
# the (since-deleted) Central-envelope adapter-normalizer module.
_TOWN_ANCHORS_SEED: dict[str, dict[str, Any]] = {
- "boise": {"lat": 43.6150, "lon": -116.2023, "state": "ID"},
- "meridian": {"lat": 43.6121, "lon": -116.3915, "state": "ID"},
- "nampa": {"lat": 43.5407, "lon": -116.5635, "state": "ID"},
- "caldwell": {"lat": 43.6629, "lon": -116.6874, "state": "ID"},
- "idaho falls": {"lat": 43.4666, "lon": -112.0340, "state": "ID"},
- "pocatello": {"lat": 42.8713, "lon": -112.4455, "state": "ID"},
- "twin falls": {"lat": 42.5630, "lon": -114.4609, "state": "ID"},
- "coeur d'alene": {"lat": 47.6777, "lon": -116.7805, "state": "ID"},
- "lewiston": {"lat": 46.4165, "lon": -117.0177, "state": "ID"},
- "moscow": {"lat": 46.7324, "lon": -117.0002, "state": "ID"},
- "sandpoint": {"lat": 48.2766, "lon": -116.5535, "state": "ID"},
- "post falls": {"lat": 47.7180, "lon": -116.9516, "state": "ID"},
- "hayden": {"lat": 47.7660, "lon": -116.7866, "state": "ID"},
- "rathdrum": {"lat": 47.8121, "lon": -116.8950, "state": "ID"},
- "plummer": {"lat": 47.3344, "lon": -116.8856, "state": "ID"},
- "kellogg": {"lat": 47.5380, "lon": -116.1352, "state": "ID"},
- "bonners ferry": {"lat": 48.6914, "lon": -116.3181, "state": "ID"},
- "rexburg": {"lat": 43.8260, "lon": -111.7897, "state": "ID"},
- "blackfoot": {"lat": 43.1905, "lon": -112.3447, "state": "ID"},
- "burley": {"lat": 42.5360, "lon": -113.7928, "state": "ID"},
- "jerome": {"lat": 42.7252, "lon": -114.5187, "state": "ID"},
- "mountain home": {"lat": 43.1330, "lon": -115.6912, "state": "ID"},
- "stanley": {"lat": 44.2160, "lon": -114.9311, "state": "ID"},
- "salmon": {"lat": 45.1758, "lon": -113.8957, "state": "ID"},
- "mccall": {"lat": 44.9111, "lon": -116.0987, "state": "ID"},
- "weiser": {"lat": 44.2510, "lon": -116.9690, "state": "ID"},
- "soda springs": {"lat": 42.6543, "lon": -111.6047, "state": "ID"},
- "preston": {"lat": 42.0963, "lon": -111.8766, "state": "ID"},
- "montpelier": {"lat": 42.3232, "lon": -111.2980, "state": "ID"},
+ "aberdeen": {"lat": 42.944098, "lon": -112.838381, "state": "ID"},
+ "albion": {"lat": 42.409808, "lon": -113.580438, "state": "ID"},
+ "american falls": {"lat": 42.782846, "lon": -112.854211, "state": "ID"},
+ "ammon": {"lat": 43.474999, "lon": -111.959631, "state": "ID"},
+ "arbon valley": {"lat": 42.88763, "lon": -112.589386, "state": "ID"},
+ "arco": {"lat": 43.631893, "lon": -113.301033, "state": "ID"},
+ "arimo": {"lat": 42.560385, "lon": -112.172927, "state": "ID"},
+ "ashton": {"lat": 44.073332, "lon": -111.448311, "state": "ID"},
+ "athol": {"lat": 47.947065, "lon": -116.707958, "state": "ID"},
+ "avimor": {"lat": 43.776181, "lon": -116.257108, "state": "ID"},
+ "bancroft": {"lat": 42.720241, "lon": -111.88301, "state": "ID"},
+ "basalt": {"lat": 43.314443, "lon": -112.165044, "state": "ID"},
+ "bellevue": {"lat": 43.467894, "lon": -114.254955, "state": "ID"},
+ "bennington": {"lat": 42.382259, "lon": -111.32098, "state": "ID"},
+ "blackfoot": {"lat": 43.1905, "lon": -112.3447, "state": "ID"},
+ "blanchard": {"lat": 48.0137, "lon": -116.996503, "state": "ID"},
+ "bliss": {"lat": 42.924284, "lon": -114.947516, "state": "ID"},
+ "boise": {"lat": 43.615, "lon": -116.2023, "state": "ID"},
+ "bonners ferry": {"lat": 48.6914, "lon": -116.3181, "state": "ID"},
+ "buhl": {"lat": 42.598362, "lon": -114.759536, "state": "ID"},
+ "burley": {"lat": 42.536, "lon": -113.7928, "state": "ID"},
+ "caldwell": {"lat": 43.6629, "lon": -116.6874, "state": "ID"},
+ "cambridge": {"lat": 44.571748, "lon": -116.678101, "state": "ID"},
+ "carey": {"lat": 43.312011, "lon": -113.941008, "state": "ID"},
+ "cascade": {"lat": 44.508761, "lon": -116.043627, "state": "ID"},
+ "castleford": {"lat": 42.520569, "lon": -114.871806, "state": "ID"},
+ "challis": {"lat": 44.505779, "lon": -114.228184, "state": "ID"},
+ "chubbuck": {"lat": 42.926182, "lon": -112.462537, "state": "ID"},
+ "clark fork": {"lat": 48.148019, "lon": -116.172988, "state": "ID"},
+ "clifton": {"lat": 42.187286, "lon": -112.004596, "state": "ID"},
+ "coeur d'alene": {"lat": 47.6777, "lon": -116.7805, "state": "ID"},
+ "cottonwood": {"lat": 46.051045, "lon": -116.349751, "state": "ID"},
+ "council": {"lat": 44.733195, "lon": -116.436837, "state": "ID"},
+ "craigmont": {"lat": 46.24174, "lon": -116.471344, "state": "ID"},
+ "culdesac": {"lat": 46.374896, "lon": -116.670097, "state": "ID"},
+ "dalton gardens": {"lat": 47.733412, "lon": -116.767873, "state": "ID"},
+ "dayton": {"lat": 42.111246, "lon": -111.984615, "state": "ID"},
+ "deary": {"lat": 46.800586, "lon": -116.557368, "state": "ID"},
+ "declo": {"lat": 42.519599, "lon": -113.628732, "state": "ID"},
+ "dietrich": {"lat": 42.912796, "lon": -114.266289, "state": "ID"},
+ "donnelly": {"lat": 44.733391, "lon": -116.086821, "state": "ID"},
+ "dover": {"lat": 48.259156, "lon": -116.609843, "state": "ID"},
+ "downey": {"lat": 42.428828, "lon": -112.123303, "state": "ID"},
+ "driggs": {"lat": 43.729865, "lon": -111.104319, "state": "ID"},
+ "dubois": {"lat": 44.171161, "lon": -112.228278, "state": "ID"},
+ "eagle": {"lat": 43.693423, "lon": -116.345989, "state": "ID"},
+ "east hope": {"lat": 48.240895, "lon": -116.28941, "state": "ID"},
+ "eden": {"lat": 42.605309, "lon": -114.209087, "state": "ID"},
+ "emmett": {"lat": 43.869228, "lon": -116.491336, "state": "ID"},
+ "fairfield": {"lat": 43.348045, "lon": -114.800826, "state": "ID"},
+ "fernwood": {"lat": 47.115099, "lon": -116.386484, "state": "ID"},
+ "filer": {"lat": 42.56789, "lon": -114.611471, "state": "ID"},
+ "firth": {"lat": 43.305788, "lon": -112.183454, "state": "ID"},
+ "fort hall": {"lat": 43.014547, "lon": -112.45787, "state": "ID"},
+ "franklin": {"lat": 42.009503, "lon": -111.802183, "state": "ID"},
+ "fruitland": {"lat": 44.020412, "lon": -116.922109, "state": "ID"},
+ "garden city": {"lat": 43.668297, "lon": -116.294389, "state": "ID"},
+ "garden valley": {"lat": 44.083404, "lon": -115.958464, "state": "ID"},
+ "genesee": {"lat": 46.551608, "lon": -116.928389, "state": "ID"},
+ "georgetown": {"lat": 42.479083, "lon": -111.363497, "state": "ID"},
+ "glenns ferry": {"lat": 42.949908, "lon": -115.308185, "state": "ID"},
+ "gooding": {"lat": 42.937048, "lon": -114.713188, "state": "ID"},
+ "grace": {"lat": 42.575182, "lon": -111.729771, "state": "ID"},
+ "grand view": {"lat": 42.985123, "lon": -116.09368, "state": "ID"},
+ "grangeville": {"lat": 45.925826, "lon": -116.121916, "state": "ID"},
+ "greenleaf": {"lat": 43.672607, "lon": -116.821443, "state": "ID"},
+ "groveland": {"lat": 43.223483, "lon": -112.37547, "state": "ID"},
+ "hagerman": {"lat": 42.816016, "lon": -114.897681, "state": "ID"},
+ "hailey": {"lat": 43.512674, "lon": -114.299499, "state": "ID"},
+ "hammett": {"lat": 42.944114, "lon": -115.465521, "state": "ID"},
+ "hansen": {"lat": 42.531365, "lon": -114.301177, "state": "ID"},
+ "harrison": {"lat": 47.469582, "lon": -116.808336, "state": "ID"},
+ "hauser": {"lat": 47.773694, "lon": -117.008, "state": "ID"},
+ "hayden": {"lat": 47.766, "lon": -116.7866, "state": "ID"},
+ "hayden lake": {"lat": 47.76446, "lon": -116.756097, "state": "ID"},
+ "hazelton": {"lat": 42.59548, "lon": -114.136614, "state": "ID"},
+ "heyburn": {"lat": 42.559982, "lon": -113.762067, "state": "ID"},
+ "hidden springs": {"lat": 43.716541, "lon": -116.259415, "state": "ID"},
+ "hollister": {"lat": 42.352911, "lon": -114.583846, "state": "ID"},
+ "homedale": {"lat": 43.615937, "lon": -116.939029, "state": "ID"},
+ "horseshoe bend": {"lat": 43.916085, "lon": -116.199236, "state": "ID"},
+ "idaho city": {"lat": 43.827844, "lon": -115.830474, "state": "ID"},
+ "idaho falls": {"lat": 43.4666, "lon": -112.034, "state": "ID"},
+ "inkom": {"lat": 42.796548, "lon": -112.254625, "state": "ID"},
+ "iona": {"lat": 43.527007, "lon": -111.930914, "state": "ID"},
+ "irwin": {"lat": 43.403359, "lon": -111.279513, "state": "ID"},
+ "jerome": {"lat": 42.7252, "lon": -114.5187, "state": "ID"},
+ "juliaetta": {"lat": 46.574737, "lon": -116.70808, "state": "ID"},
+ "kamiah": {"lat": 46.226796, "lon": -116.028303, "state": "ID"},
+ "kellogg": {"lat": 47.538, "lon": -116.1352, "state": "ID"},
+ "kendrick": {"lat": 46.614183, "lon": -116.661272, "state": "ID"},
+ "ketchum": {"lat": 43.687718, "lon": -114.380069, "state": "ID"},
+ "kimberly": {"lat": 42.534299, "lon": -114.369931, "state": "ID"},
+ "kooskia": {"lat": 46.141687, "lon": -115.973646, "state": "ID"},
+ "kootenai": {"lat": 48.311831, "lon": -116.517128, "state": "ID"},
+ "kuna": {"lat": 43.469607, "lon": -116.424153, "state": "ID"},
+ "laclede": {"lat": 48.167129, "lon": -116.751565, "state": "ID"},
+ "lapwai": {"lat": 46.403715, "lon": -116.804223, "state": "ID"},
+ "lava hot springs": {"lat": 42.620107, "lon": -112.009902, "state": "ID"},
+ "lewiston": {"lat": 46.4165, "lon": -117.0177, "state": "ID"},
+ "lewisville": {"lat": 43.695208, "lon": -112.013232, "state": "ID"},
+ "lincoln": {"lat": 43.51825, "lon": -111.969215, "state": "ID"},
+ "mackay": {"lat": 43.911996, "lon": -113.612728, "state": "ID"},
+ "malad city": {"lat": 42.189909, "lon": -112.249688, "state": "ID"},
+ "marsing": {"lat": 43.54636, "lon": -116.810422, "state": "ID"},
+ "mccall": {"lat": 44.9111, "lon": -116.0987, "state": "ID"},
+ "mccammon": {"lat": 42.648236, "lon": -112.189394, "state": "ID"},
+ "melba": {"lat": 43.373633, "lon": -116.531933, "state": "ID"},
+ "menan": {"lat": 43.721791, "lon": -111.992353, "state": "ID"},
+ "meridian": {"lat": 43.6121, "lon": -116.3915, "state": "ID"},
+ "middleton": {"lat": 43.711593, "lon": -116.615008, "state": "ID"},
+ "montpelier": {"lat": 42.3232, "lon": -111.298, "state": "ID"},
+ "moreland": {"lat": 43.21948, "lon": -112.437772, "state": "ID"},
+ "moscow": {"lat": 46.7324, "lon": -117.0002, "state": "ID"},
+ "mountain home": {"lat": 43.133, "lon": -115.6912, "state": "ID"},
+ "mountain home afb": {"lat": 43.049186, "lon": -115.86586, "state": "ID"},
+ "moyie springs": {"lat": 48.724746, "lon": -116.195421, "state": "ID"},
+ "mud lake": {"lat": 43.842855, "lon": -112.479504, "state": "ID"},
+ "mullan": {"lat": 47.468759, "lon": -115.796351, "state": "ID"},
+ "nampa": {"lat": 43.5407, "lon": -116.5635, "state": "ID"},
+ "new meadows": {"lat": 44.971335, "lon": -116.285195, "state": "ID"},
+ "new plymouth": {"lat": 43.970417, "lon": -116.818781, "state": "ID"},
+ "newdale": {"lat": 43.886385, "lon": -111.603888, "state": "ID"},
+ "nezperce": {"lat": 46.233582, "lon": -116.241418, "state": "ID"},
+ "notus": {"lat": 43.726863, "lon": -116.800432, "state": "ID"},
+ "oakley": {"lat": 42.24206, "lon": -113.883058, "state": "ID"},
+ "oldtown": {"lat": 48.182488, "lon": -117.018005, "state": "ID"},
+ "orofino": {"lat": 46.484943, "lon": -116.253027, "state": "ID"},
+ "osburn": {"lat": 47.505731, "lon": -116.000709, "state": "ID"},
+ "paris": {"lat": 42.227978, "lon": -111.402424, "state": "ID"},
+ "parker": {"lat": 43.958405, "lon": -111.759206, "state": "ID"},
+ "parma": {"lat": 43.786284, "lon": -116.942491, "state": "ID"},
+ "paul": {"lat": 42.605496, "lon": -113.784487, "state": "ID"},
+ "payette": {"lat": 44.080093, "lon": -116.926852, "state": "ID"},
+ "pierce": {"lat": 46.495335, "lon": -115.803292, "state": "ID"},
+ "pinehurst": {"lat": 47.536314, "lon": -116.231746, "state": "ID"},
+ "plummer": {"lat": 47.3344, "lon": -116.8856, "state": "ID"},
+ "pocatello": {"lat": 42.8713, "lon": -112.4455, "state": "ID"},
+ "ponderay": {"lat": 48.30478, "lon": -116.536645, "state": "ID"},
+ "post falls": {"lat": 47.718, "lon": -116.9516, "state": "ID"},
+ "potlatch": {"lat": 46.923493, "lon": -116.897713, "state": "ID"},
+ "preston": {"lat": 42.0963, "lon": -111.8766, "state": "ID"},
+ "priest river": {"lat": 48.18336, "lon": -116.884354, "state": "ID"},
+ "rathdrum": {"lat": 47.8121, "lon": -116.895, "state": "ID"},
+ "rexburg": {"lat": 43.826, "lon": -111.7897, "state": "ID"},
+ "richfield": {"lat": 43.05164, "lon": -114.155942, "state": "ID"},
+ "rigby": {"lat": 43.673587, "lon": -111.913525, "state": "ID"},
+ "riggins": {"lat": 45.420591, "lon": -116.317636, "state": "ID"},
+ "ririe": {"lat": 43.632494, "lon": -111.771717, "state": "ID"},
+ "riverside": {"lat": 43.196554, "lon": -112.435625, "state": "ID"},
+ "roberts": {"lat": 43.720488, "lon": -112.128868, "state": "ID"},
+ "robie creek": {"lat": 43.667649, "lon": -116.015203, "state": "ID"},
+ "rockford": {"lat": 43.189235, "lon": -112.530618, "state": "ID"},
+ "rockford bay": {"lat": 47.508637, "lon": -116.886536, "state": "ID"},
+ "rockland": {"lat": 42.573157, "lon": -112.87453, "state": "ID"},
+ "rupert": {"lat": 42.618936, "lon": -113.673967, "state": "ID"},
+ "salmon": {"lat": 45.1758, "lon": -113.8957, "state": "ID"},
+ "sandpoint": {"lat": 48.2766, "lon": -116.5535, "state": "ID"},
+ "shelley": {"lat": 43.379538, "lon": -112.126098, "state": "ID"},
+ "shoshone": {"lat": 42.936185, "lon": -114.404747, "state": "ID"},
+ "silverton": {"lat": 47.495681, "lon": -115.960513, "state": "ID"},
+ "smelterville": {"lat": 47.542423, "lon": -116.177448, "state": "ID"},
+ "soda springs": {"lat": 42.6543, "lon": -111.6047, "state": "ID"},
+ "spirit lake": {"lat": 47.965799, "lon": -116.869831, "state": "ID"},
+ "st. anthony": {"lat": 43.964839, "lon": -111.685049, "state": "ID"},
+ "st. maries": {"lat": 47.314589, "lon": -116.572235, "state": "ID"},
+ "stanley": {"lat": 44.216, "lon": -114.9311, "state": "ID"},
+ "star": {"lat": 43.702788, "lon": -116.491025, "state": "ID"},
+ "sugar city": {"lat": 43.87582, "lon": -111.751032, "state": "ID"},
+ "sun valley": {"lat": 43.683852, "lon": -114.334203, "state": "ID"},
+ "swan valley": {"lat": 43.442575, "lon": -111.324544, "state": "ID"},
+ "teton": {"lat": 43.887773, "lon": -111.672254, "state": "ID"},
+ "tetonia": {"lat": 43.814578, "lon": -111.158664, "state": "ID"},
+ "troy": {"lat": 46.737981, "lon": -116.773154, "state": "ID"},
+ "twin falls": {"lat": 42.563, "lon": -114.4609, "state": "ID"},
+ "tyhee": {"lat": 42.954001, "lon": -112.456199, "state": "ID"},
+ "ucon": {"lat": 43.593538, "lon": -111.959358, "state": "ID"},
+ "victor": {"lat": 43.60155, "lon": -111.110822, "state": "ID"},
+ "wallace": {"lat": 47.473578, "lon": -115.922542, "state": "ID"},
+ "weippe": {"lat": 46.37825, "lon": -115.938844, "state": "ID"},
+ "weiser": {"lat": 44.251, "lon": -116.969, "state": "ID"},
+ "wendell": {"lat": 42.77468, "lon": -114.70296, "state": "ID"},
+ "weston": {"lat": 42.036209, "lon": -111.977799, "state": "ID"},
+ "wilder": {"lat": 43.678388, "lon": -116.907585, "state": "ID"},
+ "winchester": {"lat": 46.240812, "lon": -116.624137, "state": "ID"},
+ "worley": {"lat": 47.400533, "lon": -116.919254, "state": "ID"},
}
diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py
index 9be229d..221684b 100644
--- a/work/meshai/transport/meshcore_transport.py
+++ b/work/meshai/transport/meshcore_transport.py
@@ -36,6 +36,36 @@ _TELEMETRY_MIN_INTERVAL_SECONDS = 300
# dropped from the auto-poll rotation (a manual "Poll now" un-sticks it).
_TELEMETRY_MAX_FAILURES = 3
+# --- Companion-link keepalive tuning --------------------------------------
+# MeshMonitor's MeshCore vnode (the shared companion-link server meshai
+# attaches to) reaps any client idle >5 min, where "idle" means no bytes seen
+# FROM the client — a periodic LOCAL query resets that clock. 120s is well
+# inside the 300s reaper window with margin to spare.
+_KEEPALIVE_INTERVAL_SECONDS = 120
+
+# --- Reconnect persistence ("0 = unlimited" sentinel) ----------------------
+# config.py documents meshcore_max_reconnect_attempts as "0 = unlimited", but
+# that sentinel was never implemented here — the value was passed straight
+# through to the meshcore lib's ConnectionManager, whose retry loop is
+# `while self._reconnect_attempts < self.max_reconnect_attempts`. Taken
+# literally, 0 means ZERO attempts (immediate give-up), the opposite of
+# "unlimited", and any small bounded value (the shipped default is 5, at the
+# lib's flat 1s-per-attempt cadence) exhausts after ~5 seconds and then the
+# link stays down PERMANENTLY — there is no external supervisor for MeshCore
+# (see main.py's watchdog guard: "MeshCoreTransport manages its own
+# reconnect via the meshcore lib's auto_reconnect parameter"), so nothing
+# ever notices and retries again after that. A radio/vnode bounce longer
+# than ~5s (e.g. the 2026-08-02 device-perm heal test) killed MeshCore for
+# good until a manual container restart.
+#
+# Fix: honor the documented sentinel for real. connect() below translates a
+# configured 0 into this effectively-unbounded count, so the lib's own
+# proven-safe retry loop (still local TCP only, still ~1 attempt/sec, still
+# WITHOUT re-sending the connect-time self-advert — see
+# _post_reconnect_setup_async) just keeps going until the vnode/radio comes
+# back, no matter how long the outage lasts.
+_MC_RECONNECT_ATTEMPTS_UNLIMITED = 2_147_483_647
+
# Numeric Cayenne-LPP type id → decoded field name. Ids not in this map are
# passed through as ``lpp_`` so nothing is silently dropped.
_LPP_ID_TO_FIELD = {
@@ -132,6 +162,8 @@ class MeshCoreTransport(MeshTransport):
self._advert_task = None
# asyncio.Task handle for the telemetry auto-poll loop; None when inactive.
self._telemetry_task = None
+ # asyncio.Task handle for the companion-link keepalive loop; None when inactive.
+ self._keepalive_task = None
# Telemetry availability/bookkeeping (shared by poller + on-demand):
# _telemetry_cache: contact-id -> {contact, data, polled_at, available}
# _telemetry_failures: contact-id -> consecutive-timeout count
@@ -577,15 +609,37 @@ class MeshCoreTransport(MeshTransport):
)
return acked or (not result.is_error())
+ def _resolve_mc_channel_idx(self, meshcore_channel: str) -> Optional[int]:
+ """Resolve a config channel name to the companion's channel slot.
+
+ Tries an exact match first (fast path, preserves existing behavior
+ for e.g. ``#bot``). Falls back to a match that ignores a single
+ leading ``#`` and case, since after the radio moved to MeshMonitor's
+ vnode the companion enumerates region channels WITHOUT the leading
+ ``#`` that meshai's region_routes config still carries (e.g. config
+ ``#sc-id-aida`` vs. companion ``sc-id-aida``) — same channel/key,
+ just a display-name difference upstream.
+ """
+ idx = self._chan_name_to_idx.get(meshcore_channel)
+ if idx is not None:
+ return idx
+ canon = meshcore_channel[1:] if meshcore_channel.startswith("#") else meshcore_channel
+ canon = canon.casefold()
+ for name, slot in self._chan_name_to_idx.items():
+ name_canon = name[1:] if name.startswith("#") else name
+ if name_canon.casefold() == canon:
+ return slot
+ return None
+
async def _do_mc_broadcast_async(self, text: str, meshcore_channel: str) -> bool:
"""Channel broadcast on the MC loop (replaces send_message() broadcast branch)."""
if self._mc is None:
return False
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
# Lazy async re-enumeration (no _run_coro deadlock risk).
await self._enumerate_channels_async()
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
logger.warning("MC channel '%s' not on companion; skipping", meshcore_channel)
return False
@@ -1776,6 +1830,66 @@ class MeshCoreTransport(MeshTransport):
if task is not None and self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(task.cancel)
+ # ------------------------------------------------------------------
+ # Companion-link keepalive (Task on the dedicated loop)
+ # ------------------------------------------------------------------
+
+ async def _keepalive_loop(self) -> None:
+ """Quiet LOCAL companion-link keepalive (Task on the dedicated loop).
+
+ MeshMonitor's MeshCore vnode disconnects any client idle >5 min,
+ where "idle" means no bytes seen FROM the client — its
+ ``lastActivity`` only updates on data we send it, never on data it
+ sends us. The self-advert (every 24h by default) and telemetry poll
+ (30 min default, and only when contacts are configured) are both far
+ too infrequent to keep that clock fresh, so the link was silently
+ reaped and never recovered (``meshcore_auto_reconnect`` is the
+ recovery safety net; this loop is the prevention).
+
+ Every ``_KEEPALIVE_INTERVAL_SECONDS`` (while connected), issues
+ ``commands.get_time()`` — a single-byte companion opcode (CMD 0x05)
+ that reads the node's own onboard clock and returns CURRENT_TIME.
+ It carries no destination/contact and has no mesh-routing semantics
+ (unlike send_advert/send_msg/send_chan_msg), so the firmware answers
+ it purely locally over the companion link — it does not key the
+ radio or emit an RF packet. Runs directly on the MC loop (NOT
+ through the send queue/pacing — it is a device-info query, not a
+ mesh send, so it should never wait behind or delay a real send).
+
+ Stops on CancelledError (disconnect). A transient query failure is
+ logged and ignored — the loop keeps ticking every interval either
+ way, since the point is resetting the vnode's clock on our next
+ successful frame, not the query result itself.
+ """
+ try:
+ while True:
+ await asyncio.sleep(_KEEPALIVE_INTERVAL_SECONDS)
+ if not self._connected or self._mc is None:
+ return
+ try:
+ await self._mc.commands.get_time()
+ logger.debug("MC: companion-link keepalive query sent")
+ except Exception as exc:
+ logger.debug("MC: keepalive get_time failed (non-fatal): %s", exc)
+ except asyncio.CancelledError:
+ logger.debug("MC: keepalive task cancelled")
+ raise
+
+ def _schedule_keepalive(self) -> None:
+ """Create the keepalive asyncio.Task on the dedicated loop (thread-safe)."""
+ def _arm() -> None:
+ self._keepalive_task = asyncio.get_event_loop().create_task(
+ self._keepalive_loop()
+ )
+ self._loop.call_soon_threadsafe(_arm)
+
+ def _cancel_keepalive(self) -> None:
+ """Cancel the keepalive task (thread-safe). Called at disconnect."""
+ task = self._keepalive_task
+ self._keepalive_task = None
+ if task is not None and self._loop is not None and self._loop.is_running():
+ self._loop.call_soon_threadsafe(task.cancel)
+
# ------------------------------------------------------------------
# Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------
@@ -1885,6 +1999,16 @@ class MeshCoreTransport(MeshTransport):
ble_address = getattr(self.config, "meshcore_ble_address", "")
auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True)
max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5)
+ if max_attempts <= 0:
+ # Documented sentinel (config.py: "0 = unlimited") — see
+ # _MC_RECONNECT_ATTEMPTS_UNLIMITED's docstring for why this was
+ # never actually unlimited before and why translating it here is
+ # the fix.
+ logger.info(
+ "MeshCoreTransport: meshcore_max_reconnect_attempts=%s (unlimited) -> %d",
+ max_attempts, _MC_RECONNECT_ATTEMPTS_UNLIMITED,
+ )
+ max_attempts = _MC_RECONNECT_ATTEMPTS_UNLIMITED
# Human-readable target for logging — from the same descriptor that
# self_info() reports, so the log and the API never disagree.
@@ -1963,6 +2087,12 @@ class MeshCoreTransport(MeshTransport):
if telem_interval > 0:
self._schedule_telemetry_poll()
+ # Arm the quiet local companion-link keepalive — unconditional (not
+ # a mesh operation, no config gate): protects against the
+ # MeshMonitor vnode's 5-min idle reaper regardless of advert/
+ # telemetry cadence.
+ self._schedule_keepalive()
+
logger.info(
"MeshCoreTransport: connected as %s (pubkey %s)",
self._self_info.get("name", "unknown"),
@@ -1971,9 +2101,10 @@ class MeshCoreTransport(MeshTransport):
def disconnect(self) -> None:
"""Disconnect and stop the event loop thread."""
- # Cancel periodic advert + telemetry poll before tearing down the loop.
+ # Cancel periodic advert + telemetry poll + keepalive before tearing down the loop.
self._cancel_periodic_advert()
self._cancel_telemetry_poll()
+ self._cancel_keepalive()
if self._mc is not None:
try:
self._run_coro(self._do_disconnect(), timeout=10.0)
@@ -2078,12 +2209,12 @@ class MeshCoreTransport(MeshTransport):
)
return True
# Resolve NAME → slot against the live companion channel table.
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
# One lazy re-enumeration in case the table changed since
# connect (e.g. a channel was provisioned after startup).
self._enumerate_channels()
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
# Never blind-send to a guessed slot.
logger.warning(
@@ -2263,10 +2394,65 @@ class MeshCoreTransport(MeshTransport):
self._connected = False
logger.warning("MeshCoreTransport: DISCONNECTED event received")
+ async def _post_reconnect_setup_async(self) -> None:
+ """Redo connect()'s LOCAL post-connect setup after an auto-reconnect.
+
+ connect() does this setup once, on the initial connect: rebuild
+ ``_chan_name_to_idx`` (so channel-name broadcasts can resolve a
+ slot) and arm the companion-link keepalive (so MeshMonitor's vnode
+ doesn't reap the link again at 5 min idle). The meshcore lib's
+ auto-reconnect only re-establishes the socket and fires CONNECTED
+ (-> ``_on_connect_event``) — it does not repeat that setup, so a
+ reconnected link was left with an empty channel table and no
+ keepalive until the reaper cut it again.
+
+ Both steps here are local companion queries/timers only —
+ ``_enumerate_channels_async`` calls ``get_channel()`` and the
+ keepalive calls ``get_time()`` (see their docstrings); neither
+ keys the radio or emits an RF packet. This deliberately excludes
+ connect()'s ``send_advert()`` — that IS a transmission, and must
+ stay confined to the initial connect() path, never replayed on
+ reconnect.
+
+ Keepalive re-arm is cancel-then-schedule (idempotent) so it never
+ double-schedules the task.
+
+ Runs as a fire-and-forget task on the dedicated MC loop (see
+ ``_on_connect_event``) rather than being awaited inline via
+ ``_run_coro``: the meshcore lib invokes ``_on_connect_event`` from
+ within that same loop (like ``_on_new_contact``), so a blocking
+ ``_run_coro().result()`` call here would deadlock it.
+ """
+ try:
+ await self._enumerate_channels_async()
+ except Exception:
+ logger.warning(
+ "MeshCore: post-reconnect channel re-enumeration failed", exc_info=True
+ )
+ try:
+ self._cancel_keepalive()
+ self._schedule_keepalive()
+ except Exception:
+ logger.warning(
+ "MeshCore: post-reconnect keepalive re-arm failed", exc_info=True
+ )
+
def _on_connect_event(self, event=None) -> None:
- """Track link state: CONNECTED (auto-reconnect succeeded)."""
+ """Track link state: CONNECTED (auto-reconnect succeeded).
+
+ Schedules ``_post_reconnect_setup_async`` fire-and-forget on the
+ dedicated MC loop — see that method's docstring for why this must
+ not block (``_run_coro`` would deadlock from inside this callback,
+ exactly as noted in ``_on_new_contact``).
+ """
self._connected = True
logger.info("MeshCoreTransport: CONNECTED event received")
+ try:
+ loop = getattr(self, "_loop", None)
+ if loop is not None and loop.is_running():
+ asyncio.run_coroutine_threadsafe(self._post_reconnect_setup_async(), loop)
+ except Exception:
+ logger.debug("MeshCore: scheduling post-reconnect setup failed", exc_info=True)
# ------------------------------------------------------------------
# Node identity / topology (MeshTransport abstract methods)
diff --git a/work/tests/test_adapter_config_api.py b/work/tests/test_adapter_config_api.py
index 8e66188..19bbe4e 100644
--- a/work/tests/test_adapter_config_api.py
+++ b/work/tests/test_adapter_config_api.py
@@ -86,6 +86,7 @@ def test_per_adapter_list(client):
"regional_centroid", "regional_radius_mi",
"broadcast_pager_alerts", "global_mag_floor",
"regional_mag_floor", "escalate_mag_floor",
+ "freshness_seconds",
}
diff --git a/work/tests/test_adapter_ipaws.py b/work/tests/test_adapter_ipaws.py
index 891f060..5009082 100644
--- a/work/tests/test_adapter_ipaws.py
+++ b/work/tests/test_adapter_ipaws.py
@@ -349,6 +349,12 @@ def test_decider_uses_own_table_not_nws(_isolate_meshai_db):
# ============================================================
def test_formatter_renders_civil_alert():
+ """Idaho CEM headline ("Wildfire Immediate Evacuation Alert") names the GO
+ phase explicitly ("Immediate Evacuation"), so line 1 leads with GO instead
+ of the raw CAP event string — the whole point of this feature. Category
+ is emergency_civil (normally ⚠️), but a detected GO forces the alarm
+ emoji regardless of category — a confirmed "leave now" must never render
+ with the softer warning icon."""
from meshai.notifications.formatters.ipaws import format as ipaws_format
a = IPAWSAlertsAdapter(_config())
raw = a._parse_cap(_fx("eas_idaho_cem.xml"), "16")
@@ -357,20 +363,73 @@ def test_formatter_renders_civil_alert():
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 lines[0].startswith("🚨") # GO overrides civil's ⚠️ -> alarm emoji
+ assert "GO" in lines[0] and "Leave now" in lines[0]
assert "Boundary County" in wire
assert "evacuation" in wire.lower() # headline carried the signal
def test_formatter_evacuation_emoji():
+ """Oregon EVI has no phase language in its headline, but its CMAMtext
+ parameter ("Level 3 GO NOW evacuation notice...") does, and that text is
+ now parsed and preferred for line 3 — so GO must win here too."""
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
+ lines = wire.split("\n")
+ assert "GO" in lines[0] and "Leave now" in lines[0]
+ assert "Level 3 GO NOW" in wire # CMAMtext carried into line 3
+
+
+def test_formatter_full_three_line_go_rendering():
+ """Full 3-line render for a real GO alert (Oregon EVI): phase-led line 1,
+ area+expiry line 2, CMAMtext line 3 — exact text, not just substrings."""
+ 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)
+ ev.data["_ipaws_prefix"] = ""
+ wire = ipaws_format(ev, now=1000.0, budget=200)
+ lines = wire.split("\n")
+ assert lines[0] == "🚨 GO — Leave now"
+ assert lines[1] == "Jackson County · Until 8:50 AM MDT"
+ assert lines[2] == (
+ "Wildfire Alert- Level 3 GO NOW evacuation notice is UPGRADED for JAC-126"
+ )
+
+
+def test_formatter_no_phase_fallback_unchanged():
+ """When no READY/SET/GO language is present anywhere in headline/CMAMtext/
+ description, line 1 keeps the CURRENT raw-event-string behaviour exactly —
+ the safe fallback this feature must never break."""
+ from meshai.notifications.formatters.ipaws import format as ipaws_format
+ from meshai.notifications.events import make_event
+
+ canonical = _canonical(
+ event="Civil Emergency Message",
+ headline="Boil water advisory issued for the district",
+ description="A water main break has contaminated the supply.",
+ parameters={},
+ expires_at=None,
+ )
+ ev = make_event(
+ source="ipaws",
+ category="emergency_civil",
+ severity="priority",
+ title=canonical["headline"],
+ summary=canonical["headline"],
+ body=canonical["description"],
+ data=canonical,
+ )
+ ev.data["_ipaws_prefix"] = ""
+ wire = ipaws_format(ev, now=1000.0, budget=200)
+ lines = wire.split("\n")
+ assert lines[0] == "⚠️ Civil Emergency Message" # unchanged fallback (no phase)
+ assert lines[1] == "Boundary County"
+ assert lines[2] == "Boil water advisory issued for the district"
# ============================================================
@@ -386,3 +445,36 @@ def test_coverage_state_fips_for_bbox():
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]
+
+
+# ============================================================
+# stage-2 failure negative cache (no re-hammering FEMA)
+# ============================================================
+
+def test_stage2_forbidden_is_negative_cached():
+ """A 403 on a stage-2 detail URL is remembered so the next poll tick does
+ NOT re-fetch it, while a sibling URL that succeeds is fetched every pass."""
+ from urllib.error import HTTPError
+
+ counts: dict[str, int] = {}
+
+ def _urlopen(req, timeout=None):
+ url = req.full_url
+ counts[url] = counts.get(url, 0) + 1
+ if url.endswith("/feed"):
+ return _FakeResp(_fx("feed.xml"))
+ if url.endswith("/eas/300130859542"): # Idaho CEM -> forbidden
+ raise HTTPError(url, 403, "Forbidden", {}, None)
+ if url.endswith("/eas/300130856756"): # Oregon EVI -> ok
+ return _FakeResp(_fx("eas_oregon_evi.xml"))
+ raise AssertionError(f"unexpected fetch: {url}")
+
+ a = IPAWSAlertsAdapter(_config())
+ with patch("meshai.env.ipaws.urlopen", _urlopen):
+ a._fetch()
+ a._fetch()
+
+ cem = next(u for u in counts if u.endswith("/eas/300130859542"))
+ evi = next(u for u in counts if u.endswith("/eas/300130856756"))
+ assert counts[cem] == 1 # 403 -> negative-cached, not re-fetched
+ assert counts[evi] == 2 # success -> fetched on each pass
diff --git a/work/tests/test_adapter_roads511.py b/work/tests/test_adapter_roads511.py
index 4322c11..c76e74d 100644
--- a/work/tests/test_adapter_roads511.py
+++ b/work/tests/test_adapter_roads511.py
@@ -200,3 +200,331 @@ def test_to_event_missing_properties_returns_event(adapter):
def test_to_event_does_not_raise_on_corrupted_dict(adapter):
"""Corrupted dict returns None without raising."""
assert adapter.to_event({"garbage": True}) is None
+
+
+# ============================================================
+# EventType / EventSubType / Cause -> sub_type MAPPING TESTS
+#
+# Drives real upstream-shaped ITD 511 v2 payloads through the actual
+# _parse_event() -> to_event() pipeline (not the make_511_event() stored-
+# dict helper) so the EventSubType/Cause capture at parse time and the
+# sub_type precedence in to_event() are both exercised end to end.
+# ============================================================
+
+def _itd_raw(
+ event_id="RW-1001",
+ event_type="roadwork",
+ event_sub_type="roadConstruction",
+ cause="Construction",
+ description="Paving operations, expect delays",
+ roadway="I-84",
+ is_full_closure=False,
+ lat=43.5,
+ lon=-116.2,
+):
+ """Realistic raw ITD 511 v2 API item shape (PascalCase fields)."""
+ return {
+ "EventId": event_id,
+ "EventType": event_type,
+ "EventSubType": event_sub_type,
+ "Cause": cause,
+ "RoadwayName": roadway,
+ "Description": description,
+ "Latitude": lat,
+ "Longitude": lon,
+ "IsFullClosure": is_full_closure,
+ "LastUpdated": "2026-08-01T00:00:00Z",
+ }
+
+
+def _through_pipeline(adapter, raw_item):
+ """Parse a raw ITD item then translate to a pipeline Event, mirroring
+ the real adapter flow (_fetch -> _parse_event -> get_events -> to_event)."""
+ stored = adapter._parse_event(raw_item, time.time())
+ assert stored is not None
+ return adapter.to_event(stored)
+
+
+def test_mapping_roadwork_not_full_closure_is_road_works(adapter):
+ """Routine roadwork, not a full closure -> sub_type 'road_works' (suppressed)."""
+ raw = _itd_raw(event_type="roadwork", is_full_closure=False)
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+
+def test_mapping_roadwork_with_full_closure_is_road_closed(adapter):
+ """Construction-caused FULL closure is still genuine news -> 'road_closed',
+ and must NOT be suppressed like routine roadwork."""
+ raw = _itd_raw(
+ event_type="roadwork",
+ event_sub_type="bridgeConstruction",
+ description="Bridge replacement in progress",
+ is_full_closure=True,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_closed"
+
+
+def test_mapping_accidents_and_incidents_is_incident(adapter):
+ """EventType accidentsAndIncidents -> sub_type 'incident'."""
+ raw = _itd_raw(
+ event_id="INC-2001",
+ event_type="accidentsAndIncidents",
+ event_sub_type="crash",
+ cause="Collision",
+ description="Two vehicle collision blocking right lane",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "incident"
+
+
+def test_mapping_closures_is_road_closed(adapter):
+ """EventType closures -> sub_type 'road_closed'."""
+ raw = _itd_raw(
+ event_id="CL-3001",
+ event_type="closures",
+ event_sub_type="bridgeClosure",
+ cause="Maintenance",
+ description="Bridge out of service",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_closed"
+
+
+def test_mapping_special_events_is_incident(adapter):
+ """EventType specialEvents -> sub_type 'incident' (not suppressed)."""
+ raw = _itd_raw(
+ event_id="SE-4001",
+ event_type="specialEvents",
+ event_sub_type="parade",
+ cause=None,
+ description="Downtown parade route",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "incident"
+
+
+def test_mapping_unknown_or_missing_event_type_is_incident(adapter):
+ """Missing/unrecognized EventType fails open to 'incident' (not suppressed)."""
+ raw = {
+ "EventId": "UNK-5001",
+ "RoadwayName": "SH-21",
+ "Description": "Unusual event",
+ "Latitude": 43.5,
+ "Longitude": -115.5,
+ }
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "incident"
+
+
+def test_mapping_event_subtype_construction_overrides_non_roadwork_eventtype(adapter):
+ """EventSubType containing 'construction' forces 'road_works' even when
+ EventType itself is not literally 'roadwork'."""
+ raw = _itd_raw(
+ event_id="SE-6001",
+ event_type="specialEvents",
+ event_sub_type="bridgeConstruction",
+ cause="Construction",
+ description="Bridge deck construction nearby",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+
+def test_mapping_cause_is_carried_through_not_none(adapter):
+ """Cause from the upstream payload reaches canonical_data, not hardcoded None."""
+ raw = _itd_raw(cause="Weather")
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["cause"] == "Weather"
+ assert event.data["cause"] is not None
+
+
+def test_mapping_missing_cause_is_none(adapter):
+ """No Cause field upstream -> canonical cause is None, not a crash."""
+ raw = _itd_raw(cause=None)
+ del raw["Cause"]
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["cause"] is None
+
+
+# ============================================================
+# END-TO-END GATING TEST — proves a real roadwork event, run through the
+# actual adapter pipeline, is suppressed by gating/incident.py's work-zone
+# rule, while a real crash event from the same pipeline still broadcasts.
+# ============================================================
+
+def test_roadwork_event_suppressed_by_incident_gate_end_to_end(adapter):
+ """A real ITD roadwork payload, parsed and translated by the adapter,
+ must be suppressed at the gating layer (not just mapped to the right
+ sub_type in isolation)."""
+ from meshai.notifications.gating.incident import decide
+
+ raw = _itd_raw(event_id="RW-7001", event_type="roadwork", is_full_closure=False)
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+ result = decide(dict(event.data), source="511", now=time.time())
+ assert result.broadcast is False
+ assert result.lifecycle == "suppress"
+
+
+def test_crash_event_still_broadcasts_through_incident_gate_end_to_end(adapter):
+ """A real ITD crash payload, parsed and translated by the adapter,
+ still broadcasts (gating layer is unaffected for non-work-zone sub_types)."""
+ from meshai.notifications.gating.incident import decide
+
+ raw = _itd_raw(
+ event_id="INC-7002",
+ event_type="accidentsAndIncidents",
+ event_sub_type="crash",
+ description="Vehicle collision, right lane blocked",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "incident"
+
+
+# ============================================================
+# FULL-CLOSURE vs. PARTIAL-RESTRICTION LANGUAGE TESTS
+#
+# The loose `is_closure` property (also used for severity/summary) matches
+# any "closed" substring, including partial-restriction wording like "All
+# Shoulders Closed" that ITD explicitly does not want broadcast. The
+# sub_type mapping's full-closure branch must use a stricter,
+# mapping-only determination instead.
+# ============================================================
+
+def test_all_shoulders_closed_roadwork_is_road_works(adapter):
+ """'All Shoulders Closed' is a partial restriction, not a full closure
+ -> stays 'road_works' (suppressed), despite containing 'closed'."""
+ raw = _itd_raw(
+ event_id="RW-8001",
+ event_type="roadwork",
+ description="All Shoulders Closed",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+
+@pytest.mark.parametrize(
+ "description",
+ ["One lane closed", "Right lane closed", "Left lane closed"],
+)
+def test_single_lane_closed_roadwork_is_road_works(adapter, description):
+ """Single-lane restriction wording -> stays 'road_works' (suppressed)."""
+ raw = _itd_raw(
+ event_id="RW-8002",
+ event_type="roadwork",
+ description=description,
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+
+def test_ramp_closed_roadwork_is_road_works(adapter):
+ """'Ramp closed' is a partial restriction -> stays 'road_works' (suppressed)."""
+ raw = _itd_raw(
+ event_id="RW-8003",
+ event_type="roadwork",
+ description="Ramp closed for repaving",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+
+def test_all_lanes_closed_roadwork_is_road_closed(adapter):
+ """'All lanes closed' is a genuine full closure -> 'road_closed', and
+ must STILL BROADCAST (this is the safety case: a real closure phrased
+ as roadwork must not be suppressed by the work-zone gate)."""
+ from meshai.notifications.gating.incident import decide
+
+ raw = _itd_raw(
+ event_id="RW-8004",
+ event_type="roadwork",
+ description="All lanes closed for bridge demolition",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_closed"
+
+ result = decide(dict(event.data), source="511", now=time.time())
+ assert result.broadcast is True
+ assert result.lifecycle != "suppress"
+
+
+def test_is_full_closure_flag_true_no_closure_words_is_road_closed(adapter):
+ """IsFullClosure=true + roadwork, with a description containing no
+ closure wording at all -> still 'road_closed' via the raw flag."""
+ raw = _itd_raw(
+ event_id="RW-8005",
+ event_type="roadwork",
+ description="Bridge deck replacement in progress",
+ is_full_closure=True,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_closed"
+
+
+def test_road_closed_due_to_rock_slide_incident_is_road_closed(adapter):
+ """Explicit full-closure wording on an accidentsAndIncidents event
+ still wins the mapping -> 'road_closed'."""
+ raw = _itd_raw(
+ event_id="INC-8006",
+ event_type="accidentsAndIncidents",
+ event_sub_type="hazard",
+ cause="Rock slide",
+ description="Road closed due to rock slide",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_closed"
+
+
+def test_lane_restrictions_roadwork_is_road_works(adapter):
+ """'Lane restrictions' wording -> stays 'road_works' (suppressed)."""
+ raw = _itd_raw(
+ event_id="RW-8007",
+ event_type="roadwork",
+ description="Lane restrictions in effect through Friday",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
+
+
+def test_alternating_traffic_roadwork_is_road_works(adapter):
+ """'Alternating' one-lane traffic control -> stays 'road_works' (suppressed)."""
+ raw = _itd_raw(
+ event_id="RW-8008",
+ event_type="roadwork",
+ description="Alternating traffic controlled by flaggers",
+ is_full_closure=False,
+ )
+ event = _through_pipeline(adapter, raw)
+ assert event is not None
+ assert event.data["sub_type"] == "road_works"
diff --git a/work/tests/test_adapter_usgs_quake.py b/work/tests/test_adapter_usgs_quake.py
index 7074983..d3ee539 100644
--- a/work/tests/test_adapter_usgs_quake.py
+++ b/work/tests/test_adapter_usgs_quake.py
@@ -200,12 +200,32 @@ def test_populates_core_fields(adapter):
assert event.source == "usgs_quake"
assert event.lat == 42.61
assert event.lon == -114.48
- assert event.region == "magic_valley"
+ # to_event() deliberately does NOT propagate the raw dict's "region"
+ # (a fixed config default that never matches region_routes cell keys)
+ # onto the Event -- Event.region is left unset so CoverageFilter's
+ # geometry-based tagger (lat/lon vs named coverage areas) can set the
+ # real region name downstream. See test_region_left_unset_for_tagger
+ # and tests/test_coverage_area.py for the tagging behavior itself.
+ assert event.region is None
assert event.expires == evt["expires"]
assert event.timestamp == evt["quake_time"]
assert event.id
+def test_region_left_unset_for_tagger(adapter):
+ """Regression: to_event() must never propagate the adapter's fixed
+ config region (e.g. "magic_valley") onto the Event. That value never
+ matches a region_routes cell key ("East Idaho"/"SC Idaho"/"SW Idaho"),
+ so pre-setting it would silently break region-routed delivery. Leaving
+ event.region/regions unset lets CoverageFilter's geometry tagger (which
+ only fires when `not event.regions`) stamp the real area name from the
+ quake's actual lat/lon."""
+ evt = make_quake_event(lat=44.2, lon=-114.0, region="magic_valley")
+ event = adapter.to_event(evt)
+ assert event.region is None
+ assert event.regions == []
+
+
# ============================================================
# DEFENSIVE TESTS
# ============================================================
diff --git a/work/tests/test_curation.py b/work/tests/test_curation.py
index ebf3f48..47faf40 100644
--- a/work/tests/test_curation.py
+++ b/work/tests/test_curation.py
@@ -183,13 +183,16 @@ def test_api_list_towns(client):
def test_api_post_add_town(client):
+ # A clearly-fictitious name, not a real Idaho town -- avoids colliding
+ # with the town_anchors seed (real curated Idaho/neighbor towns; a
+ # UNIQUE(name) constraint 400s on a duplicate insert).
r = client.post("/api/town-anchors", json={
- "name": "Bellevue", "lat": 43.4670, "lon": -114.2557, "state": "ID",
+ "name": "Testopolis", "lat": 43.4670, "lon": -114.2557, "state": "ID",
})
assert r.status_code == 200
- assert r.json()["name"] == "bellevue"
+ assert r.json()["name"] == "testopolis"
invalidate_curation_cache()
- coord = lookup_town_anchor("bellevue")
+ coord = lookup_town_anchor("testopolis")
assert coord is not None
diff --git a/work/tests/test_evac_phase.py b/work/tests/test_evac_phase.py
new file mode 100644
index 0000000..7b8fd45
--- /dev/null
+++ b/work/tests/test_evac_phase.py
@@ -0,0 +1,76 @@
+"""Tests for meshai.notifications.evac_phase.detect_phase.
+
+Cases are drawn from REAL FEMA IPAWS alert headline/CMAMtext/description
+strings (see tests/fixtures/ipaws/) plus explicit false-positive guards, since
+a wrong GO detection would broadcast an evacuation order that was never
+issued.
+"""
+from __future__ import annotations
+
+import pytest
+
+from meshai.notifications.evac_phase import detect_phase
+
+
+# ============================================================
+# real strings -> expected phase
+# ============================================================
+
+@pytest.mark.parametrize("text, expected", [
+ ("LEVEL 1 READY - Evacuation Status", "READY"),
+ ("Level 2 Set Alert", "SET"),
+ ("LEVEL I SET Alert", "SET"), # word beats numeral; err upward
+ ("Level 3 - Go Now", "GO"),
+ ("Level 3- Go Now", "GO"),
+ ("Ohio Gulch GO Evacuation Status", "GO"),
+ ("Indian Creek Set to GO.", "GO"), # highest wins, NOT SET
+ ("Immediate Evacuation", "GO"),
+ ("Prepare to Evacuate", "SET"),
+ ("BRUSH FIRE", None),
+ ("Owyhee County Closure Area", None),
+ ("Endangered Missing Person Alert", None),
+ (
+ "Jackson County Sheriff's Office- Level 3 GO NOW evacuation notice "
+ "UPGRADED for JAC-126",
+ "GO",
+ ),
+])
+def test_detect_phase_real_strings(text, expected):
+ assert detect_phase(text) == expected
+
+
+# ============================================================
+# false-positive guards — bare lowercase / narrative usage must NOT match
+# ============================================================
+
+@pytest.mark.parametrize("text", [
+ "residents should go to the Blaine County Fairgrounds",
+ "crews are set to arrive by 0600",
+ "evacuate via Go Creek Road",
+])
+def test_detect_phase_false_positive_guards(text):
+ assert detect_phase(text) is None
+
+
+# ============================================================
+# precedence + multi-arg scanning
+# ============================================================
+
+def test_precedence_highest_always_wins_regardless_of_arg_order():
+ # SET language in the first text, GO language in the second — GO must win.
+ assert detect_phase("Prepare to Evacuate", "Level 3 - Go Now") == "GO"
+ # Same phrases, arguments reversed — still GO (order-independent).
+ assert detect_phase("Level 3 - Go Now", "Prepare to Evacuate") == "GO"
+
+
+def test_detect_phase_scans_all_texts_together():
+ # No single text alone carries a phase; combined they do (SET here,
+ # since "LEVEL 2" is an explicit SET phrase and no GO phrase is present).
+ assert detect_phase("Jackson County Sheriff's Office", "Level 2 Set Alert") == "SET"
+
+
+def test_detect_phase_none_texts_and_empty_input_are_safe():
+ assert detect_phase(None, None) is None
+ assert detect_phase() is None
+ assert detect_phase("") is None
+ assert detect_phase(None, "BRUSH FIRE", None) is None
diff --git a/work/tests/test_fire_refactor.py b/work/tests/test_fire_refactor.py
index 5d31bf1..da09eb3 100644
--- a/work/tests/test_fire_refactor.py
+++ b/work/tests/test_fire_refactor.py
@@ -217,7 +217,10 @@ class TestFormatterGolden:
# Golden literal (captured from the live fire_format all-clear branch;
# this is the SAME format string handle_wfigs used to build inline
# before its removal -- see notifications/formatters/fire.py::_render_allclear).
- assert new_wire == "✅ Cache Peak Fire — contained & closed\n1,847 ac | 23% contained | 24 mi S of Burley"
+ # "oakley" is nearer to 42.197,-113.710 than "burley" and is now in
+ # the town_anchors seed after the seed-list sync (Fix 2), so it wins
+ # the anchor resolution instead of the previously-nearest seeded town.
+ assert new_wire == "✅ Cache Peak Fire — contained & closed\n1,847 ac | 23% contained | 9 mi E of Oakley"
# ─────────────────────────────────────────────────────────────────────────────
@@ -323,6 +326,102 @@ class TestGateSequenceParity:
assert gate.lifecycle == "suppress"
+# ─────────────────────────────────────────────────────────────────────────────
+# 2b. Tombstone mute — decide() must honor fires.tombstoned_at
+# ─────────────────────────────────────────────────────────────────────────────
+
+class TestTombstoneMute:
+ """A non-null fires.tombstoned_at is a durable operator kill-switch: an
+ incident row stamped tombstoned must never broadcast again through
+ decide() -- new, update, or otherwise -- regardless of forward
+ acreage/containment growth or the 8h cooldown. This is the SAME column
+ the dashboard's /api/env/active view already filters on
+ (WHERE tombstoned_at IS NULL); decide() previously never read it."""
+
+ _IRWIN = "IRWIN-TOMBSTONE-1"
+
+ def _seed(self, conn, *, acres, contained, last_bcast_at, tombstoned_at=None):
+ _write_fire_state(
+ conn, irwin_id=self._IRWIN, name="Lake Channel Fire",
+ acres=acres, contained_pct=contained, lat=42.0, lon=-114.0,
+ county="Twin Falls", state="ID", now=int(last_bcast_at or 1_000_000))
+ conn.execute(
+ "UPDATE fires SET last_broadcast_at=?, last_broadcast_acres=?, "
+ "last_broadcast_contained=?, tombstoned_at=? WHERE irwin_id=?",
+ (last_bcast_at, acres, contained, tombstoned_at, self._IRWIN),
+ )
+
+ def _incident(self, *, acres, contained):
+ return {
+ "_kind": "wfigs_incident", "irwin_id": self._IRWIN,
+ "incident_name": "Lake Channel Fire", "acres": acres,
+ "contained_pct": contained, "declared_at_epoch": None,
+ "lat": 42.0, "lon": -114.0, "county": "Twin Falls", "state": "ID",
+ }
+
+ # (a) non-tombstoned row, forward growth past cooldown -> still broadcasts.
+ def test_non_tombstoned_growth_still_broadcasts(self, mem_db):
+ self._seed(mem_db, acres=100, contained=10,
+ last_bcast_at=1_000_000 - 9 * 3600, tombstoned_at=None)
+ gate = fire_decide(self._incident(acres=200, contained=10),
+ source="wfigs", now=1_000_000.0)
+ assert gate.broadcast is True
+ assert gate.lifecycle == "update"
+
+ # (b) same row, tombstoned_at set -> suppressed regardless of growth/cooldown.
+ def test_tombstoned_row_suppressed_despite_growth(self, mem_db):
+ self._seed(mem_db, acres=100, contained=10,
+ last_bcast_at=1_000_000 - 9 * 3600, tombstoned_at=999_000)
+ gate = fire_decide(self._incident(acres=9999, contained=99),
+ source="wfigs", now=1_000_000.0)
+ assert gate.broadcast is False
+ assert gate.lifecycle == "suppress"
+ assert "tombston" in gate.reason.lower()
+
+ # (c) brand-new incident, not tombstoned (no row at all) -> broadcasts new.
+ def test_brand_new_incident_not_tombstoned_broadcasts(self, mem_db):
+ gate = fire_decide(self._incident(acres=50, contained=0),
+ source="wfigs", now=1_000_000.0)
+ assert gate.broadcast is True
+ assert gate.lifecycle == "new"
+
+ # (d) defensive: a row lacking the tombstoned_at column must not raise.
+ def test_missing_tombstoned_at_column_does_not_raise(self, mem_db, monkeypatch):
+ self._seed(mem_db, acres=100, contained=10,
+ last_bcast_at=1_000_000 - 9 * 3600, tombstoned_at=None)
+
+ import sqlite3
+
+ import meshai.notifications.gating.fire as fire_mod
+
+ class _NoTombstoneColConn:
+ """Proxies the real connection but simulates a pre-v12 schema:
+ any SELECT naming tombstoned_at raises OperationalError, exactly
+ as a real un-migrated `fires` table would."""
+
+ def __init__(self, real):
+ self._real = real
+
+ def execute(self, sql, *args, **kwargs):
+ if "tombstoned_at" in sql and "SELECT" in sql.upper():
+ raise sqlite3.OperationalError(
+ "no such column: tombstoned_at")
+ return self._real.execute(sql, *args, **kwargs)
+
+ def __getattr__(self, name):
+ return getattr(self._real, name)
+
+ monkeypatch.setattr(
+ fire_mod, "get_db", lambda: _NoTombstoneColConn(mem_db))
+
+ gate = fire_decide(self._incident(acres=200, contained=10),
+ source="wfigs", now=1_000_000.0)
+ # Falls back to "not tombstoned" and proceeds with normal gating --
+ # must not raise, and growth past cooldown still broadcasts.
+ assert gate.broadcast is True
+ assert gate.lifecycle == "update"
+
+
# ─────────────────────────────────────────────────────────────────────────────
# 3. Registration — the three explicit categories resolve; FIRMS does not
# ─────────────────────────────────────────────────────────────────────────────
diff --git a/work/tests/test_generic_http.py b/work/tests/test_generic_http.py
index 2b40fab..854aa76 100644
--- a/work/tests/test_generic_http.py
+++ b/work/tests/test_generic_http.py
@@ -8,6 +8,7 @@ Ported-behavior coverage:
* geometry-path Point -> centroid extraction
"""
from __future__ import annotations
+import asyncio
import json
@@ -203,7 +204,7 @@ def test_cold_start_silent_first_poll_seeds_persists_no_emit():
# Stub the network fetch with one active outage.
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
- store.refresh() # poll 1 == pre-existing backlog
+ asyncio.run(store.refresh()) # poll 1 == pre-existing backlog
# Nothing broadcast on the cold-start poll...
assert captured == [], "first poll must broadcast NOTHING (cold-start seed)"
@@ -220,14 +221,14 @@ def test_cold_start_silent_first_poll_seeds_persists_no_emit():
def test_later_poll_broadcasts_newly_received_item():
store, adapter, captured = _make_store_with_generic()
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
- store.refresh() # poll 1 — seed silently
+ asyncio.run(store.refresh()) # poll 1 — seed silently
assert captured == []
# A genuinely NEW outage appears on a later poll -> it must broadcast.
new_item = dict(IDAHO_POWER_ITEM, omsOutageId="456", omsCustomerCount=99)
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM, new_item])
adapter._last_poll.clear() # force cadence to elapse
- store.refresh() # poll 2
+ asyncio.run(store.refresh()) # poll 2
assert len(captured) == 1, "only the newly-received outage broadcasts"
assert captured[0].category == "power_outage"
@@ -367,7 +368,7 @@ def test_build_generic_detail_reader():
from meshai.notifications.env_reporter import EnvReporter
store, adapter, captured = _make_store_with_generic()
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
- store.refresh()
+ asyncio.run(store.refresh())
text = EnvReporter().build_generic_detail()
assert "idaho_power" in text
diff --git a/work/tests/test_incident_refactor.py b/work/tests/test_incident_refactor.py
index 8f6d1ca..3bb82c2 100644
--- a/work/tests/test_incident_refactor.py
+++ b/work/tests/test_incident_refactor.py
@@ -188,7 +188,11 @@ class TestWorkZoneGolden:
_GOLDEN = {
"0002.json": "🚧 US-91, near Chubbuck: southbound, road construction, ends Aug 17",
- "0003.json": "🚧 US-95, near Wilder: southbound, ends Jul 19",
+ # "wilder" was added to the town_anchors seed by the seed-list sync
+ # (Fix 2), so the DB-anchor step now wins over the live Photon
+ # geocode this golden was originally captured against; the DB row's
+ # coords round to 1 mi S instead of Photon's sub-mile "near".
+ "0003.json": "🚧 US-95, 1 mi S of Wilder: southbound, ends Jul 19",
}
# Captured from the deleted normalizer's normalize() + _n_to_canonical_workzone()
@@ -434,6 +438,35 @@ class TestAnchorResolve:
assert resolve_anchor(None, -116.2, max_mi=50.0) is None
assert resolve_anchor(43.6, None, max_mi=50.0) is None
+ def test_disabled_anchor_excluded(self, monkeypatch):
+ """A disabled=0 town_anchors row must not be selected, even when it is
+ the closest row within max_mi — the enabled flag is a hard exclude."""
+ import time as _time
+ from meshai.persistence import get_db
+ from meshai.notifications.formatters._anchor import resolve_anchor
+ from meshai import geo
+
+ # Force the Photon fallback to a known miss so a non-None result can
+ # only come from the (wrongly-included) disabled DB row.
+ monkeypatch.setattr(
+ geo, "nearest_town",
+ lambda lat, lon, max_distance_mi=50.0: None,
+ )
+
+ conn = get_db()
+ # Clear seeded anchors so only our controlled (disabled) row exists.
+ conn.execute("DELETE FROM town_anchors")
+ conn.execute(
+ "INSERT INTO town_anchors(name, lat, lon, state, enabled, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ ("disabledville", -33.8688, 151.2093, "NSW", 0, _time.time()), # Sydney
+ )
+
+ # Event right next to the disabled row; Photon fallback is forced to
+ # miss → None confirms the DB step excluded the disabled row.
+ result = resolve_anchor(-33.870, 151.210, max_mi=50.0)
+ assert result is None
+
# ── 4. Schema conformance ────────────────────────────────────────────────────
diff --git a/work/tests/test_quake_freshness_and_region_tagging.py b/work/tests/test_quake_freshness_and_region_tagging.py
new file mode 100644
index 0000000..ab0166f
--- /dev/null
+++ b/work/tests/test_quake_freshness_and_region_tagging.py
@@ -0,0 +1,212 @@
+"""Regression tests for the usgs_quake dispatcher-freshness + region-tagging fix.
+
+Problem (see meshai/notifications/pipeline/dispatcher.py:359-374 and
+meshai/env/usgs_quake.py): the usgs_quake adapter polls a rolling PAST-DAY
+USGS feed (2.5_day.geojson), so a genuinely first-seen quake is routinely
+already 10min-20h old by the time it's detected. The generic per-toggle
+freshness_seconds (600s) silently dropped essentially every native quake
+before broadcast -- confirmed against production: quake_events had 12
+first-sighting rows (decide() ran, magnitude/region gate passed, DB row
+inserted) with last_broadcast_at=NULL on every single one (commit() never
+reached because the staleness filter dropped the event downstream first).
+
+Fix 1 (dispatcher.py): earthquake_event gets its own adapter_config-backed
+freshness override (adapter_config.usgs_quake.freshness_seconds, default
+3600s), mirroring the existing wfigs/"fire" override -- but scoped to the
+CATEGORY, not the "seismic" family/toggle, because stream_flood_warning /
+stream_high_water (hydro) also live under toggle="seismic" and must keep
+using the generic per-toggle freshness unchanged.
+
+Fix 2 (env/usgs_quake.py): to_event() no longer passes the adapter's fixed
+config region ("magic_valley", which never matches a region_routes cell
+key) onto the Event. Event.region is left unset so CoverageFilter's
+geometry-based tagger (lat/lon vs named coverage areas) can stamp the real
+region name.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshai.config import Config
+from meshai.coverage_area import MonitoringArea
+from meshai.env.usgs_quake import USGSQuakeAdapter
+from meshai.notifications.events import make_event
+from meshai.notifications.pipeline.coverage_filter import CoverageFilter
+from meshai.notifications.pipeline.dispatcher import Dispatcher
+
+
+# --------------------------------------------------------------------------
+# Shared dispatcher-test helpers (mirror tests/test_v052_dispatcher.py)
+# --------------------------------------------------------------------------
+
+class RecChannel:
+ def __init__(self, rec):
+ self.rec = rec
+
+ async def deliver(self, payload, rule):
+ self.rec.append({"name": rule.name, "message": payload.message})
+ return True
+
+
+def _make_dispatcher(cfg):
+ rec: list = []
+ d = Dispatcher(cfg, lambda rule, conn: RecChannel(rec), connector=None)
+ return d, rec
+
+
+def _dispatch_one(cfg, event):
+ d, rec = _make_dispatcher(cfg)
+ asyncio.run(d.dispatch(event))
+ return d, rec
+
+
+def _quake_cfg():
+ """Config with the seismic toggle enabled, cold-start grace disabled."""
+ cfg = Config()
+ cfg.notifications.rules = []
+ cfg.notifications.cold_start_grace_seconds = 0
+ t = cfg.notifications.toggles["seismic"]
+ t.enabled = True
+ t.min_severity = "routine"
+ t.regions = []
+ t.severity_channels = {
+ "routine": ["mesh_broadcast"],
+ "priority": ["mesh_broadcast"],
+ "immediate": ["mesh_broadcast"],
+ }
+ t.cooldown_seconds = 0
+ # Generic per-toggle freshness -- deliberately tight (600s, the old
+ # default) so these tests prove the earthquake_event category is NOT
+ # using this value anymore.
+ t.freshness_seconds = 600
+ return cfg
+
+
+def _quake_event(age_seconds: float, event_id="us6000abcd", lat=42.6, lon=-114.5):
+ return make_event(
+ source="usgs_quake",
+ category="earthquake_event",
+ severity="routine",
+ title=f"M3.0 -- {event_id}",
+ timestamp=time.time() - age_seconds,
+ lat=lat,
+ lon=lon,
+ group_key=event_id,
+ inhibit_keys=[event_id],
+ )
+
+
+# ============================================================
+# (a) fresh quake passes under the new per-adapter override
+# ============================================================
+
+def test_fresh_quake_passes_staleness_gate_with_override():
+ """A 30-minute-old quake (1800s) exceeds the generic 600s toggle window
+ but must pass under the adapter_config.usgs_quake.freshness_seconds
+ override (default 3600s)."""
+ cfg = _quake_cfg()
+ event = _quake_event(age_seconds=1800)
+ d, rec = _dispatch_one(cfg, event)
+ assert len(rec) == 1, "30-min-old quake must broadcast under the 3600s override"
+ assert d.dispatch_stats()["stale_dropped"] == 0
+
+
+# ============================================================
+# (b) old quake (12h) is still dropped
+# ============================================================
+
+def test_old_quake_still_dropped_by_override():
+ """A 12-hour-old quake (43200s) must still be dropped -- the override
+ widens the window, it does not disable the staleness check."""
+ cfg = _quake_cfg()
+ event = _quake_event(age_seconds=12 * 3600)
+ d, rec = _dispatch_one(cfg, event)
+ assert rec == [], "12h-old quake must still be dropped as stale"
+ assert d.dispatch_stats()["stale_dropped"] == 1
+
+
+def test_hydro_seismic_sibling_unaffected_by_quake_override():
+ """Guardrail: stream_flood_warning shares toggle='seismic' with
+ earthquake_event but must keep using the GENERIC per-toggle freshness
+ (600s here), not the quake-specific 3600s override. A 1800s-old hydro
+ event must still be dropped."""
+ cfg = _quake_cfg()
+ event = make_event(
+ source="usgs", category="stream_flood_warning", severity="priority",
+ title="Snake River nr Twin Falls 12.8 ft",
+ timestamp=time.time() - 1800,
+ )
+ d, rec = _dispatch_one(cfg, event)
+ assert rec == [], "hydro sibling must NOT inherit the quake freshness override"
+ assert d.dispatch_stats()["stale_dropped"] == 1
+
+
+# ============================================================
+# (c) region-tagging from real lat/lon
+# ============================================================
+
+SW_IDAHO = MonitoringArea(name="SW Idaho", west=-117.993408, south=42.05,
+ east=-115.389404, north=44.331707)
+SC_IDAHO = MonitoringArea(name="SC Idaho", west=-115.389404, south=42.05,
+ east=-112.8, north=44.331707)
+EAST_IDAHO = MonitoringArea(name="East Idaho", west=-112.8, south=41.9,
+ east=-110.9, north=45.331707)
+PROD_AREAS = [SW_IDAHO, SC_IDAHO, EAST_IDAHO]
+
+
+def _quake_adapter():
+ cfg = MagicMock()
+ cfg.feed_url = "https://example.test/feed.geojson"
+ cfg.min_magnitude = 2.5
+ cfg.bbox = [-115.5, 42.0, -110.0, 45.2]
+ cfg.region = "magic_valley"
+ cfg.tick_seconds = 300
+ return USGSQuakeAdapter(cfg)
+
+
+def test_quake_event_region_tags_to_matching_coverage_area():
+ """A quake with real Idaho lat/lon, run through the actual adapter's
+ to_event() and then the real CoverageFilter (named exactly like prod's
+ SW/SC/East Idaho areas), must end up tagged with a region name that
+ matches a region_routes cell key -- NOT the adapter's stale
+ "magic_valley" default."""
+ adapter = _quake_adapter()
+ raw = {
+ "source": "usgs_quake",
+ "event_id": "us7000example",
+ "event_type": "Earthquake",
+ "severity": "routine",
+ "headline": "M2.7 -- 10 km N of Twin Falls, ID",
+ "magnitude": 2.7,
+ "place": "10 km N of Twin Falls, ID",
+ "depth_km": 8.0,
+ "sig": 80,
+ "url": "https://earthquake.usgs.gov/x",
+ "region": "magic_valley",
+ "lat": 42.61, # Twin Falls -- falls inside SC Idaho
+ "lon": -114.48,
+ "quake_time": time.time(),
+ "expires": time.time() + 86400,
+ "fetched_at": time.time(),
+ }
+ event = adapter.to_event(raw)
+ assert event is not None
+ assert event.region is None # adapter no longer pre-stamps a region
+
+ received: list = []
+ flt = CoverageFilter(next_handler=received.append, areas=PROD_AREAS, enabled=True)
+ flt.handle(event)
+
+ assert len(received) == 1, "in-region quake must pass the coverage gate"
+ tagged = received[0]
+ assert tagged.region == "SC Idaho", (
+ f"expected geometry tagging to set 'SC Idaho', got {tagged.region!r} "
+ "(pre-fix this would have stayed 'magic_valley' and never matched a "
+ "region_routes cell key)"
+ )
+ assert tagged.regions == ["SC Idaho"]
diff --git a/work/tests/test_satpass_native.py b/work/tests/test_satpass_native.py
index 8a35ef1..63bacb1 100644
--- a/work/tests/test_satpass_native.py
+++ b/work/tests/test_satpass_native.py
@@ -354,3 +354,76 @@ def test_parse_norad_ids_handles_comma_string():
assert SatpassAdapter._parse_norad_ids("25544, 33591") == [25544, 33591]
assert SatpassAdapter._parse_norad_ids([25544, "33591"]) == [25544, 33591]
assert SatpassAdapter._parse_norad_ids([]) == []
+
+
+# ══════════════════════════════════════════════════════════════════════
+# 8. observer_list FLOWS INTO THE GATE'S data DICT (region-tagging fix)
+# ══════════════════════════════════════════════════════════════════════
+#
+# gate_consolidated_pass() builds the event `data` dict that rides all the
+# way to the dispatcher via to_event(). Satpass events carry no lat/lon/
+# geometry, so coverage_area.observer_region_names() reading
+# event.data["observer_list"] is the ONLY path that can region-tag a
+# satpass event for the region_routes matrix. These tests pin that the
+# gate actually populates it (and stays safe when it can't).
+
+def test_gate_consolidated_pass_data_contains_observer_list():
+ """The (wire, data) tuple returned by gate_consolidated_pass() must
+ carry the same comma-joined observer_list already written to the
+ satpass_events audit column."""
+ _enable_satpass_db(dry_run=False)
+ from meshai.env.satellite import pass_format as sh
+
+ consolidated = {
+ "consolidated_id": "25544:900000",
+ "norad_id": 25544,
+ "sat_name": "ISS (ZARYA)",
+ "max_elevation": 70.0,
+ "aos_epoch": 1_000_000,
+ "los_epoch": 1_000_300,
+ "aos_compass": "SW",
+ "los_compass": "NE",
+ "peak_compass": "S",
+ "entry_observer": "Boise",
+ "exit_observer": "Twin Falls",
+ "observer_list": "boise,twin",
+ }
+ result = sh.gate_consolidated_pass(consolidated, now=0)
+ assert result is not None
+ _, data = result
+ assert data["observer_list"] == "boise,twin"
+
+
+def test_gate_consolidated_pass_missing_observer_list_is_defensive():
+ """A `consolidated` dict with no observer_list must not raise, and the
+ resulting data["observer_list"] must be falsy (never a garbage value
+ that would resolve to a bogus region)."""
+ _enable_satpass_db(dry_run=False)
+ from meshai.env.satellite import pass_format as sh
+
+ consolidated = {
+ "consolidated_id": "25544:900001",
+ "norad_id": 25544,
+ "sat_name": "ISS (ZARYA)",
+ "max_elevation": 40.0,
+ "aos_epoch": 2_000_000,
+ "los_epoch": 2_000_300,
+ "aos_compass": "SW",
+ "los_compass": "NE",
+ "peak_compass": "S",
+ # entry_observer / exit_observer / observer_list all deliberately absent
+ }
+ result = sh.gate_consolidated_pass(consolidated, now=0)
+ assert result is not None
+ _, data = result
+ assert not data.get("observer_list")
+
+ # And observer_region_names() must treat that as "no region", not raise.
+ from meshai.coverage_area import MonitoringArea, observer_region_names
+ from meshai.notifications.events import make_event
+
+ event = make_event(source="satpass", category="sat_pass", severity="routine",
+ title="Pass", data=data)
+ areas = [MonitoringArea(north=44.0, south=42.0, east=-113.0, west=-117.0,
+ name="SW Idaho")]
+ assert observer_region_names(event, areas) == []
diff --git a/work/tests/test_satpass_region_tagging.py b/work/tests/test_satpass_region_tagging.py
index db48d73..ff885df 100644
--- a/work/tests/test_satpass_region_tagging.py
+++ b/work/tests/test_satpass_region_tagging.py
@@ -245,3 +245,111 @@ class TestSatpassToggle:
def test_categories_for_toggle_satpass_returns_sat_pass(self):
result = categories_for_toggle("satpass")
assert result == ["sat_pass"]
+
+
+# ---------------------------------------------------------------------------
+# End-to-end: a satpass Event built through the REAL native-adapter path
+# (predict -> consolidate -> gate_consolidated_pass -> to_event) must
+# region-tag via event_region_names(), using the two production observers.
+# This is the regression test for the observer_list wiring bug: before the
+# fix, gate_consolidated_pass()'s data dict never carried observer_list, so
+# this resolved to [] no matter how the areas were configured.
+# ---------------------------------------------------------------------------
+
+# Production ground stations (see adapter_config observers): Treasure Valley
+# (Boise area) and Magic Valley (Twin Falls area).
+_TREASURE_VALLEY = {"slug": "treasure_valley", "name": "Treasure Valley",
+ "lat": 43.6, "lon": -116.2, "alt_m": 0.0}
+_MAGIC_VALLEY = {"slug": "magic_valley", "name": "Magic Valley",
+ "lat": 42.5558, "lon": -114.4701, "alt_m": 0.0}
+
+# Coarse named boxes that cover each observer, standing in for the real
+# region_routes cells ("SW Idaho" covers the Treasure Valley/Boise area,
+# "SC Idaho" covers the Magic Valley/Twin Falls area).
+_SW_IDAHO = MonitoringArea(north=44.5, south=42.8, east=-115.0, west=-117.5,
+ name="SW Idaho")
+_SC_IDAHO_PROD = MonitoringArea(north=43.2, south=42.0, east=-113.0, west=-115.2,
+ name="SC Idaho")
+_PROD_AREAS = [_SW_IDAHO, _SC_IDAHO_PROD]
+
+
+class TestSatpassEndToEndRegionTagging:
+ def test_real_gate_path_region_tags_nonempty(self, monkeypatch):
+ """Drive the actual SatpassAdapter tick -> gate -> to_event path
+ (no shortcuts through gate_consolidated_pass or make_event) with the
+ two production observer coordinates, then confirm event_region_names
+ returns a non-empty list built from event.data['observer_list']."""
+ import json as _json
+ from datetime import datetime, timezone
+
+ from meshai.adapter_config import invalidate_cache
+ from meshai.config import SatpassConfig
+ from meshai.env.satellite.pass_predictor import PassInfo
+ from meshai.env.satellite.tle_store import upsert_tle
+ from meshai.env.satpass import SatpassAdapter
+ from meshai.persistence import get_db
+
+ # -- seed a fresh ISS TLE --------------------------------------------------
+ conn = get_db()
+ fresh = datetime.now(timezone.utc).isoformat()
+ iss_l1 = "1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008"
+ iss_l2 = "2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12345"
+ upsert_tle(conn, 25544, "ISS (ZARYA)", iss_l1, iss_l2, fresh)
+
+ # -- enable satpass, dry_run off so the gate actually returns data --------
+ conn.execute("UPDATE adapter_config SET value_json='true' "
+ "WHERE adapter='satpass' AND key='enabled'")
+ conn.execute("UPDATE adapter_config SET value_json='false' "
+ "WHERE adapter='satpass' AND key='dry_run'")
+ conn.execute("UPDATE adapter_config SET value_json=? "
+ "WHERE adapter='satpass' AND key='max_broadcasts_per_hour'",
+ (_json.dumps(100),))
+ invalidate_cache()
+
+ # -- patch observers + predictor -------------------------------------------
+ monkeypatch.setattr(
+ "meshai.persistence.observer_locations.get_observers",
+ lambda *a, **k: [_TREASURE_VALLEY, _MAGIC_VALLEY])
+
+ t0 = (1783000000 // 3600) * 3600 + 100
+
+ def _fake_compute_passes(l1, l2, lat, lon, alt, window_h, min_el, now):
+ def _pi(aos, los, max_el, az_aos, az_los, az_peak):
+ peak = (aos + los) // 2
+ return PassInfo(
+ aos_time=datetime.fromtimestamp(aos, tz=timezone.utc),
+ los_time=datetime.fromtimestamp(los, tz=timezone.utc),
+ peak_time=datetime.fromtimestamp(peak, tz=timezone.utc),
+ max_elevation=max_el,
+ azimuth_at_aos=az_aos, azimuth_at_los=az_los,
+ azimuth_at_peak=az_peak)
+ if abs(lat - _TREASURE_VALLEY["lat"]) < 0.1:
+ return [_pi(t0, t0 + 300, 40.0, 225, 300, 90)]
+ if abs(lat - _MAGIC_VALLEY["lat"]) < 0.1:
+ return [_pi(t0 + 60, t0 + 400, 70.0, 270, 45, 180)]
+ return []
+
+ monkeypatch.setattr(
+ "meshai.env.satellite.pass_predictor.compute_passes",
+ _fake_compute_passes)
+
+ cfg = SatpassConfig(enabled=True, feed_source="native",
+ norad_ids=[25544], min_elevation_deg=10.0,
+ window_hours=24)
+ adapter = SatpassAdapter(cfg)
+ assert adapter.tick(now=t0 - 1800) is True # AOS 30 min out -> imminent
+
+ staged = adapter.get_events()
+ assert len(staged) == 1
+ event = adapter.to_event(staged[0])
+ assert event is not None
+
+ # The bug: before the fix, data["observer_list"] was never set, so
+ # this was always [] regardless of area config.
+ assert event.data.get("observer_list"), (
+ "gate_consolidated_pass() did not populate observer_list on the "
+ "event data dict")
+
+ names = event_region_names(event, _PROD_AREAS)
+ assert names, "satpass event failed to region-tag via observer_list"
+ assert set(names) == {"SW Idaho", "SC Idaho"}
diff --git a/work/tests/test_store_received_delta.py b/work/tests/test_store_received_delta.py
index 77555fc..1b253ef 100644
--- a/work/tests/test_store_received_delta.py
+++ b/work/tests/test_store_received_delta.py
@@ -14,6 +14,7 @@ These tests drive the real EnvironmentalStore + EventBus with a fake adapter
whose per-poll batch we control, and assert exactly which events reach the bus.
"""
from __future__ import annotations
+import asyncio
from meshai.env.store import EnvironmentalStore, _key_ext
from meshai.config import EnvironmentalConfig
@@ -79,7 +80,7 @@ def test_first_poll_seeds_and_broadcasts_nothing():
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # poll 1 — the backlog
+ asyncio.run(store.refresh()) # poll 1 — the backlog
assert captured == [], "first poll must broadcast NOTHING (backlog seed)"
@@ -88,11 +89,11 @@ def test_second_poll_emits_only_newly_received():
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # poll 1: seed
+ asyncio.run(store.refresh()) # poll 1: seed
assert _emitted_ids(captured) == []
adapter.set_batch(["A", "B", "C", "D"])
- store.refresh() # poll 2: only D is new
+ asyncio.run(store.refresh()) # poll 2: only D is new
assert _emitted_ids(captured) == ["D"]
@@ -100,11 +101,11 @@ def test_unchanged_poll_emits_nothing():
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # poll 1: seed
+ asyncio.run(store.refresh()) # poll 1: seed
adapter.set_batch(["A", "B", "C", "D"])
- store.refresh() # poll 2: D
+ asyncio.run(store.refresh()) # poll 2: D
adapter.set_batch(["A", "B", "C", "D"])
- store.refresh() # poll 3: nothing new
+ asyncio.run(store.refresh()) # poll 3: nothing new
assert _emitted_ids(captured) == ["D"], "poll 3 has no new items"
@@ -113,21 +114,21 @@ def test_restart_reseeds_and_never_rebroadcasts_backlog():
# Process 1 sees A,B,C,D and broadcasts D.
store1, adapter1, cap1 = _make_store()
adapter1.set_batch(["A", "B", "C"])
- store1.refresh()
+ asyncio.run(store1.refresh())
adapter1.set_batch(["A", "B", "C", "D"])
- store1.refresh()
+ asyncio.run(store1.refresh())
assert _emitted_ids(cap1) == ["D"]
# RESTART: a fresh store has an empty seen-set. The SAME backlog [A,B,C,D]
# arriving on its first poll must be re-seeded silently, not re-broadcast.
store2, adapter2, cap2 = _make_store()
adapter2.set_batch(["A", "B", "C", "D"])
- store2.refresh()
+ asyncio.run(store2.refresh())
assert cap2 == [], "restart must NEVER re-broadcast the existing backlog"
# And a genuinely new item after the restart still broadcasts once.
adapter2.set_batch(["A", "B", "C", "D", "E"])
- store2.refresh()
+ asyncio.run(store2.refresh())
assert _emitted_ids(cap2) == ["E"]
@@ -135,9 +136,9 @@ def test_stable_key_prevents_reemit_when_batch_reorders():
# The same real-world items in a different order are NOT "newly received".
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # seed
+ asyncio.run(store.refresh()) # seed
adapter.set_batch(["C", "A", "B"]) # reordered, same items
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "reordering the same items emits nothing"
@@ -147,12 +148,12 @@ def test_disabled_for_days_then_backlog_is_not_broadcast():
store, adapter, captured = _make_store()
backlog = [f"evt{i}" for i in range(200)]
adapter.set_batch(backlog)
- store.refresh() # first poll after re-enable
+ asyncio.run(store.refresh()) # first poll after re-enable
assert captured == [], "a days-old backlog is seeded silently, never sent"
# Only a truly new arrival afterward is announced.
adapter.set_batch(backlog + ["fresh"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["fresh"]
@@ -290,11 +291,11 @@ def test_persistent_preseed_known_suppressed_new_emitted():
assert len(store._seen["wzdx"]) == 5
adapter.set_batch(known)
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "all 5 are durably-known → zero broadcast"
adapter.set_batch(known + ["z_new"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["z_new"], "only the not-in-table id broadcasts"
@@ -307,9 +308,9 @@ def test_persistent_preseed_cross_tick_staging_no_leak():
store, captured = _build_store(_GENERIC_NAME, adapter)
adapter.set_batch(["A"])
- store.refresh() # tick 1: only A present
+ asyncio.run(store.refresh()) # tick 1: only A present
adapter.set_batch(["A", "B"])
- store.refresh() # tick 2: B appears (backlog)
+ asyncio.run(store.refresh()) # tick 2: B appears (backlog)
assert captured == [], "B is durably-known — must NOT leak on a later tick"
# CONTROL: identical staging but NO durable rows → B leaks (proves the
@@ -325,11 +326,11 @@ def test_persistent_preseed_cross_tick_staging_no_leak():
# Re-point ctrl events to a fresh source with no durable rows.
for e in ctrl._batch:
e["source"] = "wzdx_ctrl"
- store2.refresh()
+ asyncio.run(store2.refresh())
ctrl.set_batch(["A", "B"])
for e in ctrl._batch:
e["source"] = "wzdx_ctrl"
- store2.refresh()
+ asyncio.run(store2.refresh())
assert [e.title for e in cap2] == ["B"], "without a durable record, B leaks"
@@ -343,11 +344,11 @@ def test_incremental_empty_first_tick_then_only_new_broadcasts():
store, captured = _build_store(_GENERIC_NAME, adapter)
adapter.set_batch([]) # empty first tick
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "empty tick emits nothing"
adapter.set_batch(["A", "B", "C"]) # backlog A,B + new C
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["C"], "only the never-received C broadcasts"
@@ -360,18 +361,18 @@ def test_restart_against_same_persistent_db_never_rebroadcasts():
a1 = _FakeWZDx()
store1, cap1 = _build_store(_GENERIC_NAME, a1)
a1.set_batch(backlog)
- store1.refresh()
+ asyncio.run(store1.refresh())
assert cap1 == [], "process 1: durable backlog is silent"
# RESTART: brand-new store, same persistent DB → pre-seed reloads.
a2 = _FakeWZDx()
store2, cap2 = _build_store(_GENERIC_NAME, a2)
a2.set_batch(backlog)
- store2.refresh()
+ asyncio.run(store2.refresh())
assert cap2 == [], "restart must NEVER re-broadcast the durable backlog"
a2.set_batch(backlog + ["E"])
- store2.refresh()
+ asyncio.run(store2.refresh())
assert _emitted_ids(cap2) == ["E"], "a genuinely-new item still broadcasts once"
@@ -385,11 +386,11 @@ def test_persistent_preseed_quake_by_event_id():
assert len(store._seen["usgs_quake"]) == 2
adapter.set_batch(["us1000aaaa", "us1000bbbb"])
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "both quakes already received → zero broadcast"
adapter.set_batch(["us1000aaaa", "us1000bbbb", "us1000cccc"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["us1000cccc"], "only the new quake broadcasts"
@@ -402,11 +403,11 @@ def test_no_durable_rows_falls_back_to_silent_first_poll():
assert "wzdx" not in store._seeded, "0 durable rows → not pre-marked seeded"
adapter.set_batch(["A", "B"])
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "first non-empty poll on a fresh DB is silent"
adapter.set_batch(["A", "B", "C"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["C"]
@@ -486,9 +487,9 @@ def test_persistent_preseed_roads511_by_external_id():
assert len(store._seen["511"]) == 4
adapter.set_batch(known)
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "all 4 durably-known 511 rows → zero broadcast"
adapter.set_batch(known + ["511_99"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["511_99"], "only the not-in-table id broadcasts"
diff --git a/work/tests/test_store_wzdx_persist.py b/work/tests/test_store_wzdx_persist.py
index 13422ee..4e60a97 100644
--- a/work/tests/test_store_wzdx_persist.py
+++ b/work/tests/test_store_wzdx_persist.py
@@ -20,6 +20,7 @@ adapter whose per-poll coalesced set we control, then assert directly against
traffic_events AND against the bus (nothing must ever be dispatched).
"""
from __future__ import annotations
+import asyncio
from meshai.env.store import EnvironmentalStore
from meshai.config import EnvironmentalConfig
@@ -147,7 +148,7 @@ def test_first_poll_persists_current_set_and_broadcasts_nothing():
store, captured = _build_store(adapter)
adapter.set_zones(ZONES3)
- store.refresh() # first (cold-start) poll
+ asyncio.run(store.refresh()) # first (cold-start) poll
rows = _wzdx_rows()
exts = {r["external_id"] for r in rows}
@@ -171,7 +172,7 @@ def test_columns_match_summary_and_dm_queries():
adapter = _FakeWZDx()
store, _ = _build_store(adapter)
adapter.set_zones([ZONES3[1]]) # the full_closure I-84 zone
- store.refresh()
+ asyncio.run(store.refresh())
r = _wzdx_rows()[0]
assert r["road"] == "I-84"
@@ -188,12 +189,12 @@ def test_subsequent_poll_reconciles_removed_zone():
adapter = _FakeWZDx()
store, captured = _build_store(adapter)
adapter.set_zones(ZONES3)
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 3
# Next poll: US-20 dropped out; I-84 + ID-55 remain.
adapter.set_zones([ZONES3[1], ZONES3[2]])
- store.refresh()
+ asyncio.run(store.refresh())
exts = {r["external_id"] for r in _wzdx_rows()}
assert exts == {ZONES3[1]["ext"], ZONES3[2]["ext"]}, (
@@ -207,11 +208,11 @@ def test_empty_or_failed_fetch_does_not_wipe_existing_rows():
adapter = _FakeWZDx()
store, _ = _build_store(adapter)
adapter.set_zones(ZONES3)
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 3
adapter.set_raw([]) # empty/failed poll
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 3, (
"an empty fetch must NEVER wipe the existing active set")
@@ -225,7 +226,7 @@ def test_upsert_preserves_first_seen_at_and_refreshes_end_at():
z = dict(ZONES3[0]); z["end_at"] = 1000
adapter.set_zones([z])
- store.refresh()
+ asyncio.run(store.refresh())
r1 = _wzdx_rows()[0]
first_seen = r1["first_seen_at"]
assert r1["end_at"] == 1000
@@ -233,7 +234,7 @@ def test_upsert_preserves_first_seen_at_and_refreshes_end_at():
# Same zone reappears with a LATER end_at.
z2 = dict(ZONES3[0]); z2["end_at"] = 5000
adapter.set_zones([z2])
- store.refresh()
+ asyncio.run(store.refresh())
r2 = _wzdx_rows()[0]
assert r2["first_seen_at"] == first_seen, "first_seen_at must be preserved"
assert r2["end_at"] == 5000, "end_at must refresh from the feed"
@@ -254,7 +255,7 @@ def test_expiry_end_at_preserved_for_not_expired_filter():
"sub_type": "x", "impact": "partial", "end_at": now - 10_000}, # expired
]
adapter.set_zones(zones)
- store.refresh()
+ asyncio.run(store.refresh())
# All 3 persisted (ingest does not itself drop expired rows) ...
assert len(_wzdx_rows()) == 3
@@ -269,7 +270,7 @@ def test_id_less_zone_is_skipped_not_fatal():
store, _ = _build_store(adapter)
good = ZONES3[0]
adapter.set_zones([good])
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 1
# Poll with the good zone plus an id-less junk event.
@@ -277,7 +278,7 @@ def test_id_less_zone_is_skipped_not_fatal():
junk = {"source": "wzdx", "event_id": None, "external_id": None,
"lat": 5.0, "lon": 5.0, "normalized": {}, "fetched_at": 0}
adapter._batch.append(junk)
- store.refresh()
+ asyncio.run(store.refresh())
rows = _wzdx_rows()
assert {r["external_id"] for r in rows} == {good["ext"]}, (
@@ -296,7 +297,7 @@ def test_bulk_current_set_persists_all_like_the_real_127():
for i in range(127)
]
adapter.set_zones(zones)
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 127
assert _summary_visible_count(now=0) == 127, (
diff --git a/work/tests/test_wfigs_handler.py b/work/tests/test_wfigs_handler.py
index c81728b..7fb2a21 100644
--- a/work/tests/test_wfigs_handler.py
+++ b/work/tests/test_wfigs_handler.py
@@ -357,8 +357,10 @@ def test_k_anchor_falls_to_nearest_town(monkeypatch, mem_db):
county="Cassia")
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
- # Resolves anchor via town_anchors table (Burley @ 42.536, -113.793)
- assert "Burley" in wire
+ # Resolves anchor via town_anchors table (Oakley @ 42.24206, -113.883058
+ # -- now the nearest seeded anchor to the incident's 42.197,-113.710
+ # after the full-list seed sync; Burley is farther away)
+ assert "Oakley" in wire
def test_k_anchor_falls_to_landclass(monkeypatch, mem_db):
@@ -372,7 +374,8 @@ def test_k_anchor_falls_to_landclass(monkeypatch, mem_db):
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
# Resolves nearest town from town_anchors table, overriding landclass
- assert "Burley" in wire
+ # (Oakley is nearest to 42.197,-113.710 after the full-list seed sync)
+ assert "Oakley" in wire
def test_k_anchor_falls_to_county(monkeypatch, mem_db):
@@ -385,7 +388,8 @@ def test_k_anchor_falls_to_county(monkeypatch, mem_db):
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
# Resolves nearest town from town_anchors table
- assert "Burley" in wire
+ # (Oakley is nearest to 42.197,-113.710 after the full-list seed sync)
+ assert "Oakley" in wire
def test_k_anchor_nearest_town_under_one_mile_says_near(monkeypatch, mem_db):
@@ -398,7 +402,8 @@ def test_k_anchor_nearest_town_under_one_mile_says_near(monkeypatch, mem_db):
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
# Anchor resolved via town_anchors; exact format depends on distance
- assert "Burley" in wire
+ # (Oakley is nearest to 42.197,-113.710 after the full-list seed sync)
+ assert "Oakley" in wire
# ============================================================================