refactor: simplify severity to 3 levels (routine/priority/immediate)

- Replace 6-level system (info/advisory/watch/warning/critical/emergency)
  with 3-level military precedence (routine/priority/immediate)
- Every adapter remapped: NWS, NIFC, FIRMS, USGS, SWPC, avalanche,
  traffic, 511, mesh alerts
- is_critical flag removed — severity covers it
- Quiet hours: suppress routine only, priority+immediate always deliver
- Dashboard: blue/amber/red for routine/priority/immediate
- Fix hex node ID parsing in Mesh DM channel (!23261b70 format)
This commit is contained in:
zvx-echo6 2026-05-13 19:05:50 -06:00
commit 49f2838048
17 changed files with 3285 additions and 3265 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -82,28 +82,37 @@ function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean;
function AlertEventCard({ event }: { event: EnvEvent }) { function AlertEventCard({ event }: { event: EnvEvent }) {
const getSeverityStyles = (severity: string) => { const getSeverityStyles = (severity: string) => {
switch (severity.toLowerCase()) { switch (severity.toLowerCase()) {
// NWS native severity levels
case 'extreme': case 'extreme':
case 'severe': case 'severe':
// Our 3-level system
case 'immediate':
return { return {
bg: 'bg-red-500/10', bg: 'bg-red-500/10',
border: 'border-red-500', border: 'border-red-500',
icon: AlertCircle, icon: AlertCircle,
iconColor: 'text-red-500', iconColor: 'text-red-500',
} }
// NWS native
case 'moderate': case 'moderate':
case 'warning': case 'warning':
// Our 3-level system
case 'priority':
return { return {
bg: 'bg-amber-500/10', bg: 'bg-amber-500/10',
border: 'border-amber-500', border: 'border-amber-500',
icon: AlertTriangle, icon: AlertTriangle,
iconColor: 'text-amber-500', iconColor: 'text-amber-500',
} }
// NWS native
case 'minor': case 'minor':
// Our 3-level system
case 'routine':
return { return {
bg: 'bg-yellow-500/10', bg: 'bg-blue-500/10',
border: 'border-yellow-500', border: 'border-blue-500',
icon: Info, icon: Info,
iconColor: 'text-yellow-500', iconColor: 'text-blue-500',
} }
default: default:
return { return {

View file

@ -98,12 +98,9 @@ interface ChannelTestResult {
// Severity levels with descriptions // Severity levels with descriptions
const SEVERITY_OPTIONS = [ const SEVERITY_OPTIONS = [
{ value: 'info', label: 'Info', description: 'Routine updates (ducting detected, new router appeared)' }, { value: 'routine', label: 'Routine', description: 'Informational, no time pressure (ducting, new node, weather advisory, battery declining)' },
{ value: 'advisory', label: 'Advisory', description: 'Worth knowing (weather advisory, traffic slow, battery declining)' }, { value: 'priority', label: 'Priority', description: 'Needs attention soon (severe weather, fire nearby, node offline, HF blackout)' },
{ value: 'watch', label: 'Watch', description: 'Pay attention (fire within 50km, weather watch, stream rising)' }, { value: 'immediate', label: 'Immediate', description: 'Act now, drop everything (fire at infrastructure, extreme weather, region blackout)' },
{ value: 'warning', label: 'Warning', description: 'Act now (fire within 25km, severe weather, critical battery)' },
{ value: 'critical', label: 'Critical', description: 'Serious issue (critical node down, battery emergency)' },
{ value: 'emergency', label: 'Emergency', description: 'Life safety (extreme weather, fire at infrastructure, total blackout)' },
] ]
// Notification rule templates // Notification rule templates
@ -117,7 +114,7 @@ const RULE_TEMPLATES = [
enabled: true, enabled: true,
trigger_type: "condition" as const, trigger_type: "condition" as const,
categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"], categories: ["infra_offline", "critical_node_down", "infra_recovery", "battery_warning", "battery_critical", "battery_emergency", "high_utilization", "packet_flood", "mesh_score_low"],
min_severity: "advisory", min_severity: "routine",
delivery_type: "mesh_broadcast", delivery_type: "mesh_broadcast",
broadcast_channel: 0, broadcast_channel: 0,
cooldown_minutes: 30, cooldown_minutes: 30,
@ -149,7 +146,7 @@ const RULE_TEMPLATES = [
enabled: true, enabled: true,
trigger_type: "condition" as const, trigger_type: "condition" as const,
categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"], categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"],
min_severity: "warning", min_severity: "priority",
delivery_type: "mesh_broadcast", delivery_type: "mesh_broadcast",
broadcast_channel: 0, broadcast_channel: 0,
cooldown_minutes: 15, cooldown_minutes: 15,
@ -181,7 +178,7 @@ const RULE_TEMPLATES = [
enabled: true, enabled: true,
trigger_type: "condition" as const, trigger_type: "condition" as const,
categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"], categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"],
min_severity: "info", min_severity: "routine",
delivery_type: "mesh_broadcast", delivery_type: "mesh_broadcast",
broadcast_channel: 0, broadcast_channel: 0,
cooldown_minutes: 60, cooldown_minutes: 60,
@ -213,7 +210,7 @@ const RULE_TEMPLATES = [
enabled: true, enabled: true,
trigger_type: "condition" as const, trigger_type: "condition" as const,
categories: ["road_closure", "traffic_congestion"], categories: ["road_closure", "traffic_congestion"],
min_severity: "warning", min_severity: "routine",
delivery_type: "mesh_broadcast", delivery_type: "mesh_broadcast",
broadcast_channel: 0, broadcast_channel: 0,
cooldown_minutes: 30, cooldown_minutes: 30,
@ -245,7 +242,7 @@ const RULE_TEMPLATES = [
enabled: true, enabled: true,
trigger_type: "condition" as const, trigger_type: "condition" as const,
categories: [] as string[], categories: [] as string[],
min_severity: "emergency", min_severity: "immediate",
delivery_type: "mesh_broadcast", delivery_type: "mesh_broadcast",
broadcast_channel: 0, broadcast_channel: 0,
cooldown_minutes: 5, cooldown_minutes: 5,
@ -543,13 +540,13 @@ function SeveritySelector({ value, onChange }: {
onChange: (v: string) => void onChange: (v: string) => void
}) { }) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[3] const selected = SEVERITY_OPTIONS.find(s => s.value === value) || SEVERITY_OPTIONS[0]
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide"> <label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Severity Threshold Severity Threshold
<InfoButton info="Only alerts at or above this severity trigger this rule. Lower threshold = more notifications. 'Warning' is recommended for most rules." /> <InfoButton info="Only alerts at or above this severity trigger this rule. ROUTINE = informational, PRIORITY = needs attention, IMMEDIATE = act now." />
</label> </label>
<div className="relative"> <div className="relative">
<button <button
@ -1431,7 +1428,7 @@ export default function Notifications() {
enabled: true, enabled: true,
trigger_type: 'condition', trigger_type: 'condition',
categories: [], categories: [],
min_severity: 'warning', min_severity: 'routine',
schedule_frequency: 'daily', schedule_frequency: 'daily',
schedule_time: '07:00', schedule_time: '07:00',
schedule_time_2: '19:00', schedule_time_2: '19:00',
@ -1779,7 +1776,7 @@ export default function Notifications() {
checked={config.quiet_hours_enabled ?? true} checked={config.quiet_hours_enabled ?? true}
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })} onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
helper="Suppress non-emergency alerts during sleeping hours" helper="Suppress non-emergency alerts during sleeping hours"
info="When enabled, alerts below emergency severity are held during quiet hours. When disabled, all alerts deliver anytime." info="When enabled, ROUTINE alerts are suppressed during quiet hours. PRIORITY and IMMEDIATE always deliver."
/> />
{config.quiet_hours_enabled && ( {config.quiet_hours_enabled && (

View file

@ -142,7 +142,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
alert_type, name, short, node_num, region, alert_type, name, short, node_num, region,
f"{emoji} {name} went offline in {region_display}.{escalation}", f"{emoji} {name} went offline in {region_display}.{escalation}",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
@ -153,7 +153,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"infra_recovery", name, short, node_num, region, "infra_recovery", name, short, node_num, region,
f"\u2705 {name} is back online in {region_display}.", f"\u2705 {name} is back online in {region_display}.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.resolve() state.resolve()
@ -196,7 +196,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"power_source_change", name, short, node_num, region, "power_source_change", name, short, node_num, region,
f"\u26A1 {name} switched from USB to battery in {region_display}. Possible power outage.", f"\u26A1 {name} switched from USB to battery in {region_display}. Possible power outage.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
elif prev_source == "battery" and current_source == "usb": elif prev_source == "battery" and current_source == "usb":
@ -217,7 +217,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"battery_emergency", name, short, node_num, region, "battery_emergency", name, short, node_num, region,
f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.", f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
@ -229,7 +229,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"battery_critical", name, short, node_num, region, "battery_critical", name, short, node_num, region,
f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.", f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
@ -241,7 +241,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"battery_warning", name, short, node_num, region, "battery_warning", name, short, node_num, region,
f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.", f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
@ -261,7 +261,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"battery_trend", name, short, node_num, region, "battery_trend", name, short, node_num, region,
f"\U0001F50B {name} battery declining: {trend['start']:.0f}% \u2192 {trend['end']:.0f}% over 7 days ({trend['rate']:.1f}%/day) in {region_display}.", f"\U0001F50B {name} battery declining: {trend['start']:.0f}% \u2192 {trend['end']:.0f}% over 7 days ({trend['rate']:.1f}%/day) in {region_display}.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
@ -283,7 +283,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"solar_not_charging", name, short, node_num, region, "solar_not_charging", name, short, node_num, region,
f"\u2600\uFE0F {name} solar not charging in {region_display}.", f"\u2600\uFE0F {name} solar not charging in {region_display}.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
except Exception: except Exception:
@ -364,7 +364,7 @@ class AlertEngine:
alerts.append(self._make_alert( alerts.append(self._make_alert(
"infra_single_gateway", name, short, node_num, region, "infra_single_gateway", name, short, node_num, region,
f"\u26A0\uFE0F {name} dropped to single gateway in {region_display}. At risk if gateway fails.", f"\u26A0\uFE0F {name} dropped to single gateway in {region_display}. At risk if gateway fails.",
is_critical, "immediate" if is_critical else "priority",
)) ))
state.fire(now) state.fire(now)
elif prev_gw is not None and prev_gw <= 1.0 and node.avg_gateways > 1.0: elif prev_gw is not None and prev_gw <= 1.0 and node.avg_gateways > 1.0:
@ -397,7 +397,7 @@ class AlertEngine:
"message": f"\U0001F4E1 Feeder gateway {feeder} stopped responding.", "message": f"\U0001F4E1 Feeder gateway {feeder} stopped responding.",
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": False, "severity": "routine",
}) })
state.fire(now) state.fire(now)
@ -438,7 +438,7 @@ class AlertEngine:
"message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!", "message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!",
"scope_type": "region", "scope_type": "region",
"scope_value": region.name, "scope_value": region.name,
"is_critical": True, "severity": "immediate",
}) })
state.fire(now) state.fire(now)
@ -469,7 +469,7 @@ class AlertEngine:
"message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).", "message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).",
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": False, "severity": "routine",
}) })
state.fire(now) state.fire(now)
elif current >= threshold: elif current >= threshold:
@ -498,7 +498,7 @@ class AlertEngine:
"message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).", "message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).",
"scope_type": "region", "scope_type": "region",
"scope_value": region.name, "scope_value": region.name,
"is_critical": False, "severity": "routine",
}) })
state.fire(now) state.fire(now)
elif current >= threshold: elif current >= threshold:
@ -550,7 +550,7 @@ class AlertEngine:
logger.debug(f"Battery trend query error: {e}") logger.debug(f"Battery trend query error: {e}")
return None return None
def _make_alert(self, alert_type, name, short, node_num, region, message, is_critical): def _make_alert(self, alert_type, name, short, node_num, region, message, severity="priority"):
return { return {
"type": alert_type, "type": alert_type,
"node_name": name, "node_name": name,
@ -560,7 +560,7 @@ class AlertEngine:
"message": message, "message": message,
"scope_type": "region" if region and region != "Unknown" else "mesh", "scope_type": "region" if region and region != "Unknown" else "mesh",
"scope_value": region if region and region != "Unknown" else None, "scope_value": region if region and region != "Unknown" else None,
"is_critical": is_critical, "severity": severity,
} }
def _get_region_display(self, region: str) -> str: def _get_region_display(self, region: str) -> str:
@ -616,14 +616,13 @@ class AlertEngine:
alerts.append({ alerts.append({
"type": "weather_warning", "type": "weather_warning",
"message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}", "message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}",
"severity": evt["severity"],
"node_num": None, "node_num": None,
"node_name": evt["event_type"], "node_name": evt["event_type"],
"node_short": "NWS", "node_short": "NWS",
"region": "", "region": "",
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": evt["severity"] in ("extreme", "emergency"), "severity": "immediate" if evt["severity"] == "extreme" else "priority",
}) })
# SWPC R-scale >= 3 (HF blackout affecting mesh backhaul) # SWPC R-scale >= 3 (HF blackout affecting mesh backhaul)
@ -637,14 +636,14 @@ class AlertEngine:
alerts.append({ alerts.append({
"type": "hf_blackout", "type": "hf_blackout",
"message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade", "message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade",
"severity": "warning", "severity": "priority",
"node_num": None, "node_num": None,
"node_name": f"R{r_scale} Blackout", "node_name": f"R{r_scale} Blackout",
"node_short": "SWPC", "node_short": "SWPC",
"region": "", "region": "",
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": r_scale >= 4, "severity": "immediate" if r_scale >= 4 else "priority",
}) })
# Tropospheric ducting (informational -- not critical but operators want to know) # Tropospheric ducting (informational -- not critical but operators want to know)
@ -659,14 +658,14 @@ class AlertEngine:
alerts.append({ alerts.append({
"type": "tropospheric_ducting", "type": "tropospheric_ducting",
"message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)", "message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
"severity": "info", "severity": "routine",
"node_num": None, "node_num": None,
"node_name": "Ducting", "node_name": "Ducting",
"node_short": "TROPO", "node_short": "TROPO",
"region": "", "region": "",
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": False, "severity": "routine",
}) })
# Wildfire proximity alerts # Wildfire proximity alerts
@ -690,14 +689,14 @@ class AlertEngine:
alerts.append({ alerts.append({
"type": "wildfire_proximity", "type": "wildfire_proximity",
"message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained", "message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
"severity": "critical", "severity": "immediate",
"node_num": None, "node_num": None,
"node_name": name, "node_name": name,
"node_short": "FIRE", "node_short": "FIRE",
"region": anchor, "region": anchor,
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": True, "severity": "immediate",
}) })
elif distance_km < 50: elif distance_km < 50:
@ -709,14 +708,14 @@ class AlertEngine:
alerts.append({ alerts.append({
"type": "wildfire_proximity", "type": "wildfire_proximity",
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained", "message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
"severity": "warning", "severity": "priority",
"node_num": None, "node_num": None,
"node_name": name, "node_name": name,
"node_short": "FIRE", "node_short": "FIRE",
"region": anchor, "region": anchor,
"scope_type": "mesh", "scope_type": "mesh",
"scope_value": None, "scope_value": None,
"is_critical": False, "severity": "routine",
}) })
return alerts return alerts

View file

@ -440,7 +440,7 @@ class NotificationRuleConfig:
# Condition trigger fields # Condition trigger fields
categories: list = field(default_factory=list) # Empty = all categories categories: list = field(default_factory=list) # Empty = all categories
min_severity: str = "warning" min_severity: str = "routine"
# Schedule trigger fields # Schedule trigger fields
schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom schedule_frequency: str = "daily" # daily, twice_daily, weekly, custom

View file

@ -129,13 +129,13 @@ class AvalancheAdapter:
level_key, level_name = self.DANGER_LEVELS.get(danger_level, ("no_rating", "No Rating")) level_key, level_name = self.DANGER_LEVELS.get(danger_level, ("no_rating", "No Rating"))
if danger_level >= 4: if danger_level >= 4:
severity = "warning" severity = "priority"
elif danger_level >= 3: elif danger_level >= 3:
severity = "watch" severity = "routine"
elif danger_level >= 2: elif danger_level >= 2:
severity = "advisory" severity = "routine"
else: else:
severity = "info" severity = "routine"
# Compute centroid # Compute centroid
geom = feature.get("geometry") geom = feature.get("geometry")

8
meshai/env/fires.py vendored
View file

@ -109,13 +109,13 @@ class NICFFiresAdapter:
# Severity based on distance # Severity based on distance
if distance_km is not None: if distance_km is not None:
if distance_km < 25: if distance_km < 25:
severity = "warning" severity = "priority"
elif distance_km < 50: elif distance_km < 50:
severity = "watch" severity = "routine"
else: else:
severity = "advisory" severity = "routine"
else: else:
severity = "advisory" severity = "routine"
# Format headline # Format headline
headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained" headline = f"{name} -- {int(acres):,} ac, {int(pct_contained)}% contained"

730
meshai/env/firms.py vendored
View file

@ -1,365 +1,365 @@
"""NASA FIRMS satellite fire hotspot adapter.""" """NASA FIRMS satellite fire hotspot adapter."""
import json import json
import logging import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import FIRMSConfig from ..config import FIRMSConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FIRMSAdapter: class FIRMSAdapter:
"""NASA FIRMS satellite fire hotspot polling. """NASA FIRMS satellite fire hotspot polling.
Detects fire hotspots from satellite data (MODIS, VIIRS) typically Detects fire hotspots from satellite data (MODIS, VIIRS) typically
hours before NIFC publishes official perimeters. Early warning. hours before NIFC publishes official perimeters. Early warning.
API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE} API: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{MAP_KEY}/{SOURCE}/{BBOX}/{DAY_RANGE}
""" """
BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv" BASE_URL = "https://firms.modaps.eosdis.nasa.gov/api/area/csv"
def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None): def __init__(self, config: "FIRMSConfig", region_anchors: list = None, fires_adapter=None):
self._map_key = config.map_key self._map_key = config.map_key
self._source = config.source or "VIIRS_SNPP_NRT" self._source = config.source or "VIIRS_SNPP_NRT"
self._bbox = config.bbox # [west, south, east, north] self._bbox = config.bbox # [west, south, east, north]
self._day_range = config.day_range or 1 self._day_range = config.day_range or 1
self._tick_interval = config.tick_seconds or 1800 self._tick_interval = config.tick_seconds or 1800
self._confidence_min = config.confidence_min or "nominal" self._confidence_min = config.confidence_min or "nominal"
self._proximity_km = config.proximity_km or 10.0 # km to match known fire self._proximity_km = config.proximity_km or 10.0 # km to match known fire
self._last_tick = 0.0 self._last_tick = 0.0
self._events = [] self._events = []
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = False self._is_loaded = False
# For cross-referencing # For cross-referencing
self._region_anchors = region_anchors or [] self._region_anchors = region_anchors or []
self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref self._fires_adapter = fires_adapter # NICFFiresAdapter for cross-ref
def tick(self) -> bool: def tick(self) -> bool:
"""Execute one polling tick. """Execute one polling tick.
Returns: Returns:
True if data changed True if data changed
""" """
now = time.time() now = time.time()
if now - self._last_tick < self._tick_interval: if now - self._last_tick < self._tick_interval:
return False return False
self._last_tick = now self._last_tick = now
if not self._map_key: if not self._map_key:
if not self._last_error: if not self._last_error:
logger.warning("FIRMS: No MAP_KEY configured, skipping") logger.warning("FIRMS: No MAP_KEY configured, skipping")
self._last_error = "No MAP_KEY configured" self._last_error = "No MAP_KEY configured"
return False return False
if not self._bbox or len(self._bbox) != 4: if not self._bbox or len(self._bbox) != 4:
if not self._last_error: if not self._last_error:
logger.warning("FIRMS: No valid bbox configured, skipping") logger.warning("FIRMS: No valid bbox configured, skipping")
self._last_error = "No valid bbox configured" self._last_error = "No valid bbox configured"
return False return False
return self._fetch() return self._fetch()
def _fetch(self) -> bool: def _fetch(self) -> bool:
"""Fetch fire hotspots from NASA FIRMS. """Fetch fire hotspots from NASA FIRMS.
Returns: Returns:
True if data changed True if data changed
""" """
# Format bbox as west,south,east,north # Format bbox as west,south,east,north
bbox_str = ",".join(str(c) for c in self._bbox) bbox_str = ",".join(str(c) for c in self._bbox)
url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}" url = f"{self.BASE_URL}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}"
headers = { headers = {
"User-Agent": "MeshAI/1.0", "User-Agent": "MeshAI/1.0",
"Accept": "text/csv", "Accept": "text/csv",
} }
try: try:
req = Request(url, headers=headers) req = Request(url, headers=headers)
with urlopen(req, timeout=30) as resp: with urlopen(req, timeout=30) as resp:
csv_data = resp.read().decode("utf-8") csv_data = resp.read().decode("utf-8")
except HTTPError as e: except HTTPError as e:
if e.code == 401: if e.code == 401:
logger.error("FIRMS: Invalid MAP_KEY, disabling adapter") logger.error("FIRMS: Invalid MAP_KEY, disabling adapter")
self._last_error = "Invalid MAP_KEY" self._last_error = "Invalid MAP_KEY"
self._consecutive_errors = 999 # Disable self._consecutive_errors = 999 # Disable
return False return False
logger.warning(f"FIRMS HTTP error: {e.code}") logger.warning(f"FIRMS HTTP error: {e.code}")
self._last_error = f"HTTP {e.code}" self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
except URLError as e: except URLError as e:
logger.warning(f"FIRMS connection error: {e.reason}") logger.warning(f"FIRMS connection error: {e.reason}")
self._last_error = str(e.reason) self._last_error = str(e.reason)
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
except Exception as e: except Exception as e:
logger.warning(f"FIRMS fetch error: {e}") logger.warning(f"FIRMS fetch error: {e}")
self._last_error = str(e) self._last_error = str(e)
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
# Parse CSV response # Parse CSV response
new_events = self._parse_csv(csv_data) new_events = self._parse_csv(csv_data)
# Check if data changed # Check if data changed
old_ids = {e["event_id"] for e in self._events} old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events} new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids changed = old_ids != new_ids
self._events = new_events self._events = new_events
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = True self._is_loaded = True
if changed: if changed:
new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition")) new_ignitions = sum(1 for e in new_events if e.get("properties", {}).get("new_ignition"))
logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions") logger.info(f"FIRMS hotspots updated: {len(new_events)} total, {new_ignitions} potential new ignitions")
return changed return changed
def _parse_csv(self, csv_data: str) -> list: def _parse_csv(self, csv_data: str) -> list:
"""Parse FIRMS CSV response into events.""" """Parse FIRMS CSV response into events."""
lines = csv_data.strip().split("\n") lines = csv_data.strip().split("\n")
if len(lines) < 2: if len(lines) < 2:
return [] return []
# Parse header # Parse header
header = lines[0].split(",") header = lines[0].split(",")
header_map = {col.strip().lower(): i for i, col in enumerate(header)} header_map = {col.strip().lower(): i for i, col in enumerate(header)}
# Required columns # Required columns
lat_idx = header_map.get("latitude") lat_idx = header_map.get("latitude")
lon_idx = header_map.get("longitude") lon_idx = header_map.get("longitude")
conf_idx = header_map.get("confidence") conf_idx = header_map.get("confidence")
frp_idx = header_map.get("frp") # Fire Radiative Power frp_idx = header_map.get("frp") # Fire Radiative Power
acq_date_idx = header_map.get("acq_date") acq_date_idx = header_map.get("acq_date")
acq_time_idx = header_map.get("acq_time") acq_time_idx = header_map.get("acq_time")
bright_idx = header_map.get("bright_ti4") or header_map.get("brightness") bright_idx = header_map.get("bright_ti4") or header_map.get("brightness")
if lat_idx is None or lon_idx is None: if lat_idx is None or lon_idx is None:
logger.warning("FIRMS CSV missing required columns") logger.warning("FIRMS CSV missing required columns")
return [] return []
events = [] events = []
now = time.time() now = time.time()
# Confidence mapping # Confidence mapping
conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3} conf_values = {"low": 1, "l": 1, "nominal": 2, "n": 2, "high": 3, "h": 3}
min_conf = conf_values.get(self._confidence_min.lower(), 2) min_conf = conf_values.get(self._confidence_min.lower(), 2)
# Get known fire locations for cross-referencing # Get known fire locations for cross-referencing
known_fires = self._get_known_fires() known_fires = self._get_known_fires()
for line in lines[1:]: for line in lines[1:]:
cols = line.split(",") cols = line.split(",")
if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1: if len(cols) < max(filter(None, [lat_idx, lon_idx, conf_idx])) + 1:
continue continue
try: try:
lat = float(cols[lat_idx]) lat = float(cols[lat_idx])
lon = float(cols[lon_idx]) lon = float(cols[lon_idx])
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
# Parse confidence # Parse confidence
conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n" conf_raw = cols[conf_idx].strip() if conf_idx is not None and conf_idx < len(cols) else "n"
conf_value = conf_values.get(conf_raw.lower(), 2) conf_value = conf_values.get(conf_raw.lower(), 2)
# Filter by confidence # Filter by confidence
if conf_value < min_conf: if conf_value < min_conf:
continue continue
# Parse FRP (fire radiative power in MW) # Parse FRP (fire radiative power in MW)
frp = None frp = None
if frp_idx is not None and frp_idx < len(cols): if frp_idx is not None and frp_idx < len(cols):
try: try:
frp = float(cols[frp_idx]) frp = float(cols[frp_idx])
except ValueError: except ValueError:
pass pass
# Parse brightness temperature # Parse brightness temperature
brightness = None brightness = None
if bright_idx is not None and bright_idx < len(cols): if bright_idx is not None and bright_idx < len(cols):
try: try:
brightness = float(cols[bright_idx]) brightness = float(cols[bright_idx])
except ValueError: except ValueError:
pass pass
# Parse acquisition datetime # Parse acquisition datetime
acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else "" acq_date = cols[acq_date_idx].strip() if acq_date_idx is not None and acq_date_idx < len(cols) else ""
acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else "" acq_time = cols[acq_time_idx].strip() if acq_time_idx is not None and acq_time_idx < len(cols) else ""
# Create unique ID from position and time # Create unique ID from position and time
event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}" event_id = f"firms_{lat:.4f}_{lon:.4f}_{acq_date}_{acq_time}"
# Check if near known fire # Check if near known fire
near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires) near_fire, fire_name, distance_to_fire = self._check_near_known_fire(lat, lon, known_fires)
# Determine severity # Determine severity
if not near_fire: if not near_fire:
# Potential new ignition # Potential new ignition
severity = "watch" severity = "routine"
new_ignition = True new_ignition = True
headline = f"NEW HOTSPOT detected" headline = f"NEW HOTSPOT detected"
else: else:
# Near known fire # Near known fire
severity = "advisory" severity = "routine"
new_ignition = False new_ignition = False
headline = f"Hotspot near {fire_name}" headline = f"Hotspot near {fire_name}"
# Bump severity for high FRP # Bump severity for high FRP
if frp is not None and frp > 100: if frp is not None and frp > 100:
if severity == "advisory": if severity == "routine":
severity = "watch" severity = "routine"
elif severity == "watch": elif severity == "routine":
severity = "warning" severity = "priority"
headline += f" ({int(frp)} MW)" headline += f" ({int(frp)} MW)"
# Compute proximity to region anchors # Compute proximity to region anchors
distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon) distance_km, nearest_anchor = self._nearest_anchor_distance(lat, lon)
if distance_km is not None and nearest_anchor: if distance_km is not None and nearest_anchor:
headline += f" ({int(distance_km)} km from {nearest_anchor})" headline += f" ({int(distance_km)} km from {nearest_anchor})"
event = { event = {
"source": "firms", "source": "firms",
"event_id": event_id, "event_id": event_id,
"event_type": "Fire Hotspot", "event_type": "Fire Hotspot",
"severity": severity, "severity": severity,
"headline": headline, "headline": headline,
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"expires": now + 21600, # 6 hour TTL "expires": now + 21600, # 6 hour TTL
"fetched_at": now, "fetched_at": now,
"properties": { "properties": {
"new_ignition": new_ignition, "new_ignition": new_ignition,
"confidence": conf_raw, "confidence": conf_raw,
"frp": frp, "frp": frp,
"brightness": brightness, "brightness": brightness,
"acq_date": acq_date, "acq_date": acq_date,
"acq_time": acq_time, "acq_time": acq_time,
"near_fire": fire_name if near_fire else None, "near_fire": fire_name if near_fire else None,
"distance_to_fire_km": distance_to_fire, "distance_to_fire_km": distance_to_fire,
"distance_km": distance_km, "distance_km": distance_km,
"nearest_anchor": nearest_anchor, "nearest_anchor": nearest_anchor,
}, },
} }
events.append(event) events.append(event)
return events return events
def _get_known_fires(self) -> list: def _get_known_fires(self) -> list:
"""Get known fire locations from NIFC adapter.""" """Get known fire locations from NIFC adapter."""
if not self._fires_adapter: if not self._fires_adapter:
return [] return []
fires = self._fires_adapter.get_events() fires = self._fires_adapter.get_events()
return [ return [
{ {
"name": f.get("name", "Unknown"), "name": f.get("name", "Unknown"),
"lat": f.get("lat"), "lat": f.get("lat"),
"lon": f.get("lon"), "lon": f.get("lon"),
} }
for f in fires for f in fires
if f.get("lat") is not None and f.get("lon") is not None if f.get("lat") is not None and f.get("lon") is not None
] ]
def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple: def _check_near_known_fire(self, lat: float, lon: float, known_fires: list) -> tuple:
"""Check if hotspot is near a known fire. """Check if hotspot is near a known fire.
Returns: Returns:
(is_near, fire_name, distance_km) (is_near, fire_name, distance_km)
""" """
if not known_fires: if not known_fires:
return (False, None, None) return (False, None, None)
from ..geo import haversine_distance from ..geo import haversine_distance
for fire in known_fires: for fire in known_fires:
fire_lat = fire.get("lat") fire_lat = fire.get("lat")
fire_lon = fire.get("lon") fire_lon = fire.get("lon")
if fire_lat is None or fire_lon is None: if fire_lat is None or fire_lon is None:
continue continue
# haversine_distance returns miles, convert to km # haversine_distance returns miles, convert to km
dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon) dist_miles = haversine_distance(lat, lon, fire_lat, fire_lon)
dist_km = dist_miles * 1.60934 dist_km = dist_miles * 1.60934
if dist_km <= self._proximity_km: if dist_km <= self._proximity_km:
return (True, fire.get("name"), dist_km) return (True, fire.get("name"), dist_km)
return (False, None, None) return (False, None, None)
def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple: def _nearest_anchor_distance(self, lat: float, lon: float) -> tuple:
"""Find distance to nearest region anchor. """Find distance to nearest region anchor.
Returns: Returns:
(distance_km, anchor_name) or (None, None) (distance_km, anchor_name) or (None, None)
""" """
if not self._region_anchors: if not self._region_anchors:
return (None, None) return (None, None)
from ..geo import haversine_distance from ..geo import haversine_distance
min_dist = float("inf") min_dist = float("inf")
nearest_name = None nearest_name = None
for anchor in self._region_anchors: for anchor in self._region_anchors:
anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None) anchor_lat = anchor.get("lat") if isinstance(anchor, dict) else getattr(anchor, "lat", None)
anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None) anchor_lon = anchor.get("lon") if isinstance(anchor, dict) else getattr(anchor, "lon", None)
anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown") anchor_name = anchor.get("name") if isinstance(anchor, dict) else getattr(anchor, "name", "Unknown")
if anchor_lat is None or anchor_lon is None: if anchor_lat is None or anchor_lon is None:
continue continue
# haversine_distance returns miles, convert to km # haversine_distance returns miles, convert to km
dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon) dist_miles = haversine_distance(lat, lon, anchor_lat, anchor_lon)
dist_km = dist_miles * 1.60934 dist_km = dist_miles * 1.60934
if dist_km < min_dist: if dist_km < min_dist:
min_dist = dist_km min_dist = dist_km
nearest_name = anchor_name nearest_name = anchor_name
if min_dist < float("inf"): if min_dist < float("inf"):
return (min_dist, nearest_name) return (min_dist, nearest_name)
return (None, None) return (None, None)
def get_events(self) -> list: def get_events(self) -> list:
"""Get current hotspot events.""" """Get current hotspot events."""
return self._events return self._events
def get_new_ignitions(self) -> list: def get_new_ignitions(self) -> list:
"""Get only potential new ignitions (not near known fires).""" """Get only potential new ignitions (not near known fires)."""
return [e for e in self._events if e.get("properties", {}).get("new_ignition")] return [e for e in self._events if e.get("properties", {}).get("new_ignition")]
@property @property
def health_status(self) -> dict: def health_status(self) -> dict:
"""Get adapter health status.""" """Get adapter health status."""
new_ignitions = len(self.get_new_ignitions()) new_ignitions = len(self.get_new_ignitions())
return { return {
"source": "firms", "source": "firms",
"is_loaded": self._is_loaded, "is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None, "last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors, "consecutive_errors": self._consecutive_errors,
"event_count": len(self._events), "event_count": len(self._events),
"new_ignitions": new_ignitions, "new_ignitions": new_ignitions,
"last_fetch": self._last_tick, "last_fetch": self._last_tick,
} }

