mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
5b78e38d2e
commit
49f2838048
17 changed files with 3285 additions and 3265 deletions
|
|
@ -59,30 +59,21 @@ function getAlertIcon(type: string) {
|
||||||
|
|
||||||
function getSeverityStyles(severity: string) {
|
function getSeverityStyles(severity: string) {
|
||||||
switch (severity?.toLowerCase()) {
|
switch (severity?.toLowerCase()) {
|
||||||
case 'critical':
|
case 'immediate':
|
||||||
case 'emergency':
|
|
||||||
return {
|
return {
|
||||||
bg: 'bg-red-500/10',
|
bg: 'bg-red-500/10',
|
||||||
border: 'border-red-500',
|
border: 'border-red-500',
|
||||||
badge: 'bg-red-500/20 text-red-400',
|
badge: 'bg-red-500/20 text-red-400',
|
||||||
iconColor: 'text-red-500',
|
iconColor: 'text-red-500',
|
||||||
}
|
}
|
||||||
case 'warning':
|
case 'priority':
|
||||||
return {
|
return {
|
||||||
bg: 'bg-amber-500/10',
|
bg: 'bg-amber-500/10',
|
||||||
border: 'border-amber-500',
|
border: 'border-amber-500',
|
||||||
badge: 'bg-amber-500/20 text-amber-400',
|
badge: 'bg-amber-500/20 text-amber-400',
|
||||||
iconColor: 'text-amber-500',
|
iconColor: 'text-amber-500',
|
||||||
}
|
}
|
||||||
case 'watch':
|
case 'routine':
|
||||||
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':
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
bg: 'bg-blue-500/10',
|
bg: 'bg-blue-500/10',
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,14 @@ function AlertItem({ alert }: { alert: Alert }) {
|
||||||
switch (severity.toLowerCase()) {
|
switch (severity.toLowerCase()) {
|
||||||
case 'critical':
|
case 'critical':
|
||||||
case 'emergency':
|
case 'emergency':
|
||||||
|
case 'immediate':
|
||||||
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
|
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
|
||||||
case 'warning':
|
case 'warning':
|
||||||
|
case 'priority':
|
||||||
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
|
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
|
||||||
|
case 'routine':
|
||||||
default:
|
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' },
|
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> = {
|
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',
|
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',
|
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
watch: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
warning: 'bg-red-500/20 text-red-400 border-red-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',
|
critical: 'bg-red-600/20 text-red-300 border-red-600/30',
|
||||||
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
8
meshai/env/avalanche.py
vendored
8
meshai/env/avalanche.py
vendored
|
|
@ -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
8
meshai/env/fires.py
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
12
meshai/env/firms.py
vendored
12
meshai/env/firms.py
vendored
|
|
@ -214,21 +214,21 @@ class FIRMSAdapter:
|
||||||
# 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
|
||||||
|
|
|
||||||
10
meshai/env/nws.py
vendored
10
meshai/env/nws.py
vendored
|
|
@ -29,6 +29,16 @@ class NWSAlertsAdapter:
|
||||||
self._backoff_until = 0.0
|
self._backoff_until = 0.0
|
||||||
self._is_loaded = False
|
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:
|
def tick(self) -> bool:
|
||||||
"""Execute one polling tick.
|
"""Execute one polling tick.
|
||||||
|
|
||||||
|
|
|
||||||
8
meshai/env/roads511.py
vendored
8
meshai/env/roads511.py
vendored
|
|
@ -294,13 +294,13 @@ class Roads511Adapter:
|
||||||
|
|
||||||
# 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:
|
||||||
|
|
|
||||||
2
meshai/env/swpc.py
vendored
2
meshai/env/swpc.py
vendored
|
|
@ -244,7 +244,7 @@ class SWPCAdapter:
|
||||||
"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": [],
|
||||||
|
|
|
||||||
8
meshai/env/traffic.py
vendored
8
meshai/env/traffic.py
vendored
|
|
@ -190,13 +190,13 @@ class TomTomTrafficAdapter:
|
||||||
|
|
||||||
# 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:
|
||||||
|
|
|
||||||
12
meshai/env/usgs.py
vendored
12
meshai/env/usgs.py
vendored
|
|
@ -353,7 +353,7 @@ class USGSStreamsAdapter:
|
||||||
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:
|
||||||
|
|
@ -363,23 +363,23 @@ class USGSStreamsAdapter:
|
||||||
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":
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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": [{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue