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

View file

@ -59,30 +59,21 @@ function getAlertIcon(type: string) {
function getSeverityStyles(severity: string) {
switch (severity?.toLowerCase()) {
case 'critical':
case 'emergency':
case 'immediate':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
badge: 'bg-red-500/20 text-red-400',
iconColor: 'text-red-500',
}
case 'warning':
case 'priority':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
badge: 'bg-amber-500/20 text-amber-400',
iconColor: 'text-amber-500',
}
case 'watch':
return {
bg: 'bg-yellow-500/10',
border: 'border-yellow-500',
badge: 'bg-yellow-500/20 text-yellow-400',
iconColor: 'text-yellow-500',
}
case 'advisory':
case 'info':
case 'routine':
default:
return {
bg: 'bg-blue-500/10',

View file

@ -137,11 +137,14 @@ function AlertItem({ alert }: { alert: Alert }) {
switch (severity.toLowerCase()) {
case 'critical':
case 'emergency':
case 'immediate':
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
case 'warning':
case 'priority':
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
case 'routine':
default:
return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
return { bg: 'bg-blue-500/10', border: 'border-blue-500', icon: Info, iconColor: 'text-blue-500' }
}
}
@ -444,13 +447,20 @@ const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: s
roads: { icon: Construction, color: 'text-amber-400', label: '511' },
}
// Severity badge colors
// Severity badge colors (3-level system + legacy support)
const SEVERITY_COLORS: Record<string, string> = {
// New 3-level system
routine: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
priority: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
immediate: 'bg-red-600/20 text-red-300 border-red-600/30',
// NWS native (for raw event display)
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
advisory: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
warning: 'bg-red-500/20 text-red-400 border-red-500/30',
watch: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
warning: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
severe: 'bg-red-500/20 text-red-400 border-red-500/30',
extreme: 'bg-red-600/20 text-red-300 border-red-600/30',
critical: 'bg-red-600/20 text-red-300 border-red-600/30',
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
}

View file

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

View file

@ -98,12 +98,9 @@ interface ChannelTestResult {
// Severity levels with descriptions
const SEVERITY_OPTIONS = [
{ value: 'info', label: 'Info', description: 'Routine updates (ducting detected, new router appeared)' },
{ value: 'advisory', label: 'Advisory', description: 'Worth knowing (weather advisory, traffic slow, battery declining)' },
{ value: 'watch', label: 'Watch', description: 'Pay attention (fire within 50km, weather watch, stream rising)' },
{ 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)' },
{ value: 'routine', label: 'Routine', description: 'Informational, no time pressure (ducting, new node, weather advisory, battery declining)' },
{ value: 'priority', label: 'Priority', description: 'Needs attention soon (severe weather, fire nearby, node offline, HF blackout)' },
{ value: 'immediate', label: 'Immediate', description: 'Act now, drop everything (fire at infrastructure, extreme weather, region blackout)' },
]
// Notification rule templates
@ -117,7 +114,7 @@ const RULE_TEMPLATES = [
enabled: true,
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"],
min_severity: "advisory",
min_severity: "routine",
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 30,
@ -149,7 +146,7 @@ const RULE_TEMPLATES = [
enabled: true,
trigger_type: "condition" as const,
categories: ["weather_warning", "fire_proximity", "new_ignition", "stream_flood_warning"],
min_severity: "warning",
min_severity: "priority",
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 15,
@ -181,7 +178,7 @@ const RULE_TEMPLATES = [
enabled: true,
trigger_type: "condition" as const,
categories: ["hf_blackout", "tropospheric_ducting", "geomagnetic_storm"],
min_severity: "info",
min_severity: "routine",
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 60,
@ -213,7 +210,7 @@ const RULE_TEMPLATES = [
enabled: true,
trigger_type: "condition" as const,
categories: ["road_closure", "traffic_congestion"],
min_severity: "warning",
min_severity: "routine",
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 30,
@ -245,7 +242,7 @@ const RULE_TEMPLATES = [
enabled: true,
trigger_type: "condition" as const,
categories: [] as string[],
min_severity: "emergency",
min_severity: "immediate",
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 5,
@ -543,13 +540,13 @@ function SeveritySelector({ value, onChange }: {
onChange: (v: string) => void
}) {
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 (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
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>
<div className="relative">
<button
@ -1431,7 +1428,7 @@ export default function Notifications() {
enabled: true,
trigger_type: 'condition',
categories: [],
min_severity: 'warning',
min_severity: 'routine',
schedule_frequency: 'daily',
schedule_time: '07:00',
schedule_time_2: '19:00',
@ -1779,7 +1776,7 @@ export default function Notifications() {
checked={config.quiet_hours_enabled ?? true}
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
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 && (

View file

@ -142,7 +142,7 @@ class AlertEngine:
alerts.append(self._make_alert(
alert_type, name, short, node_num, region,
f"{emoji} {name} went offline in {region_display}.{escalation}",
is_critical,
"immediate" if is_critical else "priority",
))
state.fire(now)
@ -153,7 +153,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"infra_recovery", name, short, node_num, region,
f"\u2705 {name} is back online in {region_display}.",
is_critical,
"immediate" if is_critical else "priority",
))
state.resolve()
@ -196,7 +196,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"power_source_change", name, short, node_num, region,
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)
elif prev_source == "battery" and current_source == "usb":
@ -217,7 +217,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"battery_emergency", name, short, node_num, region,
f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.",
is_critical,
"immediate" if is_critical else "priority",
))
state.fire(now)
@ -229,7 +229,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"battery_critical", name, short, node_num, region,
f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.",
is_critical,
"immediate" if is_critical else "priority",
))
state.fire(now)
@ -241,7 +241,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"battery_warning", name, short, node_num, region,
f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.",
is_critical,
"immediate" if is_critical else "priority",
))
state.fire(now)
@ -261,7 +261,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"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}.",
is_critical,
"immediate" if is_critical else "priority",
))
state.fire(now)
@ -283,7 +283,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"solar_not_charging", name, short, node_num, region,
f"\u2600\uFE0F {name} solar not charging in {region_display}.",
is_critical,
"immediate" if is_critical else "priority",
))
state.fire(now)
except Exception:
@ -364,7 +364,7 @@ class AlertEngine:
alerts.append(self._make_alert(
"infra_single_gateway", name, short, node_num, region,
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)
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.",
"scope_type": "mesh",
"scope_value": None,
"is_critical": False,
"severity": "routine",
})
state.fire(now)
@ -438,7 +438,7 @@ class AlertEngine:
"message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!",
"scope_type": "region",
"scope_value": region.name,
"is_critical": True,
"severity": "immediate",
})
state.fire(now)
@ -469,7 +469,7 @@ class AlertEngine:
"message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).",
"scope_type": "mesh",
"scope_value": None,
"is_critical": False,
"severity": "routine",
})
state.fire(now)
elif current >= threshold:
@ -498,7 +498,7 @@ class AlertEngine:
"message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).",
"scope_type": "region",
"scope_value": region.name,
"is_critical": False,
"severity": "routine",
})
state.fire(now)
elif current >= threshold:
@ -550,7 +550,7 @@ class AlertEngine:
logger.debug(f"Battery trend query error: {e}")
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 {
"type": alert_type,
"node_name": name,
@ -560,7 +560,7 @@ class AlertEngine:
"message": message,
"scope_type": "region" if region and region != "Unknown" else "mesh",
"scope_value": region if region and region != "Unknown" else None,
"is_critical": is_critical,
"severity": severity,
}
def _get_region_display(self, region: str) -> str:
@ -616,14 +616,13 @@ class AlertEngine:
alerts.append({
"type": "weather_warning",
"message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}",
"severity": evt["severity"],
"node_num": None,
"node_name": evt["event_type"],
"node_short": "NWS",
"region": "",
"scope_type": "mesh",
"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)
@ -637,14 +636,14 @@ class AlertEngine:
alerts.append({
"type": "hf_blackout",
"message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade",
"severity": "warning",
"severity": "priority",
"node_num": None,
"node_name": f"R{r_scale} Blackout",
"node_short": "SWPC",
"region": "",
"scope_type": "mesh",
"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)
@ -659,14 +658,14 @@ class AlertEngine:
alerts.append({
"type": "tropospheric_ducting",
"message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
"severity": "info",
"severity": "routine",
"node_num": None,
"node_name": "Ducting",
"node_short": "TROPO",
"region": "",
"scope_type": "mesh",
"scope_value": None,
"is_critical": False,
"severity": "routine",
})
# Wildfire proximity alerts
@ -690,14 +689,14 @@ class AlertEngine:
alerts.append({
"type": "wildfire_proximity",
"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_name": name,
"node_short": "FIRE",
"region": anchor,
"scope_type": "mesh",
"scope_value": None,
"is_critical": True,
"severity": "immediate",
})
elif distance_km < 50:
@ -709,14 +708,14 @@ class AlertEngine:
alerts.append({
"type": "wildfire_proximity",
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
"severity": "warning",
"severity": "priority",
"node_num": None,
"node_name": name,
"node_short": "FIRE",
"region": anchor,
"scope_type": "mesh",
"scope_value": None,
"is_critical": False,
"severity": "routine",
})
return alerts

View file

@ -440,7 +440,7 @@ class NotificationRuleConfig:
# Condition trigger fields
categories: list = field(default_factory=list) # Empty = all categories
min_severity: str = "warning"
min_severity: str = "routine"
# Schedule trigger fields
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"))
if danger_level >= 4:
severity = "warning"
severity = "priority"
elif danger_level >= 3:
severity = "watch"
severity = "routine"
elif danger_level >= 2:
severity = "advisory"
severity = "routine"
else:
severity = "info"
severity = "routine"
# Compute centroid
geom = feature.get("geometry")

8
meshai/env/fires.py vendored
View file

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

12
meshai/env/firms.py vendored
View file

@ -214,21 +214,21 @@ class FIRMSAdapter:
# Determine severity
if not near_fire:
# Potential new ignition
severity = "watch"
severity = "routine"
new_ignition = True
headline = f"NEW HOTSPOT detected"
else:
# Near known fire
severity = "advisory"
severity = "routine"
new_ignition = False
headline = f"Hotspot near {fire_name}"
# Bump severity for high FRP
if frp is not None and frp > 100:
if severity == "advisory":
severity = "watch"
elif severity == "watch":
severity = "warning"
if severity == "routine":
severity = "routine"
elif severity == "routine":
severity = "priority"
headline += f" ({int(frp)} MW)"
# Compute proximity to region anchors

10
meshai/env/nws.py vendored
View file

@ -29,6 +29,16 @@ class NWSAlertsAdapter:
self._backoff_until = 0.0
self._is_loaded = False
def _map_nws_severity(self, nws_severity: str) -> str:
"""Map NWS severity to 3-level system."""
if nws_severity == "extreme":
return "immediate"
elif nws_severity in ("severe", "warning"):
return "priority"
else: # moderate, minor, unknown
return "routine"
def tick(self) -> bool:
"""Execute one polling tick.

View file

@ -294,13 +294,13 @@ class Roads511Adapter:
# Determine severity
if is_closure:
severity = "warning"
severity = "priority"
elif "construction" in str(event_type).lower():
severity = "advisory"
severity = "routine"
elif "incident" in str(event_type).lower():
severity = "advisory"
severity = "routine"
else:
severity = "info"
severity = "routine"
# Format headline
if roadway and description:

2
meshai/env/swpc.py vendored
View file

@ -244,7 +244,7 @@ class SWPCAdapter:
"source": "swpc",
"event_id": f"swpc_r{r_scale}_{int(time.time())}",
"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",
"expires": time.time() + 3600, # 1hr TTL
"areas": [],

View file

@ -190,13 +190,13 @@ class TomTomTrafficAdapter:
# Determine severity
if road_closure:
severity = "warning"
severity = "priority"
elif ratio >= 0.8:
severity = "info"
severity = "routine"
elif ratio >= 0.5:
severity = "advisory"
severity = "routine"
else:
severity = "warning"
severity = "priority"
# Format headline
if road_closure:

12
meshai/env/usgs.py vendored
View file

@ -353,7 +353,7 @@ class USGSStreamsAdapter:
nwps_stages = self._lookup_nwps_stages(site_id)
# Determine severity based on flood stages (for gage height)
severity = "info"
severity = "routine"
flood_status = None
if param_type == "height" and nwps_stages:
@ -363,23 +363,23 @@ class USGSStreamsAdapter:
action = nwps_stages.get("action_stage")
if major and value >= major:
severity = "critical"
severity = "immediate"
flood_status = "Major Flood"
elif moderate and value >= moderate:
severity = "warning"
severity = "priority"
flood_status = "Moderate Flood"
elif minor and value >= minor:
severity = "warning"
severity = "priority"
flood_status = "Minor Flood"
elif action and value >= action:
severity = "advisory"
severity = "routine"
flood_status = "Action Stage"
# Fall back to legacy manual thresholds
if severity == "info":
threshold = self._flood_thresholds.get(site_id, {}).get(param_type)
if threshold and value > threshold:
severity = "warning"
severity = "priority"
# Format headline
if param_type == "flow":

View file

@ -2,6 +2,11 @@
Defines all alertable conditions with human-readable names, descriptions,
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 = {
@ -9,25 +14,25 @@ ALERT_CATEGORIES = {
"infra_offline": {
"name": "Infrastructure Node Offline",
"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",
},
"critical_node_down": {
"name": "Critical Node Down",
"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",
},
"infra_recovery": {
"name": "Infrastructure Recovery",
"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",
},
"new_router": {
"name": "New Router",
"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",
},
@ -35,37 +40,37 @@ ALERT_CATEGORIES = {
"battery_warning": {
"name": "Battery Warning",
"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",
},
"battery_critical": {
"name": "Battery Critical",
"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",
},
"battery_emergency": {
"name": "Battery Emergency",
"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",
},
"battery_trend": {
"name": "Battery Declining",
"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)",
},
"power_source_change": {
"name": "Power Source Change",
"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",
},
"solar_not_charging": {
"name": "Solar Not Charging",
"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)",
},
@ -73,19 +78,19 @@ ALERT_CATEGORIES = {
"high_utilization": {
"name": "Channel Airtime High",
"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.",
},
"sustained_high_util": {
"name": "Sustained High Utilization",
"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.",
},
"packet_flood": {
"name": "Packet Flood",
"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?",
},
@ -93,19 +98,19 @@ ALERT_CATEGORIES = {
"infra_single_gateway": {
"name": "Single Gateway",
"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.",
},
"feeder_offline": {
"name": "Feeder Offline",
"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.",
},
"region_total_blackout": {
"name": "Region Blackout",
"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!",
},
@ -113,13 +118,13 @@ ALERT_CATEGORIES = {
"mesh_score_low": {
"name": "Mesh Health Low",
"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.",
},
"region_score_low": {
"name": "Region Health Low",
"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.",
},
@ -127,7 +132,7 @@ ALERT_CATEGORIES = {
"weather_warning": {
"name": "Severe Weather",
"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",
},
@ -135,13 +140,13 @@ ALERT_CATEGORIES = {
"hf_blackout": {
"name": "HF Radio Blackout",
"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.",
},
"geomagnetic_storm": {
"name": "Geomagnetic Storm",
"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°.",
},
@ -149,7 +154,7 @@ ALERT_CATEGORIES = {
"tropospheric_ducting": {
"name": "Tropospheric Ducting",
"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.",
},
@ -157,19 +162,19 @@ ALERT_CATEGORIES = {
"fire_proximity": {
"name": "Fire Near Mesh",
"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.",
},
"wildfire_proximity": {
"name": "Fire Near Mesh",
"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.",
},
"new_ignition": {
"name": "New Fire Ignition",
"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.",
},
@ -177,13 +182,13 @@ ALERT_CATEGORIES = {
"stream_flood_warning": {
"name": "Stream Flood Warning",
"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.",
},
"stream_high_water": {
"name": "Stream High Water",
"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.",
},
@ -191,13 +196,13 @@ ALERT_CATEGORIES = {
"road_closure": {
"name": "Road Closure",
"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.",
},
"traffic_congestion": {
"name": "Traffic Congestion",
"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",
},
@ -205,13 +210,13 @@ ALERT_CATEGORIES = {
"avalanche_warning": {
"name": "Avalanche Danger High",
"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.",
},
"avalanche_considerable": {
"name": "Avalanche Danger Considerable",
"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.",
},
}
@ -224,7 +229,7 @@ def get_category(category_id: str) -> dict:
return {
"name": category_id.replace("_", " ").title(),
"description": f"Alert type: {category_id}",
"default_severity": "info",
"default_severity": "routine",
"example_message": f"Alert: {category_id}",
}

View file

@ -168,7 +168,7 @@ class MeshDMChannel(NotificationChannel):
for node_id in self._node_ids:
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)
except Exception as 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:
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="MeshAI DM test",
destination=dest,
@ -249,7 +249,7 @@ class MeshDMChannel(NotificationChannel):
for node_id in self._node_ids:
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)
success_count += 1
except Exception as e:
@ -529,10 +529,9 @@ class WebhookChannel(NotificationChannel):
if "discord.com" in self._url or "slack.com" in self._url:
severity = alert.get("severity", "info")
color = {
"emergency": 0xFF0000,
"critical": 0xFF4444,
"warning": 0xFFAA00,
"info": 0x0099FF,
"immediate": 0xFF0000,
"priority": 0xFFAA00,
"routine": 0x0099FF,
}.get(severity, 0x888888)
payload = {
"embeds": [{

View file

@ -17,7 +17,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Severity levels in order
SEVERITY_ORDER = ["info", "advisory", "watch", "warning", "critical", "emergency"]
SEVERITY_ORDER = ["routine", "priority", "immediate"]
# State file for rule statistics
RULE_STATS_FILE = "/opt/meshai/data/rule_stats.json"
@ -164,7 +164,7 @@ class NotificationRouter:
continue
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):
continue