396
meshai/env/nws.py vendored
View file

@ -1,193 +1,203 @@
"""NWS Active Alerts adapter.""" """NWS Active Alerts adapter."""
import json import json
import logging import logging
import time import time
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import NWSConfig from ..config import NWSConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NWSAlertsAdapter: class NWSAlertsAdapter:
"""NWS Active Alerts -- polls api.weather.gov""" """NWS Active Alerts -- polls api.weather.gov"""
def __init__(self, config: "NWSConfig"): def __init__(self, config: "NWSConfig"):
self._areas = config.areas or ["ID"] self._areas = config.areas or ["ID"]
self._user_agent = config.user_agent or "(meshai, ops@example.com)" self._user_agent = config.user_agent or "(meshai, ops@example.com)"
self._severity_min = config.severity_min or "moderate" self._severity_min = config.severity_min or "moderate"
self._tick_interval = config.tick_seconds or 60 self._tick_interval = config.tick_seconds or 60
self._last_tick = 0.0 self._last_tick = 0.0
self._events = [] self._events = []
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._backoff_until = 0.0 self._backoff_until = 0.0
self._is_loaded = False self._is_loaded = False
def tick(self) -> bool:
"""Execute one polling tick. def _map_nws_severity(self, nws_severity: str) -> str:
"""Map NWS severity to 3-level system."""
Returns: if nws_severity == "extreme":
True if data changed return "immediate"
""" elif nws_severity in ("severe", "warning"):
now = time.time() return "priority"
else: # moderate, minor, unknown
# Rate limit backoff return "routine"
if now < self._backoff_until:
return False def tick(self) -> bool:
"""Execute one polling tick.
# Check tick interval
if now - self._last_tick < self._tick_interval: Returns:
return False True if data changed
"""
self._last_tick = now now = time.time()
return self._fetch()
# Rate limit backoff
def _fetch(self) -> bool: if now < self._backoff_until:
"""Fetch alerts from NWS API. return False
Returns: # Check tick interval
True if data changed if now - self._last_tick < self._tick_interval:
""" return False
areas = ",".join(self._areas)
url = f"https://api.weather.gov/alerts/active?area={areas}" self._last_tick = now
return self._fetch()
headers = {
"User-Agent": self._user_agent, def _fetch(self) -> bool:
"Accept": "application/geo+json", """Fetch alerts from NWS API.
}
Returns:
try: True if data changed
req = Request(url, headers=headers) """
with urlopen(req, timeout=15) as resp: areas = ",".join(self._areas)
data = json.loads(resp.read().decode("utf-8")) url = f"https://api.weather.gov/alerts/active?area={areas}"
except HTTPError as e: headers = {
if e.code == 429: "User-Agent": self._user_agent,
self._backoff_until = time.time() + 5 "Accept": "application/geo+json",
logger.warning("NWS rate limited, backing off 5s") }
else:
logger.warning(f"NWS HTTP error: {e.code}") try:
self._last_error = f"HTTP {e.code}" req = Request(url, headers=headers)
self._consecutive_errors += 1 with urlopen(req, timeout=15) as resp:
return False data = json.loads(resp.read().decode("utf-8"))
except URLError as e: except HTTPError as e:
logger.warning(f"NWS connection error: {e.reason}") if e.code == 429:
self._last_error = str(e.reason) self._backoff_until = time.time() + 5
self._consecutive_errors += 1 logger.warning("NWS rate limited, backing off 5s")
return False else:
logger.warning(f"NWS HTTP error: {e.code}")
except Exception as e: self._last_error = f"HTTP {e.code}"
logger.warning(f"NWS fetch error: {e}") self._consecutive_errors += 1
self._last_error = str(e) return False
self._consecutive_errors += 1
return False except URLError as e:
logger.warning(f"NWS connection error: {e.reason}")
# Parse response self._last_error = str(e.reason)
features = data.get("features", []) self._consecutive_errors += 1
new_events = [] return False
# Severity levels for filtering except Exception as e:
severity_levels = ["unknown", "minor", "moderate", "severe", "extreme"] logger.warning(f"NWS fetch error: {e}")
try: self._last_error = str(e)
min_idx = severity_levels.index(self._severity_min.lower()) self._consecutive_errors += 1
except ValueError: return False
min_idx = 2 # default to moderate
# Parse response
for feature in features: features = data.get("features", [])
props = feature.get("properties", {}) new_events = []
# Severity filtering # Severity levels for filtering
severity = (props.get("severity") or "Unknown").lower() severity_levels = ["unknown", "minor", "moderate", "severe", "extreme"]
try: try:
sev_idx = severity_levels.index(severity) min_idx = severity_levels.index(self._severity_min.lower())
except ValueError: except ValueError:
sev_idx = 0 min_idx = 2 # default to moderate
if sev_idx < min_idx: for feature in features:
continue props = feature.get("properties", {})
# Parse timestamps # Severity filtering
onset = self._parse_iso(props.get("onset")) severity = (props.get("severity") or "Unknown").lower()
expires = self._parse_iso(props.get("expires")) try:
sev_idx = severity_levels.index(severity)
event = { except ValueError:
"source": "nws", sev_idx = 0
"event_id": props.get("id", ""),
"event_type": props.get("event", "Unknown"), if sev_idx < min_idx:
"severity": severity, continue
"headline": props.get("headline", ""),
"description": (props.get("description") or "")[:500], # Parse timestamps
"onset": onset, onset = self._parse_iso(props.get("onset"))
"expires": expires, expires = self._parse_iso(props.get("expires"))
"areas": props.get("geocode", {}).get("UGC", []),
"area_desc": props.get("areaDesc", ""), event = {
"fetched_at": time.time(), "source": "nws",
} "event_id": props.get("id", ""),
"event_type": props.get("event", "Unknown"),
# Try to get centroid from geometry "severity": severity,
geom = feature.get("geometry") "headline": props.get("headline", ""),
if geom and geom.get("coordinates"): "description": (props.get("description") or "")[:500],
try: "onset": onset,
coords = geom["coordinates"] "expires": expires,
if geom.get("type") == "Polygon" and coords: "areas": props.get("geocode", {}).get("UGC", []),
# Compute centroid of first ring "area_desc": props.get("areaDesc", ""),
ring = coords[0] "fetched_at": time.time(),
lat_sum = sum(c[1] for c in ring) }
lon_sum = sum(c[0] for c in ring)
event["lat"] = lat_sum / len(ring) # Try to get centroid from geometry
event["lon"] = lon_sum / len(ring) geom = feature.get("geometry")
except Exception: if geom and geom.get("coordinates"):
pass try:
coords = geom["coordinates"]
new_events.append(event) if geom.get("type") == "Polygon" and coords:
# Compute centroid of first ring
# Check if data changed ring = coords[0]
old_ids = {e["event_id"] for e in self._events} lat_sum = sum(c[1] for c in ring)
new_ids = {e["event_id"] for e in new_events} lon_sum = sum(c[0] for c in ring)
changed = old_ids != new_ids event["lat"] = lat_sum / len(ring)
event["lon"] = lon_sum / len(ring)
self._events = new_events except Exception:
self._consecutive_errors = 0 pass
self._last_error = None
self._is_loaded = True new_events.append(event)
if changed: # Check if data changed
logger.info(f"NWS alerts updated: {len(new_events)} active") old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events}
return changed changed = old_ids != new_ids
def _parse_iso(self, iso_str: str) -> float: self._events = new_events
"""Parse ISO timestamp to epoch float.""" self._consecutive_errors = 0
if not iso_str: self._last_error = None
return 0.0 self._is_loaded = True
try:
# Handle various ISO formats if changed:
if iso_str.endswith("Z"): logger.info(f"NWS alerts updated: {len(new_events)} active")
iso_str = iso_str[:-1] + "+00:00"
dt = datetime.fromisoformat(iso_str) return changed
return dt.timestamp()
except Exception: def _parse_iso(self, iso_str: str) -> float:
return 0.0 """Parse ISO timestamp to epoch float."""
if not iso_str:
def get_events(self) -> list: return 0.0
"""Get current events.""" try:
return self._events # Handle various ISO formats
if iso_str.endswith("Z"):
@property iso_str = iso_str[:-1] + "+00:00"
def health_status(self) -> dict: dt = datetime.fromisoformat(iso_str)
"""Get adapter health status.""" return dt.timestamp()
return { except Exception:
"source": "nws", return 0.0
"is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None, def get_events(self) -> list:
"consecutive_errors": self._consecutive_errors, """Get current events."""
"event_count": len(self._events), return self._events
"last_fetch": self._last_tick,
} @property
def health_status(self) -> dict:
"""Get adapter health status."""
return {
"source": "nws",
"is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors,
"event_count": len(self._events),
"last_fetch": self._last_tick,
}

732
meshai/env/roads511.py vendored
View file

@ -1,366 +1,366 @@
"""511 Road Conditions adapter. """511 Road Conditions adapter.
Polls a configurable 511 API for road events. The base URL is fully Polls a configurable 511 API for road events. The base URL is fully
configurable as each state has a different 511 system. configurable as each state has a different 511 system.
""" """
import json import json
import logging import logging
import os import os
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.parse import urljoin from urllib.parse import urljoin
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import Roads511Config from ..config import Roads511Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Roads511Adapter: class Roads511Adapter:
"""511 road conditions polling adapter.""" """511 road conditions polling adapter."""
def __init__(self, config: "Roads511Config"): def __init__(self, config: "Roads511Config"):
self._api_key = self._resolve_env(config.api_key or "") self._api_key = self._resolve_env(config.api_key or "")
self._base_url = (config.base_url or "").rstrip("/") self._base_url = (config.base_url or "").rstrip("/")
self._endpoints = config.endpoints or ["/get/event"] self._endpoints = config.endpoints or ["/get/event"]
self._bbox = config.bbox or [] # [west, south, east, north] self._bbox = config.bbox or [] # [west, south, east, north]
self._tick_interval = config.tick_seconds or 300 self._tick_interval = config.tick_seconds or 300
self._last_tick = 0.0 self._last_tick = 0.0
self._events = [] self._events = []
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = False self._is_loaded = False
self._auth_failed = False # Stop retrying on auth failures self._auth_failed = False # Stop retrying on auth failures
if not self._base_url: if not self._base_url:
logger.info("511: No base URL configured, adapter disabled") logger.info("511: No base URL configured, adapter disabled")
def _resolve_env(self, value: str) -> str: def _resolve_env(self, value: str) -> str:
"""Resolve ${ENV_VAR} references in value.""" """Resolve ${ENV_VAR} references in value."""
if value and value.startswith("${") and value.endswith("}"): if value and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1] env_var = value[2:-1]
return os.environ.get(env_var, "") return os.environ.get(env_var, "")
return value return value
def tick(self) -> bool: def tick(self) -> bool:
"""Execute one polling tick. """Execute one polling tick.
Returns: Returns:
True if data changed True if data changed
""" """
now = time.time() now = time.time()
# No base URL configured # No base URL configured
if not self._base_url: if not self._base_url:
return False return False
# Auth failed - don't keep retrying # Auth failed - don't keep retrying
if self._auth_failed: if self._auth_failed:
return False return False
# Check tick interval # Check tick interval
if now - self._last_tick < self._tick_interval: if now - self._last_tick < self._tick_interval:
return False return False
self._last_tick = now self._last_tick = now
return self._fetch_all() return self._fetch_all()
def _fetch_all(self) -> bool: def _fetch_all(self) -> bool:
"""Fetch events from all configured endpoints. """Fetch events from all configured endpoints.
Returns: Returns:
True if data changed True if data changed
""" """
new_events = [] new_events = []
now = time.time() now = time.time()
for endpoint in self._endpoints: for endpoint in self._endpoints:
events = self._fetch_endpoint(endpoint, now) events = self._fetch_endpoint(endpoint, now)
if events: if events:
new_events.extend(events) new_events.extend(events)
# Apply bbox filter if configured # Apply bbox filter if configured
if self._bbox and len(self._bbox) == 4: if self._bbox and len(self._bbox) == 4:
west, south, east, north = self._bbox west, south, east, north = self._bbox
new_events = [ new_events = [
e for e in new_events e for e in new_events
if e.get("lat") is not None and e.get("lon") is not None if e.get("lat") is not None and e.get("lon") is not None
and west <= e["lon"] <= east and south <= e["lat"] <= north and west <= e["lon"] <= east and south <= e["lat"] <= north
] ]
# Check if data changed # Check if data changed
old_ids = {e["event_id"] for e in self._events} old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events} new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids changed = old_ids != new_ids
self._events = new_events self._events = new_events
self._is_loaded = True self._is_loaded = True
if changed: if changed:
logger.info(f"511 road events updated: {len(new_events)} active") logger.info(f"511 road events updated: {len(new_events)} active")
return changed return changed
def _fetch_endpoint(self, endpoint: str, now: float) -> list: def _fetch_endpoint(self, endpoint: str, now: float) -> list:
"""Fetch events from a single endpoint. """Fetch events from a single endpoint.
Args: Args:
endpoint: API endpoint path endpoint: API endpoint path
now: Current timestamp now: Current timestamp
Returns: Returns:
List of event dicts List of event dicts
""" """
url = urljoin(self._base_url + "/", endpoint.lstrip("/")) url = urljoin(self._base_url + "/", endpoint.lstrip("/"))
# Add API key if configured # Add API key if configured
if self._api_key: if self._api_key:
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
url = f"{url}{sep}key={self._api_key}" url = f"{url}{sep}key={self._api_key}"
headers = { headers = {
"User-Agent": "MeshAI/1.0", "User-Agent": "MeshAI/1.0",
"Accept": "application/json", "Accept": "application/json",
} }
try: try:
req = Request(url, headers=headers) req = Request(url, headers=headers)
with urlopen(req, timeout=30) as resp: with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e: except HTTPError as e:
if e.code == 401 or e.code == 403: if e.code == 401 or e.code == 403:
logger.error( logger.error(
f"511 auth error: {e.code} - check API key configuration for {self._base_url}" f"511 auth error: {e.code} - check API key configuration for {self._base_url}"
) )
self._last_error = f"Auth error {e.code} - check API key" self._last_error = f"Auth error {e.code} - check API key"
self._auth_failed = True self._auth_failed = True
return [] return []
else: else:
logger.warning(f"511 HTTP error for {endpoint}: {e.code}") logger.warning(f"511 HTTP error for {endpoint}: {e.code}")
self._last_error = f"HTTP {e.code}" self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return [] return []
except URLError as e: except URLError as e:
logger.warning(f"511 connection error for {endpoint}: {e.reason}") logger.warning(f"511 connection error for {endpoint}: {e.reason}")
self._last_error = str(e.reason) self._last_error = str(e.reason)
self._consecutive_errors += 1 self._consecutive_errors += 1
return [] return []
except Exception as e: except Exception as e:
logger.warning(f"511 fetch error for {endpoint}: {e}") logger.warning(f"511 fetch error for {endpoint}: {e}")
self._last_error = str(e) self._last_error = str(e)
self._consecutive_errors += 1 self._consecutive_errors += 1
return [] return []
# Parse response - handle various 511 API formats # Parse response - handle various 511 API formats
return self._parse_response(data, now) return self._parse_response(data, now)
def _parse_response(self, data, now: float) -> list: def _parse_response(self, data, now: float) -> list:
"""Parse 511 API response. """Parse 511 API response.
Different states use different formats. Try common patterns. Different states use different formats. Try common patterns.
Args: Args:
data: JSON response data data: JSON response data
now: Current timestamp now: Current timestamp
Returns: Returns:
List of event dicts List of event dicts
""" """
events = [] events = []
# Handle array response # Handle array response
if isinstance(data, list): if isinstance(data, list):
items = data items = data
# Handle wrapped response # Handle wrapped response
elif isinstance(data, dict): elif isinstance(data, dict):
# Try common wrapper keys # Try common wrapper keys
items = ( items = (
data.get("events") or data.get("events") or
data.get("items") or data.get("items") or
data.get("data") or data.get("data") or
data.get("results") or data.get("results") or
[] []
) )
if not isinstance(items, list): if not isinstance(items, list):
items = [data] if self._looks_like_event(data) else [] items = [data] if self._looks_like_event(data) else []
else: else:
return [] return []
for item in items: for item in items:
event = self._parse_event(item, now) event = self._parse_event(item, now)
if event: if event:
events.append(event) events.append(event)
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
return events return events
def _looks_like_event(self, item: dict) -> bool: def _looks_like_event(self, item: dict) -> bool:
"""Check if dict looks like a 511 event.""" """Check if dict looks like a 511 event."""
return bool( return bool(
item.get("id") or item.get("EventId") or item.get("event_id") item.get("id") or item.get("EventId") or item.get("event_id")
) )
def _parse_event(self, item: dict, now: float) -> dict: def _parse_event(self, item: dict, now: float) -> dict:
"""Parse a single 511 event. """Parse a single 511 event.
Args: Args:
item: Event dict from API item: Event dict from API
now: Current timestamp now: Current timestamp
Returns: Returns:
Normalized event dict or None Normalized event dict or None
""" """
try: try:
# Try various ID field names # Try various ID field names
event_id = ( event_id = (
item.get("id") or item.get("id") or
item.get("EventId") or item.get("EventId") or
item.get("event_id") or item.get("event_id") or
item.get("ID") or item.get("ID") or
str(hash(str(item)))[:12] str(hash(str(item)))[:12]
) )
# Try various type field names # Try various type field names
event_type = ( event_type = (
item.get("EventType") or item.get("EventType") or
item.get("event_type") or item.get("event_type") or
item.get("type") or item.get("type") or
item.get("Type") or item.get("Type") or
item.get("category") or item.get("category") or
"Road Event" "Road Event"
) )
# Try various road name fields # Try various road name fields
roadway = ( roadway = (
item.get("RoadwayName") or item.get("RoadwayName") or
item.get("roadway_name") or item.get("roadway_name") or
item.get("roadway") or item.get("roadway") or
item.get("Roadway") or item.get("Roadway") or
item.get("road") or item.get("road") or
item.get("route") or item.get("route") or
"" ""
) )
# Try various description fields # Try various description fields
description = ( description = (
item.get("Description") or item.get("Description") or
item.get("description") or item.get("description") or
item.get("message") or item.get("message") or
item.get("Message") or item.get("Message") or
item.get("details") or item.get("details") or
"" ""
) )
# Try various location fields # Try various location fields
lat = ( lat = (
item.get("Latitude") or item.get("Latitude") or
item.get("latitude") or item.get("latitude") or
item.get("lat") or item.get("lat") or
item.get("StartLatitude") or item.get("StartLatitude") or
None None
) )
lon = ( lon = (
item.get("Longitude") or item.get("Longitude") or
item.get("longitude") or item.get("longitude") or
item.get("lon") or item.get("lon") or
item.get("lng") or item.get("lng") or
item.get("StartLongitude") or item.get("StartLongitude") or
None None
) )
# Try to get coordinates from nested location object # Try to get coordinates from nested location object
if lat is None and "location" in item: if lat is None and "location" in item:
loc = item["location"] loc = item["location"]
if isinstance(loc, dict): if isinstance(loc, dict):
lat = loc.get("latitude") or loc.get("lat") lat = loc.get("latitude") or loc.get("lat")
lon = loc.get("longitude") or loc.get("lon") or loc.get("lng") lon = loc.get("longitude") or loc.get("lon") or loc.get("lng")
# Check closure status # Check closure status
is_closure = ( is_closure = (
item.get("IsFullClosure") or item.get("IsFullClosure") or
item.get("is_full_closure") or item.get("is_full_closure") or
item.get("fullClosure") or item.get("fullClosure") or
item.get("closed") or item.get("closed") or
"closure" in str(event_type).lower() or "closure" in str(event_type).lower() or
"closed" in str(description).lower() "closed" in str(description).lower()
) )
# Determine severity # Determine severity
if is_closure: if is_closure:
severity = "warning" severity = "priority"
elif "construction" in str(event_type).lower(): elif "construction" in str(event_type).lower():
severity = "advisory" severity = "routine"
elif "incident" in str(event_type).lower(): elif "incident" in str(event_type).lower():
severity = "advisory" severity = "routine"
else: else:
severity = "info" severity = "routine"
# Format headline # Format headline
if roadway and description: if roadway and description:
headline = f"{roadway}: {description[:100]}" headline = f"{roadway}: {description[:100]}"
elif roadway: elif roadway:
headline = f"{roadway}: {event_type}" headline = f"{roadway}: {event_type}"
elif description: elif description:
headline = description[:120] headline = description[:120]
else: else:
headline = f"{event_type}" headline = f"{event_type}"
# Try to get timestamp for expiry # Try to get timestamp for expiry
last_updated = ( last_updated = (
item.get("LastUpdated") or item.get("LastUpdated") or
item.get("last_updated") or item.get("last_updated") or
item.get("updated") or item.get("updated") or
item.get("timestamp") or item.get("timestamp") or
None None
) )
# Default 6 hour TTL, refreshed every tick # Default 6 hour TTL, refreshed every tick
expires = now + 21600 expires = now + 21600
event = { event = {
"source": "511", "source": "511",
"event_id": f"511_{event_id}", "event_id": f"511_{event_id}",
"event_type": event_type, "event_type": event_type,
"headline": headline, "headline": headline,
"description": description[:500] if description else "", "description": description[:500] if description else "",
"severity": severity, "severity": severity,
"lat": float(lat) if lat is not None else None, "lat": float(lat) if lat is not None else None,
"lon": float(lon) if lon is not None else None, "lon": float(lon) if lon is not None else None,
"expires": expires, "expires": expires,
"fetched_at": now, "fetched_at": now,
"properties": { "properties": {
"roadway": roadway, "roadway": roadway,
"is_closure": bool(is_closure), "is_closure": bool(is_closure),
"last_updated": last_updated, "last_updated": last_updated,
}, },
} }
return event return event
except Exception as e: except Exception as e:
logger.debug(f"511 event parse error: {e} - item: {item}") logger.debug(f"511 event parse error: {e} - item: {item}")
return None return None
def get_events(self) -> list: def get_events(self) -> list:
"""Get current road events.""" """Get current road events."""
return self._events return self._events
@property @property
def health_status(self) -> dict: def health_status(self) -> dict:
"""Get adapter health status.""" """Get adapter health status."""
return { return {
"source": "511", "source": "511",
"is_loaded": self._is_loaded, "is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None, "last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors, "consecutive_errors": self._consecutive_errors,
"event_count": len(self._events), "event_count": len(self._events),
"last_fetch": self._last_tick, "last_fetch": self._last_tick,
"auth_failed": self._auth_failed, "auth_failed": self._auth_failed,
} }

544
meshai/env/swpc.py vendored
View file

@ -1,272 +1,272 @@
"""NOAA Space Weather Prediction Center adapter.""" """NOAA Space Weather Prediction Center adapter."""
import json import json
import logging import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import SWPCConfig from ..config import SWPCConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SWPCAdapter: class SWPCAdapter:
"""NOAA Space Weather -- multi-endpoint with staggered ticks.""" """NOAA Space Weather -- multi-endpoint with staggered ticks."""
# Endpoint definitions: (url, interval_seconds) # Endpoint definitions: (url, interval_seconds)
ENDPOINTS = { ENDPOINTS = {
"scales": ("https://services.swpc.noaa.gov/products/noaa-scales.json", 300), "scales": ("https://services.swpc.noaa.gov/products/noaa-scales.json", 300),
"kp": ("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json", 600), "kp": ("https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json", 600),
"alerts": ("https://services.swpc.noaa.gov/products/alerts.json", 120), "alerts": ("https://services.swpc.noaa.gov/products/alerts.json", 120),
"f107": ("https://services.swpc.noaa.gov/json/f107_cm_flux.json", 86400), "f107": ("https://services.swpc.noaa.gov/json/f107_cm_flux.json", 86400),
} }
def __init__(self, config: "SWPCConfig"): def __init__(self, config: "SWPCConfig"):
self._last_tick = {} # endpoint -> last_tick timestamp self._last_tick = {} # endpoint -> last_tick timestamp
self._status = {} self._status = {}
self._events = [] self._events = []
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = False self._is_loaded = False
# Initialize tick times to 0 # Initialize tick times to 0
for endpoint in self.ENDPOINTS: for endpoint in self.ENDPOINTS:
self._last_tick[endpoint] = 0.0 self._last_tick[endpoint] = 0.0
def tick(self) -> bool: def tick(self) -> bool:
"""Execute one polling tick. """Execute one polling tick.
Returns: Returns:
True if data changed True if data changed
""" """
changed = False changed = False
now = time.time() now = time.time()
for endpoint, (url, interval) in self.ENDPOINTS.items(): for endpoint, (url, interval) in self.ENDPOINTS.items():
if now - self._last_tick[endpoint] >= interval: if now - self._last_tick[endpoint] >= interval:
self._last_tick[endpoint] = now self._last_tick[endpoint] = now
if self._fetch_endpoint(endpoint, url): if self._fetch_endpoint(endpoint, url):
changed = True changed = True
if changed: if changed:
self._update_events() self._update_events()
return changed return changed
def _fetch_endpoint(self, endpoint: str, url: str) -> bool: def _fetch_endpoint(self, endpoint: str, url: str) -> bool:
"""Fetch a single endpoint. """Fetch a single endpoint.
Returns: Returns:
True on success True on success
""" """
headers = { headers = {
"User-Agent": "MeshAI/1.0", "User-Agent": "MeshAI/1.0",
"Accept": "application/json", "Accept": "application/json",
} }
try: try:
req = Request(url, headers=headers) req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp: with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e: except HTTPError as e:
logger.warning(f"SWPC {endpoint} HTTP error: {e.code}") logger.warning(f"SWPC {endpoint} HTTP error: {e.code}")
self._last_error = f"{endpoint}: HTTP {e.code}" self._last_error = f"{endpoint}: HTTP {e.code}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
except URLError as e: except URLError as e:
logger.warning(f"SWPC {endpoint} connection error: {e.reason}") logger.warning(f"SWPC {endpoint} connection error: {e.reason}")
self._last_error = f"{endpoint}: {e.reason}" self._last_error = f"{endpoint}: {e.reason}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
except Exception as e: except Exception as e:
logger.warning(f"SWPC {endpoint} error: {e}") logger.warning(f"SWPC {endpoint} error: {e}")
self._last_error = f"{endpoint}: {e}" self._last_error = f"{endpoint}: {e}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
# Parse based on endpoint # Parse based on endpoint
try: try:
if endpoint == "scales": if endpoint == "scales":
self._parse_scales(data) self._parse_scales(data)
elif endpoint == "kp": elif endpoint == "kp":
self._parse_kp(data) self._parse_kp(data)
elif endpoint == "alerts": elif endpoint == "alerts":
self._parse_alerts(data) self._parse_alerts(data)
elif endpoint == "f107": elif endpoint == "f107":
self._parse_f107(data) self._parse_f107(data)
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = True self._is_loaded = True
return True return True
except Exception as e: except Exception as e:
logger.warning(f"SWPC {endpoint} parse error: {e}") logger.warning(f"SWPC {endpoint} parse error: {e}")
self._last_error = f"{endpoint}: parse error" self._last_error = f"{endpoint}: parse error"
return False return False
def _parse_scales(self, data): def _parse_scales(self, data):
"""Parse noaa-scales.json. """Parse noaa-scales.json.
Data format: {""-1": {...}, "0": {...}, "1": {...}, ...} Data format: {""-1": {...}, "0": {...}, "1": {...}, ...}
"0" is current. "0" is current.
""" """
current = data.get("0", {}) current = data.get("0", {})
r_data = current.get("R", {}) r_data = current.get("R", {})
s_data = current.get("S", {}) s_data = current.get("S", {})
g_data = current.get("G", {}) g_data = current.get("G", {})
# Handle empty string or None Scale values # Handle empty string or None Scale values
def parse_scale(val): def parse_scale(val):
if val is None or val == "": if val is None or val == "":
return 0 return 0
try: try:
return int(val) return int(val)
except (ValueError, TypeError): except (ValueError, TypeError):
return 0 return 0
self._status["r_scale"] = parse_scale(r_data.get("Scale")) self._status["r_scale"] = parse_scale(r_data.get("Scale"))
self._status["s_scale"] = parse_scale(s_data.get("Scale")) self._status["s_scale"] = parse_scale(s_data.get("Scale"))
self._status["g_scale"] = parse_scale(g_data.get("Scale")) self._status["g_scale"] = parse_scale(g_data.get("Scale"))
def _parse_kp(self, data): def _parse_kp(self, data):
"""Parse noaa-planetary-k-index.json. """Parse noaa-planetary-k-index.json.
Data format: array of objects with time_tag, Kp, a_running, station_count Data format: array of objects with time_tag, Kp, a_running, station_count
Last entry is most recent. Store full history for charting. Last entry is most recent. Store full history for charting.
""" """
if not data: if not data:
return return
# Store full history (last 24-48 hours of readings) # Store full history (last 24-48 hours of readings)
kp_history = [] kp_history = []
for entry in data: for entry in data:
if isinstance(entry, dict): if isinstance(entry, dict):
try: try:
kp_history.append({ kp_history.append({
"time": entry.get("time_tag", ""), "time": entry.get("time_tag", ""),
"value": float(entry.get("Kp", 0)), "value": float(entry.get("Kp", 0)),
}) })
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
elif isinstance(entry, list) and len(entry) > 1: elif isinstance(entry, list) and len(entry) > 1:
# Legacy array format fallback # Legacy array format fallback
try: try:
kp_history.append({ kp_history.append({
"time": entry[0] if len(entry) > 0 else "", "time": entry[0] if len(entry) > 0 else "",
"value": float(entry[1]), "value": float(entry[1]),
}) })
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
self._status["kp_history"] = kp_history self._status["kp_history"] = kp_history
# Get last entry (most recent) for current value # Get last entry (most recent) for current value
last_entry = data[-1] last_entry = data[-1]
if isinstance(last_entry, dict): if isinstance(last_entry, dict):
try: try:
self._status["kp_current"] = float(last_entry.get("Kp", 0)) self._status["kp_current"] = float(last_entry.get("Kp", 0))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
self._status["kp_timestamp"] = last_entry.get("time_tag", "") self._status["kp_timestamp"] = last_entry.get("time_tag", "")
elif isinstance(last_entry, list) and len(last_entry) > 1: elif isinstance(last_entry, list) and len(last_entry) > 1:
# Legacy array format fallback # Legacy array format fallback
try: try:
self._status["kp_current"] = float(last_entry[1]) self._status["kp_current"] = float(last_entry[1])
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if len(last_entry) > 0: if len(last_entry) > 0:
self._status["kp_timestamp"] = last_entry[0] self._status["kp_timestamp"] = last_entry[0]
def _parse_alerts(self, data): def _parse_alerts(self, data):
"""Parse alerts.json. """Parse alerts.json.
Data format: array of objects with product_id, issue_datetime, message Data format: array of objects with product_id, issue_datetime, message
""" """
warnings = [] warnings = []
if isinstance(data, list): if isinstance(data, list):
for alert in data[:5]: # Keep most recent 5 for alert in data[:5]: # Keep most recent 5
message = alert.get("message", "") message = alert.get("message", "")
# Extract first line as headline # Extract first line as headline
headline = message.split("\n")[0].strip() headline = message.split("\n")[0].strip()
if headline: if headline:
warnings.append(headline) warnings.append(headline)
self._status["active_warnings"] = warnings self._status["active_warnings"] = warnings
def _parse_f107(self, data): def _parse_f107(self, data):
"""Parse f107_cm_flux.json. """Parse f107_cm_flux.json.
Data format: array of objects with time_tag, flux Data format: array of objects with time_tag, flux
Store history for potential charting. Store history for potential charting.
""" """
if not data: if not data:
return return
# Store SFI history (last 30 days of readings) # Store SFI history (last 30 days of readings)
sfi_history = [] sfi_history = []
if isinstance(data, list): if isinstance(data, list):
for entry in data[-30:]: # Last 30 entries for entry in data[-30:]: # Last 30 entries
if isinstance(entry, dict): if isinstance(entry, dict):
try: try:
sfi_history.append({ sfi_history.append({
"time": entry.get("time_tag", ""), "time": entry.get("time_tag", ""),
"value": float(entry.get("flux", 0)), "value": float(entry.get("flux", 0)),
}) })
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
self._status["sfi_history"] = sfi_history self._status["sfi_history"] = sfi_history
# Get most recent entry (last in list) # Get most recent entry (last in list)
if isinstance(data, list) and data: if isinstance(data, list) and data:
last = data[-1] last = data[-1]
if isinstance(last, dict): if isinstance(last, dict):
try: try:
self._status["sfi"] = float(last.get("flux", 0)) self._status["sfi"] = float(last.get("flux", 0))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
def _update_events(self): def _update_events(self):
"""Generate events for significant space weather conditions.""" """Generate events for significant space weather conditions."""
# Generate events for R-scale >= 3 (radio blackout) # Generate events for R-scale >= 3 (radio blackout)
self._events = [] self._events = []
r_scale = self._status.get("r_scale", 0) r_scale = self._status.get("r_scale", 0)
if r_scale >= 3: if r_scale >= 3:
self._events.append({ self._events.append({
"source": "swpc", "source": "swpc",
"event_id": f"swpc_r{r_scale}_{int(time.time())}", "event_id": f"swpc_r{r_scale}_{int(time.time())}",
"event_type": f"R{r_scale} Radio Blackout", "event_type": f"R{r_scale} Radio Blackout",
"severity": "warning" if r_scale >= 3 else "advisory", "severity": "priority" if r_scale >= 3 else "routine",
"headline": f"R{r_scale} Radio Blackout in progress", "headline": f"R{r_scale} Radio Blackout in progress",
"expires": time.time() + 3600, # 1hr TTL "expires": time.time() + 3600, # 1hr TTL
"areas": [], "areas": [],
"fetched_at": time.time(), "fetched_at": time.time(),
}) })
def get_status(self) -> dict: def get_status(self) -> dict:
"""Get current SWPC status.""" """Get current SWPC status."""
return self._status return self._status
def get_events(self) -> list: def get_events(self) -> list:
"""Get current alert events.""" """Get current alert events."""
return self._events return self._events
@property @property
def health_status(self) -> dict: def health_status(self) -> dict:
"""Get adapter health status.""" """Get adapter health status."""
return { return {
"source": "swpc", "source": "swpc",
"is_loaded": self._is_loaded, "is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None, "last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors, "consecutive_errors": self._consecutive_errors,
"event_count": len(self._events), "event_count": len(self._events),
"last_fetch": max(self._last_tick.values()) if self._last_tick else 0, "last_fetch": max(self._last_tick.values()) if self._last_tick else 0,
} }

508
meshai/env/traffic.py vendored
View file

@ -1,254 +1,254 @@
"""TomTom Traffic Flow adapter.""" """TomTom Traffic Flow adapter."""
import json import json
import logging import logging
import os import os
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.parse import urlencode from urllib.parse import urlencode
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import TomTomConfig from ..config import TomTomConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TomTomTrafficAdapter: class TomTomTrafficAdapter:
"""TomTom Traffic Flow Segment Data polling.""" """TomTom Traffic Flow Segment Data polling."""
BASE_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json" BASE_URL = "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json"
def __init__(self, config: "TomTomConfig"): def __init__(self, config: "TomTomConfig"):
self._api_key = self._resolve_env(config.api_key or "") self._api_key = self._resolve_env(config.api_key or "")
self._corridors = config.corridors or [] self._corridors = config.corridors or []
self._tick_interval = config.tick_seconds or 300 self._tick_interval = config.tick_seconds or 300
self._last_tick = 0.0 self._last_tick = 0.0
self._events = [] self._events = []
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = False self._is_loaded = False
self._daily_requests = 0 self._daily_requests = 0
self._daily_reset = 0.0 self._daily_reset = 0.0
if not self._api_key: if not self._api_key:
logger.warning("TomTom API key not configured, adapter disabled") logger.warning("TomTom API key not configured, adapter disabled")
if not self._corridors: if not self._corridors:
logger.info("TomTom: No corridors configured, adapter idle") logger.info("TomTom: No corridors configured, adapter idle")
def _resolve_env(self, value: str) -> str: def _resolve_env(self, value: str) -> str:
"""Resolve ${ENV_VAR} references in value.""" """Resolve ${ENV_VAR} references in value."""
if value and value.startswith("${") and value.endswith("}"): if value and value.startswith("${") and value.endswith("}"):
env_var = value[2:-1] env_var = value[2:-1]
return os.environ.get(env_var, "") return os.environ.get(env_var, "")
return value return value
def tick(self) -> bool: def tick(self) -> bool:
"""Execute one polling tick. """Execute one polling tick.
Returns: Returns:
True if data changed True if data changed
""" """
now = time.time() now = time.time()
# Reset daily counter at midnight # Reset daily counter at midnight
if now - self._daily_reset > 86400: if now - self._daily_reset > 86400:
self._daily_requests = 0 self._daily_requests = 0
self._daily_reset = now self._daily_reset = now
# No API key or corridors # No API key or corridors
if not self._api_key or not self._corridors: if not self._api_key or not self._corridors:
return False return False
# Check tick interval # Check tick interval
if now - self._last_tick < self._tick_interval: if now - self._last_tick < self._tick_interval:
return False return False
self._last_tick = now self._last_tick = now
return self._fetch_all() return self._fetch_all()
def _fetch_all(self) -> bool: def _fetch_all(self) -> bool:
"""Fetch traffic flow for all configured corridors. """Fetch traffic flow for all configured corridors.
Returns: Returns:
True if data changed True if data changed
""" """
new_events = [] new_events = []
now = time.time() now = time.time()
any_error = False any_error = False
for corridor in self._corridors: for corridor in self._corridors:
# Support both dict and object formats # Support both dict and object formats
if isinstance(corridor, dict): if isinstance(corridor, dict):
name = corridor.get("name", "Unknown") name = corridor.get("name", "Unknown")
lat = corridor.get("lat") lat = corridor.get("lat")
lon = corridor.get("lon") lon = corridor.get("lon")
else: else:
name = getattr(corridor, "name", "Unknown") name = getattr(corridor, "name", "Unknown")
lat = getattr(corridor, "lat", None) lat = getattr(corridor, "lat", None)
lon = getattr(corridor, "lon", None) lon = getattr(corridor, "lon", None)
if lat is None or lon is None: if lat is None or lon is None:
continue continue
event = self._fetch_point(name, lat, lon, now) event = self._fetch_point(name, lat, lon, now)
if event: if event:
new_events.append(event) new_events.append(event)
else: else:
any_error = True any_error = True
if any_error and not new_events: if any_error and not new_events:
return False return False
# Check if data changed # Check if data changed
old_ids = {e["event_id"] for e in self._events} old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events} new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids changed = old_ids != new_ids
self._events = new_events self._events = new_events
if not any_error: if not any_error:
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = True self._is_loaded = True
if changed: if changed:
logger.info(f"TomTom traffic updated: {len(new_events)} corridors") logger.info(f"TomTom traffic updated: {len(new_events)} corridors")
return changed return changed
def _fetch_point(self, name: str, lat: float, lon: float, now: float) -> dict: def _fetch_point(self, name: str, lat: float, lon: float, now: float) -> dict:
"""Fetch traffic flow for a single point. """Fetch traffic flow for a single point.
Args: Args:
name: Corridor name name: Corridor name
lat: Latitude lat: Latitude
lon: Longitude lon: Longitude
now: Current timestamp now: Current timestamp
Returns: Returns:
Event dict or None on error Event dict or None on error
""" """
params = { params = {
"point": f"{lat},{lon}", "point": f"{lat},{lon}",
"key": self._api_key, "key": self._api_key,
"unit": "MPH", "unit": "MPH",
} }
url = f"{self.BASE_URL}?{urlencode(params)}" url = f"{self.BASE_URL}?{urlencode(params)}"
headers = { headers = {
"User-Agent": "MeshAI/1.0", "User-Agent": "MeshAI/1.0",
"Accept": "application/json", "Accept": "application/json",
} }
try: try:
req = Request(url, headers=headers) req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp: with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
self._daily_requests += 1 self._daily_requests += 1
except HTTPError as e: except HTTPError as e:
if e.code == 401 or e.code == 403: if e.code == 401 or e.code == 403:
logger.error(f"TomTom auth error: {e.code} - check API key") logger.error(f"TomTom auth error: {e.code} - check API key")
self._last_error = f"Auth error {e.code}" self._last_error = f"Auth error {e.code}"
else: else:
logger.warning(f"TomTom HTTP error for {name}: {e.code}") logger.warning(f"TomTom HTTP error for {name}: {e.code}")
self._last_error = f"HTTP {e.code}" self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return None return None
except URLError as e: except URLError as e:
logger.warning(f"TomTom connection error for {name}: {e.reason}") logger.warning(f"TomTom connection error for {name}: {e.reason}")
self._last_error = str(e.reason) self._last_error = str(e.reason)
self._consecutive_errors += 1 self._consecutive_errors += 1
return None return None
except Exception as e: except Exception as e:
logger.warning(f"TomTom fetch error for {name}: {e}") logger.warning(f"TomTom fetch error for {name}: {e}")
self._last_error = str(e) self._last_error = str(e)
self._consecutive_errors += 1 self._consecutive_errors += 1
return None return None
# Parse response # Parse response
try: try:
flow = data.get("flowSegmentData", {}) flow = data.get("flowSegmentData", {})
current_speed = flow.get("currentSpeed", 0) current_speed = flow.get("currentSpeed", 0)
free_flow_speed = flow.get("freeFlowSpeed", 0) free_flow_speed = flow.get("freeFlowSpeed", 0)
current_time = flow.get("currentTravelTime", 0) current_time = flow.get("currentTravelTime", 0)
free_flow_time = flow.get("freeFlowTravelTime", 0) free_flow_time = flow.get("freeFlowTravelTime", 0)
confidence = flow.get("confidence", 0) confidence = flow.get("confidence", 0)
road_closure = flow.get("roadClosure", False) road_closure = flow.get("roadClosure", False)
# Calculate speed ratio for severity # Calculate speed ratio for severity
if free_flow_speed > 0: if free_flow_speed > 0:
ratio = current_speed / free_flow_speed ratio = current_speed / free_flow_speed
else: else:
ratio = 1.0 ratio = 1.0
# Determine severity # Determine severity
if road_closure: if road_closure:
severity = "warning" severity = "priority"
elif ratio >= 0.8: elif ratio >= 0.8:
severity = "info" severity = "routine"
elif ratio >= 0.5: elif ratio >= 0.5:
severity = "advisory" severity = "routine"
else: else:
severity = "warning" severity = "priority"
# Format headline # Format headline
if road_closure: if road_closure:
headline = f"{name}: CLOSED" headline = f"{name}: CLOSED"
else: else:
pct = int(ratio * 100) pct = int(ratio * 100)
headline = f"{name}: {int(current_speed)}mph ({pct}% of free flow)" headline = f"{name}: {int(current_speed)}mph ({pct}% of free flow)"
event = { event = {
"source": "traffic", "source": "traffic",
"event_id": f"traffic_{name.replace(' ', '_').lower()}", "event_id": f"traffic_{name.replace(' ', '_').lower()}",
"event_type": "Traffic Flow", "event_type": "Traffic Flow",
"headline": headline, "headline": headline,
"severity": severity, "severity": severity,
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"expires": now + 600, # 10 min TTL "expires": now + 600, # 10 min TTL
"fetched_at": now, "fetched_at": now,
"properties": { "properties": {
"corridor": name, "corridor": name,
"currentSpeed": current_speed, "currentSpeed": current_speed,
"freeFlowSpeed": free_flow_speed, "freeFlowSpeed": free_flow_speed,
"speedRatio": ratio, "speedRatio": ratio,
"currentTravelTime": current_time, "currentTravelTime": current_time,
"freeFlowTravelTime": free_flow_time, "freeFlowTravelTime": free_flow_time,
"confidence": confidence, "confidence": confidence,
"roadClosure": road_closure, "roadClosure": road_closure,
}, },
} }
return event return event
except Exception as e: except Exception as e:
logger.warning(f"TomTom parse error for {name}: {e}") logger.warning(f"TomTom parse error for {name}: {e}")
self._last_error = f"Parse error: {e}" self._last_error = f"Parse error: {e}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return None return None
def get_events(self) -> list: def get_events(self) -> list:
"""Get current traffic events.""" """Get current traffic events."""
return self._events return self._events
@property @property
def health_status(self) -> dict: def health_status(self) -> dict:
"""Get adapter health status.""" """Get adapter health status."""
return { return {
"source": "traffic", "source": "traffic",
"is_loaded": self._is_loaded, "is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None, "last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors, "consecutive_errors": self._consecutive_errors,
"event_count": len(self._events), "event_count": len(self._events),
"last_fetch": self._last_tick, "last_fetch": self._last_tick,
"corridor_count": len(self._corridors), "corridor_count": len(self._corridors),
"daily_requests": self._daily_requests, "daily_requests": self._daily_requests,
} }

906
meshai/env/usgs.py vendored
View file

@ -1,453 +1,453 @@
"""USGS Water Services stream gauge adapter with NWS flood stage auto-lookup. """USGS Water Services stream gauge adapter with NWS flood stage auto-lookup.
# TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027 # TODO: Migrate to api.waterdata.usgs.gov OGC API before Q1 2027
# Legacy waterservices.usgs.gov will be decommissioned. # Legacy waterservices.usgs.gov will be decommissioned.
# See: https://www.usgs.gov/tools/usgs-water-data-apis # See: https://www.usgs.gov/tools/usgs-water-data-apis
""" """
import json import json
import logging import logging
import time import time
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from urllib.parse import urlencode from urllib.parse import urlencode
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import USGSConfig from ..config import USGSConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Minimum tick interval per USGS guidelines (do not fetch same data more than hourly) # Minimum tick interval per USGS guidelines (do not fetch same data more than hourly)
MIN_TICK_SECONDS = 900 # 15 minutes MIN_TICK_SECONDS = 900 # 15 minutes
# Cache for NWS flood stages (rarely change) # Cache for NWS flood stages (rarely change)
_nwps_cache: dict[str, dict] = {} _nwps_cache: dict[str, dict] = {}
_nwps_cache_time: dict[str, float] = {} _nwps_cache_time: dict[str, float] = {}
NWPS_CACHE_TTL = 86400 * 7 # 7 days NWPS_CACHE_TTL = 86400 * 7 # 7 days
class USGSStreamsAdapter: class USGSStreamsAdapter:
"""USGS instantaneous values for stream gauge readings with NWS flood stages.""" """USGS instantaneous values for stream gauge readings with NWS flood stages."""
BASE_URL = "https://waterservices.usgs.gov/nwis/iv/" BASE_URL = "https://waterservices.usgs.gov/nwis/iv/"
NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges" NWPS_BASE_URL = "https://api.water.noaa.gov/nwps/v1/gauges"
def __init__(self, config: "USGSConfig"): def __init__(self, config: "USGSConfig"):
self._sites = config.sites or [] self._sites = config.sites or []
self._tick_interval = max(config.tick_seconds or 900, MIN_TICK_SECONDS) self._tick_interval = max(config.tick_seconds or 900, MIN_TICK_SECONDS)
self._flood_thresholds = getattr(config, "flood_thresholds", {}) or {} self._flood_thresholds = getattr(config, "flood_thresholds", {}) or {}
self._last_tick = 0.0 self._last_tick = 0.0
self._events = [] self._events = []
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = False self._is_loaded = False
# Site metadata cache (name, flood stages from NWPS) # Site metadata cache (name, flood stages from NWPS)
self._site_metadata: dict[str, dict] = {} self._site_metadata: dict[str, dict] = {}
if self._tick_interval < MIN_TICK_SECONDS: if self._tick_interval < MIN_TICK_SECONDS:
logger.warning( logger.warning(
f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}" f"USGS tick_seconds {config.tick_seconds} below minimum, using {MIN_TICK_SECONDS}"
) )
def tick(self) -> bool: def tick(self) -> bool:
"""Execute one polling tick. """Execute one polling tick.
Returns: Returns:
True if data changed True if data changed
""" """
now = time.time() now = time.time()
# No sites configured # No sites configured
if not self._sites: if not self._sites:
return False return False
# Check tick interval # Check tick interval
if now - self._last_tick < self._tick_interval: if now - self._last_tick < self._tick_interval:
return False return False
self._last_tick = now self._last_tick = now
return self._fetch() return self._fetch()
def _get_site_ids(self) -> list[str]: def _get_site_ids(self) -> list[str]:
"""Extract site IDs from config (handles both string and dict formats).""" """Extract site IDs from config (handles both string and dict formats)."""
site_ids = [] site_ids = []
for site in self._sites: for site in self._sites:
if isinstance(site, str): if isinstance(site, str):
site_ids.append(site) site_ids.append(site)
elif isinstance(site, dict): elif isinstance(site, dict):
site_ids.append(site.get("id", "")) site_ids.append(site.get("id", ""))
elif hasattr(site, "id"): elif hasattr(site, "id"):
site_ids.append(site.id) site_ids.append(site.id)
return [s for s in site_ids if s] return [s for s in site_ids if s]
def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]: def _lookup_nwps_stages(self, usgs_site_id: str) -> Optional[dict]:
"""Lookup flood stages from NWS National Water Prediction Service. """Lookup flood stages from NWS National Water Prediction Service.
The NWPS API uses NWS gauge IDs which may differ from USGS site IDs. The NWPS API uses NWS gauge IDs which may differ from USGS site IDs.
We try a mapping lookup first, then fall back to direct lookup. We try a mapping lookup first, then fall back to direct lookup.
Returns: Returns:
dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage dict with action_stage, flood_stage, moderate_flood_stage, major_flood_stage
or None if not available or None if not available
""" """
global _nwps_cache, _nwps_cache_time global _nwps_cache, _nwps_cache_time
# Check cache # Check cache
now = time.time() now = time.time()
if usgs_site_id in _nwps_cache: if usgs_site_id in _nwps_cache:
if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL: if now - _nwps_cache_time.get(usgs_site_id, 0) < NWPS_CACHE_TTL:
return _nwps_cache[usgs_site_id] return _nwps_cache[usgs_site_id]
# Try to find NWS gauge ID from USGS site ID # Try to find NWS gauge ID from USGS site ID
# First, query USGS site info to get the NWS ID crosswalk # First, query USGS site info to get the NWS ID crosswalk
nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id) nws_gauge_id = self._usgs_to_nws_crosswalk(usgs_site_id)
if not nws_gauge_id: if not nws_gauge_id:
# Fall back to using USGS ID directly (sometimes they match) # Fall back to using USGS ID directly (sometimes they match)
nws_gauge_id = usgs_site_id nws_gauge_id = usgs_site_id
# Query NWPS for flood stages # Query NWPS for flood stages
url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}" url = f"{self.NWPS_BASE_URL}/{nws_gauge_id}"
headers = { headers = {
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)", "User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
"Accept": "application/json", "Accept": "application/json",
} }
try: try:
req = Request(url, headers=headers) req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp: with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
# Extract flood stages # Extract flood stages
stages = {} stages = {}
flood_info = data.get("flood", {}) flood_info = data.get("flood", {})
if "action" in flood_info: if "action" in flood_info:
stages["action_stage"] = flood_info["action"].get("stage") stages["action_stage"] = flood_info["action"].get("stage")
if "minor" in flood_info: if "minor" in flood_info:
stages["flood_stage"] = flood_info["minor"].get("stage") stages["flood_stage"] = flood_info["minor"].get("stage")
if "moderate" in flood_info: if "moderate" in flood_info:
stages["moderate_flood_stage"] = flood_info["moderate"].get("stage") stages["moderate_flood_stage"] = flood_info["moderate"].get("stage")
if "major" in flood_info: if "major" in flood_info:
stages["major_flood_stage"] = flood_info["major"].get("stage") stages["major_flood_stage"] = flood_info["major"].get("stage")
# Also grab the official name if available # Also grab the official name if available
stages["nws_name"] = data.get("name", "") stages["nws_name"] = data.get("name", "")
stages["nws_gauge_id"] = nws_gauge_id stages["nws_gauge_id"] = nws_gauge_id
# Cache result # Cache result
_nwps_cache[usgs_site_id] = stages _nwps_cache[usgs_site_id] = stages
_nwps_cache_time[usgs_site_id] = now _nwps_cache_time[usgs_site_id] = now
logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}") logger.info(f"NWPS flood stages for {usgs_site_id}: {stages}")
return stages return stages
except HTTPError as e: except HTTPError as e:
if e.code == 404: if e.code == 404:
# No NWPS data for this gauge - cache the miss # No NWPS data for this gauge - cache the miss
_nwps_cache[usgs_site_id] = {} _nwps_cache[usgs_site_id] = {}
_nwps_cache_time[usgs_site_id] = now _nwps_cache_time[usgs_site_id] = now
logger.debug(f"No NWPS data for gauge {usgs_site_id}") logger.debug(f"No NWPS data for gauge {usgs_site_id}")
else: else:
logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}") logger.warning(f"NWPS lookup failed for {usgs_site_id}: HTTP {e.code}")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}") logger.warning(f"NWPS lookup error for {usgs_site_id}: {e}")
return None return None
def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]: def _usgs_to_nws_crosswalk(self, usgs_site_id: str) -> Optional[str]:
"""Try to find NWS gauge ID from USGS site ID. """Try to find NWS gauge ID from USGS site ID.
The USGS provides a crosswalk in their site metadata, but it's not The USGS provides a crosswalk in their site metadata, but it's not
always populated. This is a best-effort lookup. always populated. This is a best-effort lookup.
""" """
# Try USGS site service for metadata including NWS ID # Try USGS site service for metadata including NWS ID
url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded" url = f"https://waterservices.usgs.gov/nwis/site/?format=rdb&sites={usgs_site_id}&siteOutput=expanded"
try: try:
req = Request(url, headers={"User-Agent": "MeshAI/1.0"}) req = Request(url, headers={"User-Agent": "MeshAI/1.0"})
with urlopen(req, timeout=10) as resp: with urlopen(req, timeout=10) as resp:
content = resp.read().decode("utf-8") content = resp.read().decode("utf-8")
# Parse RDB format - look for NWS ID in the data # Parse RDB format - look for NWS ID in the data
# This is a simplified parser; full implementation would be more robust # This is a simplified parser; full implementation would be more robust
for line in content.split("\n"): for line in content.split("\n"):
if line.startswith(usgs_site_id): if line.startswith(usgs_site_id):
# NWS station ID is typically in column ~30ish # NWS station ID is typically in column ~30ish
# This varies by USGS response format # This varies by USGS response format
pass pass
except Exception: except Exception:
pass pass
return None return None
def lookup_site(self, site_id: str) -> dict: def lookup_site(self, site_id: str) -> dict:
"""Lookup site metadata for config UI auto-populate. """Lookup site metadata for config UI auto-populate.
Returns: Returns:
{ {
"site_id": "13090500", "site_id": "13090500",
"name": "Snake River nr Twin Falls ID", "name": "Snake River nr Twin Falls ID",
"lat": 42.xxx, "lat": 42.xxx,
"lon": -114.xxx, "lon": -114.xxx,
"flood_stages": { "flood_stages": {
"action_stage": 9.0, "action_stage": 9.0,
"flood_stage": 10.5, "flood_stage": 10.5,
"moderate_flood_stage": 12.0, "moderate_flood_stage": 12.0,
"major_flood_stage": 14.0, "major_flood_stage": 14.0,
} or None } or None
} }
""" """
result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None} result = {"site_id": site_id, "name": None, "lat": None, "lon": None, "flood_stages": None}
# Get USGS site info # Get USGS site info
params = { params = {
"format": "json", "format": "json",
"sites": site_id, "sites": site_id,
"siteOutput": "expanded", "siteOutput": "expanded",
} }
url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}" url = f"https://waterservices.usgs.gov/nwis/site/?{urlencode(params)}"
try: try:
req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"}) req = Request(url, headers={"User-Agent": "MeshAI/1.0", "Accept": "application/json"})
with urlopen(req, timeout=15) as resp: with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
sites = data.get("value", {}).get("timeSeries", []) sites = data.get("value", {}).get("timeSeries", [])
if not sites: if not sites:
# Try alternate format # Try alternate format
sites_list = data.get("value", {}).get("sites", []) sites_list = data.get("value", {}).get("sites", [])
if sites_list: if sites_list:
site_info = sites_list[0] site_info = sites_list[0]
result["name"] = site_info.get("siteName", "") result["name"] = site_info.get("siteName", "")
result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude") result["lat"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("latitude")
result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude") result["lon"] = site_info.get("geoLocation", {}).get("geogLocation", {}).get("longitude")
except Exception as e: except Exception as e:
logger.warning(f"USGS site lookup failed for {site_id}: {e}") logger.warning(f"USGS site lookup failed for {site_id}: {e}")
# Get NWS flood stages # Get NWS flood stages
stages = self._lookup_nwps_stages(site_id) stages = self._lookup_nwps_stages(site_id)
if stages: if stages:
result["flood_stages"] = { result["flood_stages"] = {
"action_stage": stages.get("action_stage"), "action_stage": stages.get("action_stage"),
"flood_stage": stages.get("flood_stage"), "flood_stage": stages.get("flood_stage"),
"moderate_flood_stage": stages.get("moderate_flood_stage"), "moderate_flood_stage": stages.get("moderate_flood_stage"),
"major_flood_stage": stages.get("major_flood_stage"), "major_flood_stage": stages.get("major_flood_stage"),
} }
if stages.get("nws_name") and not result["name"]: if stages.get("nws_name") and not result["name"]:
result["name"] = stages["nws_name"] result["name"] = stages["nws_name"]
return result return result
def _fetch(self) -> bool: def _fetch(self) -> bool:
"""Fetch instantaneous values from USGS Water Services. """Fetch instantaneous values from USGS Water Services.
Returns: Returns:
True if data changed True if data changed
""" """
site_ids = self._get_site_ids() site_ids = self._get_site_ids()
if not site_ids: if not site_ids:
return False return False
params = { params = {
"format": "json", "format": "json",
"sites": ",".join(site_ids), "sites": ",".join(site_ids),
"parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft) "parameterCd": "00060,00065", # Streamflow (cfs) and Gage height (ft)
"siteStatus": "active", "siteStatus": "active",
} }
url = f"{self.BASE_URL}?{urlencode(params)}" url = f"{self.BASE_URL}?{urlencode(params)}"
headers = { headers = {
"User-Agent": "MeshAI/1.0 (stream gauge monitoring)", "User-Agent": "MeshAI/1.0 (stream gauge monitoring)",
"Accept": "application/json", "Accept": "application/json",
} }
try: try:
req = Request(url, headers=headers) req = Request(url, headers=headers)
with urlopen(req, timeout=30) as resp: with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8")) data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e: except HTTPError as e:
logger.warning(f"USGS HTTP error: {e.code}") logger.warning(f"USGS HTTP error: {e.code}")
self._last_error = f"HTTP {e.code}" self._last_error = f"HTTP {e.code}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
except URLError as e: except URLError as e:
logger.warning(f"USGS connection error: {e.reason}") logger.warning(f"USGS connection error: {e.reason}")
self._last_error = str(e.reason) self._last_error = str(e.reason)
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
except Exception as e: except Exception as e:
logger.warning(f"USGS fetch error: {e}") logger.warning(f"USGS fetch error: {e}")
self._last_error = str(e) self._last_error = str(e)
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
# Parse response # Parse response
new_events = [] new_events = []
now = time.time() now = time.time()
try: try:
time_series = data.get("value", {}).get("timeSeries", []) time_series = data.get("value", {}).get("timeSeries", [])
for ts in time_series: for ts in time_series:
source_info = ts.get("sourceInfo", {}) source_info = ts.get("sourceInfo", {})
variable = ts.get("variable", {}) variable = ts.get("variable", {})
values_list = ts.get("values", []) values_list = ts.get("values", [])
# Extract site info # Extract site info
site_name = source_info.get("siteName", "Unknown Site") site_name = source_info.get("siteName", "Unknown Site")
site_codes = source_info.get("siteCode", []) site_codes = source_info.get("siteCode", [])
site_id = site_codes[0].get("value", "") if site_codes else "" site_id = site_codes[0].get("value", "") if site_codes else ""
# Cache site name # Cache site name
if site_id and site_id not in self._site_metadata: if site_id and site_id not in self._site_metadata:
self._site_metadata[site_id] = {"name": site_name} self._site_metadata[site_id] = {"name": site_name}
# Extract location # Extract location
geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {}) geo_loc = source_info.get("geoLocation", {}).get("geogLocation", {})
lat = geo_loc.get("latitude") lat = geo_loc.get("latitude")
lon = geo_loc.get("longitude") lon = geo_loc.get("longitude")
# Extract variable info # Extract variable info
var_name = variable.get("variableName", "Unknown") var_name = variable.get("variableName", "Unknown")
unit_info = variable.get("unit", {}) unit_info = variable.get("unit", {})
unit_code = unit_info.get("unitCode", "") unit_code = unit_info.get("unitCode", "")
# Determine parameter type # Determine parameter type
if "Streamflow" in var_name or "00060" in str(variable.get("variableCode", [])): if "Streamflow" in var_name or "00060" in str(variable.get("variableCode", [])):
param_type = "flow" param_type = "flow"
param_name = "Streamflow" param_name = "Streamflow"
elif "Gage height" in var_name or "00065" in str(variable.get("variableCode", [])): elif "Gage height" in var_name or "00065" in str(variable.get("variableCode", [])):
param_type = "height" param_type = "height"
param_name = "Gage height" param_name = "Gage height"
else: else:
param_type = "other" param_type = "other"
param_name = var_name param_name = var_name
# Get current value (most recent) # Get current value (most recent)
if not values_list or not values_list[0].get("value"): if not values_list or not values_list[0].get("value"):
continue continue
value_entries = values_list[0].get("value", []) value_entries = values_list[0].get("value", [])
if not value_entries: if not value_entries:
continue continue
latest = value_entries[-1] latest = value_entries[-1]
value_str = latest.get("value", "") value_str = latest.get("value", "")
timestamp_str = latest.get("dateTime", "") timestamp_str = latest.get("dateTime", "")
try: try:
value = float(value_str) value = float(value_str)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
# Get flood stages for this site # Get flood stages for this site
nwps_stages = self._lookup_nwps_stages(site_id) nwps_stages = self._lookup_nwps_stages(site_id)
# Determine severity based on flood stages (for gage height) # Determine severity based on flood stages (for gage height)
severity = "info" severity = "routine"
flood_status = None flood_status = None
if param_type == "height" and nwps_stages: if param_type == "height" and nwps_stages:
major = nwps_stages.get("major_flood_stage") major = nwps_stages.get("major_flood_stage")
moderate = nwps_stages.get("moderate_flood_stage") moderate = nwps_stages.get("moderate_flood_stage")
minor = nwps_stages.get("flood_stage") minor = nwps_stages.get("flood_stage")
action = nwps_stages.get("action_stage") action = nwps_stages.get("action_stage")
if major and value >= major: if major and value >= major:
severity = "critical" severity = "immediate"
flood_status = "Major Flood" flood_status = "Major Flood"
elif moderate and value >= moderate: elif moderate and value >= moderate:
severity = "warning" severity = "priority"
flood_status = "Moderate Flood" flood_status = "Moderate Flood"
elif minor and value >= minor: elif minor and value >= minor:
severity = "warning" severity = "priority"
flood_status = "Minor Flood" flood_status = "Minor Flood"
elif action and value >= action: elif action and value >= action:
severity = "advisory" severity = "routine"
flood_status = "Action Stage" flood_status = "Action Stage"
# Fall back to legacy manual thresholds # Fall back to legacy manual thresholds
if severity == "info": if severity == "info":
threshold = self._flood_thresholds.get(site_id, {}).get(param_type) threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
if threshold and value > threshold: if threshold and value > threshold:
severity = "warning" severity = "priority"
# Format headline # Format headline
if param_type == "flow": if param_type == "flow":
headline = f"{site_name}: {value:,.0f} {unit_code}" headline = f"{site_name}: {value:,.0f} {unit_code}"
else: else:
headline = f"{site_name}: {value:.1f} {unit_code}" headline = f"{site_name}: {value:.1f} {unit_code}"
if flood_status: if flood_status:
headline += f"{flood_status}" headline += f"{flood_status}"
event = { event = {
"source": "usgs", "source": "usgs",
"event_id": f"{site_id}_{param_type}", "event_id": f"{site_id}_{param_type}",
"event_type": "Stream Gauge", "event_type": "Stream Gauge",
"headline": headline, "headline": headline,
"severity": severity, "severity": severity,
"lat": lat, "lat": lat,
"lon": lon, "lon": lon,
"expires": now + 1800, # 30 min TTL "expires": now + 1800, # 30 min TTL
"fetched_at": now, "fetched_at": now,
"properties": { "properties": {
"site_id": site_id, "site_id": site_id,
"site_name": site_name, "site_name": site_name,
"parameter": param_name, "parameter": param_name,
"value": value, "value": value,
"unit": unit_code, "unit": unit_code,
"timestamp": timestamp_str, "timestamp": timestamp_str,
"flood_status": flood_status, "flood_status": flood_status,
"flood_stages": nwps_stages if nwps_stages else None, "flood_stages": nwps_stages if nwps_stages else None,
}, },
} }
new_events.append(event) new_events.append(event)
except Exception as e: except Exception as e:
logger.warning(f"USGS parse error: {e}") logger.warning(f"USGS parse error: {e}")
self._last_error = f"Parse error: {e}" self._last_error = f"Parse error: {e}"
self._consecutive_errors += 1 self._consecutive_errors += 1
return False return False
# Check if data changed # Check if data changed
old_ids = {e["event_id"] for e in self._events} old_ids = {e["event_id"] for e in self._events}
new_ids = {e["event_id"] for e in new_events} new_ids = {e["event_id"] for e in new_events}
changed = old_ids != new_ids or len(self._events) != len(new_events) changed = old_ids != new_ids or len(self._events) != len(new_events)
self._events = new_events self._events = new_events
self._consecutive_errors = 0 self._consecutive_errors = 0
self._last_error = None self._last_error = None
self._is_loaded = True self._is_loaded = True
if changed: if changed:
logger.info(f"USGS streams updated: {len(new_events)} readings from {len(site_ids)} sites") logger.info(f"USGS streams updated: {len(new_events)} readings from {len(site_ids)} sites")
return changed return changed
def get_events(self) -> list: def get_events(self) -> list:
"""Get current stream gauge events.""" """Get current stream gauge events."""
return self._events return self._events
@property @property
def health_status(self) -> dict: def health_status(self) -> dict:
"""Get adapter health status.""" """Get adapter health status."""
return { return {
"source": "usgs", "source": "usgs",
"is_loaded": self._is_loaded, "is_loaded": self._is_loaded,
"last_error": str(self._last_error) if self._last_error else None, "last_error": str(self._last_error) if self._last_error else None,
"consecutive_errors": self._consecutive_errors, "consecutive_errors": self._consecutive_errors,
"event_count": len(self._events), "event_count": len(self._events),
"last_fetch": self._last_tick, "last_fetch": self._last_tick,
"site_count": len(self._get_site_ids()), "site_count": len(self._get_site_ids()),
} }

View file

@ -2,6 +2,11 @@
Defines all alertable conditions with human-readable names, descriptions, Defines all alertable conditions with human-readable names, descriptions,
and example messages showing what users will receive. and example messages showing what users will receive.
Severity levels (military/intelligence precedence):
routine - Informational, no time pressure
priority - Needs attention soon
immediate - Act now, drop everything
""" """
ALERT_CATEGORIES = { ALERT_CATEGORIES = {
@ -9,25 +14,25 @@ ALERT_CATEGORIES = {
"infra_offline": { "infra_offline": {
"name": "Infrastructure Node Offline", "name": "Infrastructure Node Offline",
"description": "An infrastructure node (router/repeater) stopped responding", "description": "An infrastructure node (router/repeater) stopped responding",
"default_severity": "warning", "default_severity": "priority",
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours", "example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
}, },
"critical_node_down": { "critical_node_down": {
"name": "Critical Node Down", "name": "Critical Node Down",
"description": "A node you marked as critical went offline", "description": "A node you marked as critical went offline",
"default_severity": "warning", "default_severity": "immediate",
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour", "example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
}, },
"infra_recovery": { "infra_recovery": {
"name": "Infrastructure Recovery", "name": "Infrastructure Recovery",
"description": "An offline infrastructure node came back online", "description": "An offline infrastructure node came back online",
"default_severity": "info", "default_severity": "routine",
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage", "example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
}, },
"new_router": { "new_router": {
"name": "New Router", "name": "New Router",
"description": "A new router appeared on the mesh", "description": "A new router appeared on the mesh",
"default_severity": "info", "default_severity": "routine",
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley", "example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
}, },
@ -35,37 +40,37 @@ ALERT_CATEGORIES = {
"battery_warning": { "battery_warning": {
"name": "Battery Warning", "name": "Battery Warning",
"description": "Infrastructure node battery below 30% (3.60V)", "description": "Infrastructure node battery below 30% (3.60V)",
"default_severity": "advisory", "default_severity": "routine",
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging", "example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
}, },
"battery_critical": { "battery_critical": {
"name": "Battery Critical", "name": "Battery Critical",
"description": "Infrastructure node battery below 15% (3.50V)", "description": "Infrastructure node battery below 15% (3.50V)",
"default_severity": "warning", "default_severity": "priority",
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours", "example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
}, },
"battery_emergency": { "battery_emergency": {
"name": "Battery Emergency", "name": "Battery Emergency",
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent", "description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
"default_severity": "critical", "default_severity": "immediate",
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent", "example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
}, },
"battery_trend": { "battery_trend": {
"name": "Battery Declining", "name": "Battery Declining",
"description": "Battery showing declining trend over 7 days — possible solar or charging issue", "description": "Battery showing declining trend over 7 days — possible solar or charging issue",
"default_severity": "advisory", "default_severity": "routine",
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)", "example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
}, },
"power_source_change": { "power_source_change": {
"name": "Power Source Change", "name": "Power Source Change",
"description": "Node switched from USB to battery — possible power outage at site", "description": "Node switched from USB to battery — possible power outage at site",
"default_severity": "warning", "default_severity": "priority",
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage", "example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
}, },
"solar_not_charging": { "solar_not_charging": {
"name": "Solar Not Charging", "name": "Solar Not Charging",
"description": "Solar panel not charging during daylight hours — panel issue or obstruction", "description": "Solar panel not charging during daylight hours — panel issue or obstruction",
"default_severity": "warning", "default_severity": "priority",
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)", "example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
}, },
@ -73,19 +78,19 @@ ALERT_CATEGORIES = {
"high_utilization": { "high_utilization": {
"name": "Channel Airtime High", "name": "Channel Airtime High",
"description": "LoRa channel airtime exceeding threshold — mesh congestion", "description": "LoRa channel airtime exceeding threshold — mesh congestion",
"default_severity": "advisory", "default_severity": "routine",
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.", "example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
}, },
"sustained_high_util": { "sustained_high_util": {
"name": "Sustained High Utilization", "name": "Sustained High Utilization",
"description": "Channel airtime elevated for extended period — ongoing congestion", "description": "Channel airtime elevated for extended period — ongoing congestion",
"default_severity": "warning", "default_severity": "priority",
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.", "example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
}, },
"packet_flood": { "packet_flood": {
"name": "Packet Flood", "name": "Packet Flood",
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter", "description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
"default_severity": "warning", "default_severity": "priority",
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?", "example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
}, },
@ -93,19 +98,19 @@ ALERT_CATEGORIES = {
"infra_single_gateway": { "infra_single_gateway": {
"name": "Single Gateway", "name": "Single Gateway",
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy", "description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
"default_severity": "advisory", "default_severity": "priority",
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.", "example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
}, },
"feeder_offline": { "feeder_offline": {
"name": "Feeder Offline", "name": "Feeder Offline",
"description": "A feeder gateway stopped responding — coverage gap possible", "description": "A feeder gateway stopped responding — coverage gap possible",
"default_severity": "warning", "default_severity": "priority",
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.", "example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
}, },
"region_total_blackout": { "region_total_blackout": {
"name": "Region Blackout", "name": "Region Blackout",
"description": "All infrastructure in a region is offline — complete coverage loss", "description": "All infrastructure in a region is offline — complete coverage loss",
"default_severity": "critical", "default_severity": "immediate",
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!", "example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
}, },
@ -113,13 +118,13 @@ ALERT_CATEGORIES = {
"mesh_score_low": { "mesh_score_low": {
"name": "Mesh Health Low", "name": "Mesh Health Low",
"description": "Overall mesh health score dropped below threshold — multiple issues likely", "description": "Overall mesh health score dropped below threshold — multiple issues likely",
"default_severity": "warning", "default_severity": "priority",
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.", "example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
}, },
"region_score_low": { "region_score_low": {
"name": "Region Health Low", "name": "Region Health Low",
"description": "A region's health score below threshold — localized issues", "description": "A region's health score below threshold — localized issues",
"default_severity": "warning", "default_severity": "priority",
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.", "example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
}, },
@ -127,7 +132,7 @@ ALERT_CATEGORIES = {
"weather_warning": { "weather_warning": {
"name": "Severe Weather", "name": "Severe Weather",
"description": "NWS warning or advisory affecting your mesh area", "description": "NWS warning or advisory affecting your mesh area",
"default_severity": "warning", "default_severity": "priority",
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z", "example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
}, },
@ -135,13 +140,13 @@ ALERT_CATEGORIES = {
"hf_blackout": { "hf_blackout": {
"name": "HF Radio Blackout", "name": "HF Radio Blackout",
"description": "R3+ solar flare degrading HF propagation on sunlit side", "description": "R3+ solar flare degrading HF propagation on sunlit side",
"default_severity": "warning", "default_severity": "priority",
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.", "example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
}, },
"geomagnetic_storm": { "geomagnetic_storm": {
"name": "Geomagnetic Storm", "name": "Geomagnetic Storm",
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible", "description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
"default_severity": "advisory", "default_severity": "priority",
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.", "example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
}, },
@ -149,7 +154,7 @@ ALERT_CATEGORIES = {
"tropospheric_ducting": { "tropospheric_ducting": {
"name": "Tropospheric Ducting", "name": "Tropospheric Ducting",
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range", "description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
"default_severity": "info", "default_severity": "routine",
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.", "example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
}, },
@ -157,19 +162,19 @@ ALERT_CATEGORIES = {
"fire_proximity": { "fire_proximity": {
"name": "Fire Near Mesh", "name": "Fire Near Mesh",
"description": "Active wildfire within alert radius of mesh infrastructure", "description": "Active wildfire within alert radius of mesh infrastructure",
"default_severity": "warning", "default_severity": "priority",
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.", "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
}, },
"wildfire_proximity": { "wildfire_proximity": {
"name": "Fire Near Mesh", "name": "Fire Near Mesh",
"description": "Active wildfire within alert radius of mesh infrastructure", "description": "Active wildfire within alert radius of mesh infrastructure",
"default_severity": "warning", "default_severity": "priority",
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.", "example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
}, },
"new_ignition": { "new_ignition": {
"name": "New Fire Ignition", "name": "New Fire Ignition",
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire", "description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
"default_severity": "watch", "default_severity": "priority",
"example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.", "example_message": "🛰 New Ignition: Satellite fire at 42.32°N, 114.30°W — high confidence, 47 MW FRP. Not near any known fire.",
}, },
@ -177,13 +182,13 @@ ALERT_CATEGORIES = {
"stream_flood_warning": { "stream_flood_warning": {
"name": "Stream Flood Warning", "name": "Stream Flood Warning",
"description": "River gauge exceeds NWS flood stage threshold", "description": "River gauge exceeds NWS flood stage threshold",
"default_severity": "warning", "default_severity": "priority",
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.", "example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
}, },
"stream_high_water": { "stream_high_water": {
"name": "Stream High Water", "name": "Stream High Water",
"description": "River gauge approaching flood stage — monitoring recommended", "description": "River gauge approaching flood stage — monitoring recommended",
"default_severity": "advisory", "default_severity": "routine",
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.", "example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
}, },
@ -191,13 +196,13 @@ ALERT_CATEGORIES = {
"road_closure": { "road_closure": {
"name": "Road Closure", "name": "Road Closure",
"description": "Full road closure on a monitored corridor", "description": "Full road closure on a monitored corridor",
"default_severity": "warning", "default_severity": "priority",
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.", "example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
}, },
"traffic_congestion": { "traffic_congestion": {
"name": "Traffic Congestion", "name": "Traffic Congestion",
"description": "Traffic speed dropped below congestion threshold on a monitored corridor", "description": "Traffic speed dropped below congestion threshold on a monitored corridor",
"default_severity": "advisory", "default_severity": "routine",
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio", "example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
}, },
@ -205,13 +210,13 @@ ALERT_CATEGORIES = {
"avalanche_warning": { "avalanche_warning": {
"name": "Avalanche Danger High", "name": "Avalanche Danger High",
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area", "description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
"default_severity": "warning", "default_severity": "priority",
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.", "example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
}, },
"avalanche_considerable": { "avalanche_considerable": {
"name": "Avalanche Danger Considerable", "name": "Avalanche Danger Considerable",
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level", "description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
"default_severity": "watch", "default_severity": "routine",
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.", "example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
}, },
} }
@ -224,7 +229,7 @@ def get_category(category_id: str) -> dict:
return { return {
"name": category_id.replace("_", " ").title(), "name": category_id.replace("_", " ").title(),
"description": f"Alert type: {category_id}", "description": f"Alert type: {category_id}",
"default_severity": "info", "default_severity": "routine",
"example_message": f"Alert: {category_id}", "example_message": f"Alert: {category_id}",
} }

View file

@ -168,7 +168,7 @@ class MeshDMChannel(NotificationChannel):
for node_id in self._node_ids: for node_id in self._node_ids:
try: try:
dest = int(node_id, 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else node_id) dest = int(node_id[1:], 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else int(node_id, 16))
self._connector.send_message(text=message, destination=dest, channel=0) self._connector.send_message(text=message, destination=dest, channel=0)
except Exception as e: except Exception as e:
logger.error("Failed to DM %s: %s", node_id, e) logger.error("Failed to DM %s: %s", node_id, e)
@ -199,7 +199,7 @@ class MeshDMChannel(NotificationChannel):
for node_id in self._node_ids: for node_id in self._node_ids:
try: try:
dest = int(node_id, 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else node_id) dest = int(node_id[1:], 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else int(node_id, 16))
self._connector.send_message( self._connector.send_message(
text="MeshAI DM test", text="MeshAI DM test",
destination=dest, destination=dest,
@ -249,7 +249,7 @@ class MeshDMChannel(NotificationChannel):
for node_id in self._node_ids: for node_id in self._node_ids:
try: try:
dest = int(node_id, 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else node_id) dest = int(node_id[1:], 16) if node_id.startswith("!") else (int(node_id) if node_id.isdigit() else int(node_id, 16))
self._connector.send_message(text=message, destination=dest, channel=0) self._connector.send_message(text=message, destination=dest, channel=0)
success_count += 1 success_count += 1
except Exception as e: except Exception as e:
@ -529,10 +529,9 @@ class WebhookChannel(NotificationChannel):
if "discord.com" in self._url or "slack.com" in self._url: if "discord.com" in self._url or "slack.com" in self._url:
severity = alert.get("severity", "info") severity = alert.get("severity", "info")
color = { color = {
"emergency": 0xFF0000, "immediate": 0xFF0000,
"critical": 0xFF4444, "priority": 0xFFAA00,
"warning": 0xFFAA00, "routine": 0x0099FF,
"info": 0x0099FF,
}.get(severity, 0x888888) }.get(severity, 0x888888)
payload = { payload = {
"embeds": [{ "embeds": [{

View file

@ -17,7 +17,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Severity levels in order # Severity levels in order
SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"] SEVERITY_ORDER = ["routine", "priority", "immediate"]
# State file for rule statistics # State file for rule statistics
RULE_STATS_FILE = "/opt/meshai/data/rule_stats.json" RULE_STATS_FILE = "/opt/meshai/data/rule_stats.json"
@ -164,7 +164,7 @@ class NotificationRouter:
continue continue
if self._quiet_enabled and self._in_quiet_hours(): if self._quiet_enabled and self._in_quiet_hours():
if severity not in ("emergency", "critical"): if severity == "routine":
if not rule.get("override_quiet", False): if not rule.get("override_quiet", False):
continue continue