mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 08:54:36 +02:00
Compare commits
6 commits
32f6a238f8
...
344ca0677d
| Author | SHA1 | Date | |
|---|---|---|---|
| 344ca0677d | |||
| 95ec7d5351 | |||
| 7a4bd4f38f | |||
| 21d6520ffd | |||
| 839bf322d9 | |||
| 829ad562e4 |
18 changed files with 1777 additions and 1420 deletions
|
|
@ -119,18 +119,18 @@ mesh_sources: []
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# region_radius_miles: 40.0 # Radius for region clustering
|
# region_radius_miles: 40.0 # Radius for region clustering
|
||||||
# locality_radius_miles: 8.0 # Radius for locality clustering
|
# locality_radius_miles: 8.0 # Radius for locality clustering
|
||||||
# offline_threshold_hours: 24 # Hours before node considered offline
|
# offline_threshold_hours: 2 # Hours before node considered offline
|
||||||
# packet_threshold: 500 # Non-text packets per 24h to flag
|
# packet_threshold: 500 # Non-text packets per 24h to flag
|
||||||
# battery_warning_percent: 20 # Battery level for warnings
|
# battery_warning_percent: 30 # Battery level for warnings
|
||||||
# infra_overrides: [] # Node IDs to exclude from infrastructure
|
# infra_overrides: [] # Node IDs to exclude from infrastructure
|
||||||
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
|
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
|
||||||
mesh_intelligence:
|
mesh_intelligence:
|
||||||
enabled: false
|
enabled: false
|
||||||
region_radius_miles: 40.0
|
region_radius_miles: 40.0
|
||||||
locality_radius_miles: 8.0
|
locality_radius_miles: 8.0
|
||||||
offline_threshold_hours: 24
|
offline_threshold_hours: 2
|
||||||
packet_threshold: 500
|
packet_threshold: 500
|
||||||
battery_warning_percent: 20
|
battery_warning_percent: 30
|
||||||
infra_overrides: []
|
infra_overrides: []
|
||||||
region_labels: {}
|
region_labels: {}
|
||||||
|
|
||||||
|
|
@ -217,11 +217,13 @@ environmental:
|
||||||
proximity_km: 10.0 # km to match known fire perimeters
|
proximity_km: 10.0 # km to match known fire perimeters
|
||||||
|
|
||||||
|
|
||||||
# === NOTIFICATION DELIVERY ===
|
# === NOTIFICATION DELIVERY (TRANSITIONAL) ===
|
||||||
|
# NOTE: This notifications schema will be replaced in v0.3 by the 8-toggle model.
|
||||||
|
# These rule examples are transitional until Phase 1.2 lands. Do not extend.
|
||||||
|
# Severity levels: routine (informational), priority (needs attention), immediate (act now)
|
||||||
|
#
|
||||||
# Route alerts to channels (mesh, email, webhook) based on rules.
|
# Route alerts to channels (mesh, email, webhook) based on rules.
|
||||||
# Categories match alert types from alert_engine.py.
|
# Categories match alert types from alert_engine.py.
|
||||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
|
||||||
#
|
|
||||||
notifications:
|
notifications:
|
||||||
enabled: false
|
enabled: false
|
||||||
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||||
|
|
@ -236,7 +238,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: [] # Empty = all categories
|
categories: [] # Empty = all categories
|
||||||
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
|
||||||
|
|
@ -247,7 +249,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: ["infra_offline", "critical_node_down"]
|
categories: ["infra_offline", "critical_node_down"]
|
||||||
min_severity: "warning"
|
min_severity: "priority"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 30
|
cooldown_minutes: 30
|
||||||
|
|
@ -258,7 +260,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: ["wildfire_proximity", "new_ignition"]
|
categories: ["wildfire_proximity", "new_ignition"]
|
||||||
min_severity: "advisory"
|
min_severity: "routine"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 60
|
cooldown_minutes: 60
|
||||||
|
|
@ -269,7 +271,7 @@ notifications:
|
||||||
enabled: true
|
enabled: true
|
||||||
trigger_type: condition
|
trigger_type: condition
|
||||||
categories: ["weather_warning"]
|
categories: ["weather_warning"]
|
||||||
min_severity: "warning"
|
min_severity: "priority"
|
||||||
delivery_type: mesh_broadcast
|
delivery_type: mesh_broadcast
|
||||||
broadcast_channel: 0
|
broadcast_channel: 0
|
||||||
cooldown_minutes: 30
|
cooldown_minutes: 30
|
||||||
|
|
@ -280,7 +282,7 @@ notifications:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# trigger_type: condition
|
# trigger_type: condition
|
||||||
# categories: ["wildfire_proximity", "new_ignition"]
|
# categories: ["wildfire_proximity", "new_ignition"]
|
||||||
# min_severity: "advisory"
|
# min_severity: "routine"
|
||||||
# delivery_type: email
|
# delivery_type: email
|
||||||
# smtp_host: "smtp.gmail.com"
|
# smtp_host: "smtp.gmail.com"
|
||||||
# smtp_port: 587
|
# smtp_port: 587
|
||||||
|
|
@ -296,7 +298,7 @@ notifications:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# trigger_type: condition
|
# trigger_type: condition
|
||||||
# categories: []
|
# categories: []
|
||||||
# min_severity: "warning"
|
# min_severity: "priority"
|
||||||
# delivery_type: webhook
|
# delivery_type: webhook
|
||||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||||
# cooldown_minutes: 10
|
# cooldown_minutes: 10
|
||||||
|
|
@ -316,7 +318,7 @@ notifications:
|
||||||
# enabled: true
|
# enabled: true
|
||||||
# trigger_type: condition
|
# trigger_type: condition
|
||||||
# categories: ["battery_warning"]
|
# categories: ["battery_warning"]
|
||||||
# min_severity: "warning"
|
# min_severity: "priority"
|
||||||
# delivery_type: "" # Empty = no delivery, just tracks matches
|
# delivery_type: "" # Empty = no delivery, just tracks matches
|
||||||
|
|
||||||
# === WEB DASHBOARD ===
|
# === WEB DASHBOARD ===
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
|
||||||
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventFeedItem({ event }: { event: EnvEvent }) {
|
function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) {
|
||||||
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
||||||
const Icon = sourceConfig.icon
|
const Icon = sourceConfig.icon
|
||||||
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
||||||
|
|
@ -483,18 +483,43 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build display title: prefer event_type + area_desc, fall back to headline
|
||||||
|
const eventType = (event as Record<string, unknown>).event_type as string | undefined
|
||||||
|
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
|
||||||
|
const description = (event as Record<string, unknown>).description as string | undefined
|
||||||
|
|
||||||
|
let title = event.headline
|
||||||
|
if (eventType && areaDesc) {
|
||||||
|
// Shorten area description (remove "County" repetition)
|
||||||
|
const shortArea = areaDesc.replace(/ County/g, '').split(';')[0]
|
||||||
|
title = `${eventType} — ${shortArea}`
|
||||||
|
} else if (eventType) {
|
||||||
|
title = eventType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first sentence of description as subtitle
|
||||||
|
const subtitle = description ? description.split('. ')[0] : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
<div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-blue-500 pl-2 -ml-2' : ''}`}>
|
||||||
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||||
{event.severity || 'info'}
|
{event.severity || 'info'}
|
||||||
</span>
|
</span>
|
||||||
|
{isLocal && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
LOCAL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
||||||
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
|
<div className={`text-sm truncate ${isLocal ? 'text-slate-100' : 'text-slate-300'}`}>{title}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-slate-500 truncate mt-0.5">{subtitle}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||||
|
|
||||||
// Live Event Feed Card
|
// Live Event Feed Card
|
||||||
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
||||||
|
// Severity order for sorting
|
||||||
|
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
|
||||||
|
|
||||||
const sortedEvents = useMemo(() => {
|
const sortedEvents = useMemo(() => {
|
||||||
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
// Dedup by event_id
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped = events.filter(e => {
|
||||||
|
if (!e.event_id) return true
|
||||||
|
if (seen.has(e.event_id)) return false
|
||||||
|
seen.add(e.event_id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: local first, then by severity, then by time
|
||||||
|
return deduped.sort((a, b) => {
|
||||||
|
const aLocal = (a as Record<string, unknown>).is_local ? 1 : 0
|
||||||
|
const bLocal = (b as Record<string, unknown>).is_local ? 1 : 0
|
||||||
|
if (aLocal !== bLocal) return bLocal - aLocal // local first
|
||||||
|
|
||||||
|
const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2
|
||||||
|
const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2
|
||||||
|
if (aSev !== bSev) return aSev - bSev // higher severity first
|
||||||
|
|
||||||
|
return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first
|
||||||
|
})
|
||||||
}, [events])
|
}, [events])
|
||||||
|
|
||||||
// Calculate feed health summary
|
// Calculate feed health summary
|
||||||
|
|
@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
|
||||||
{sortedEvents.length > 0 ? (
|
{sortedEvents.length > 0 ? (
|
||||||
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||||
{sortedEvents.map((event, i) => (
|
{sortedEvents.map((event, i) => (
|
||||||
<EventFeedItem key={event.event_id || i} event={event} />
|
<EventFeedItem
|
||||||
|
key={event.event_id || i}
|
||||||
|
event={event}
|
||||||
|
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ class MeshIntelligenceConfig:
|
||||||
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
|
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
|
||||||
offline_threshold_hours: int = 2 # Hours before node considered offline
|
offline_threshold_hours: int = 2 # Hours before node considered offline
|
||||||
packet_threshold: int = 500 # Non-text packets per 24h to flag
|
packet_threshold: int = 500 # Non-text packets per 24h to flag
|
||||||
|
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
|
||||||
battery_warning_percent: int = 30 # Battery level for warnings
|
battery_warning_percent: int = 30 # Battery level for warnings
|
||||||
|
|
||||||
# Alert settings
|
# Alert settings
|
||||||
|
|
@ -577,7 +578,7 @@ def _migrate_legacy_channels(notifications, data: dict):
|
||||||
enabled=ch.get("enabled", True),
|
enabled=ch.get("enabled", True),
|
||||||
trigger_type="condition",
|
trigger_type="condition",
|
||||||
categories=old_rule.get("categories", []),
|
categories=old_rule.get("categories", []),
|
||||||
min_severity=old_rule.get("min_severity", "warning"),
|
min_severity=old_rule.get("min_severity", "priority"),
|
||||||
delivery_type=ch.get("type", "mesh_broadcast"),
|
delivery_type=ch.get("type", "mesh_broadcast"),
|
||||||
broadcast_channel=ch.get("channel_index", 0),
|
broadcast_channel=ch.get("channel_index", 0),
|
||||||
node_ids=ch.get("node_ids", []),
|
node_ids=ch.get("node_ids", []),
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1,210 @@
|
||||||
"""Environmental data API routes."""
|
"""Environmental data API routes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
router = APIRouter(tags=["environment"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/status")
|
@router.get("/env/status")
|
||||||
async def get_env_status(request: Request):
|
async def get_env_status(request: Request):
|
||||||
"""Get environmental feeds status."""
|
"""Get environmental feeds status."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False, "feeds": []}
|
return {"enabled": False, "feeds": []}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"feeds": env_store.get_source_health(),
|
"feeds": env_store.get_source_health(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/active")
|
@router.get("/env/active")
|
||||||
async def get_active_env(request: Request):
|
async def get_active_env(request: Request):
|
||||||
"""Get active environmental events."""
|
"""Get active environmental events with local zone marking."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active()
|
events = env_store.get_active()
|
||||||
|
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
# Dedup by event_id and add is_local field
|
||||||
async def get_swpc_data(request: Request):
|
seen_ids = set()
|
||||||
"""Get SWPC space weather data."""
|
result = []
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
for event in events:
|
||||||
|
event_id = event.get("event_id")
|
||||||
if not env_store:
|
if event_id and event_id in seen_ids:
|
||||||
return {"enabled": False}
|
continue
|
||||||
|
if event_id:
|
||||||
status = env_store.get_swpc_status()
|
seen_ids.add(event_id)
|
||||||
if not status:
|
|
||||||
return {"enabled": False}
|
# Mark as local if event zones overlap with configured mesh zones
|
||||||
|
event_zones = set(event.get("areas", []))
|
||||||
return {
|
event["is_local"] = bool(event_zones & mesh_zones)
|
||||||
"enabled": True,
|
result.append(event)
|
||||||
**status,
|
|
||||||
}
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/propagation")
|
@router.get("/env/swpc")
|
||||||
async def get_rf_propagation(request: Request):
|
async def get_swpc_data(request: Request):
|
||||||
"""Get combined HF + UHF propagation data for dashboard."""
|
"""Get SWPC space weather data."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"hf": {}, "uhf_ducting": {}}
|
return {"enabled": False}
|
||||||
|
|
||||||
return env_store.get_rf_propagation()
|
status = env_store.get_swpc_status()
|
||||||
|
if not status:
|
||||||
|
return {"enabled": False}
|
||||||
@router.get("/env/ducting")
|
|
||||||
async def get_ducting_data(request: Request):
|
return {
|
||||||
"""Get tropospheric ducting assessment."""
|
"enabled": True,
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
**status,
|
||||||
|
}
|
||||||
if not env_store:
|
|
||||||
return {"enabled": False}
|
|
||||||
|
@router.get("/env/propagation")
|
||||||
status = env_store.get_ducting_status()
|
async def get_rf_propagation(request: Request):
|
||||||
if not status:
|
"""Get combined HF + UHF propagation data for dashboard."""
|
||||||
return {"enabled": False}
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
return {
|
if not env_store:
|
||||||
"enabled": True,
|
return {"hf": {}, "uhf_ducting": {}}
|
||||||
**status,
|
|
||||||
}
|
return env_store.get_rf_propagation()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/fires")
|
@router.get("/env/ducting")
|
||||||
async def get_fires_data(request: Request):
|
async def get_ducting_data(request: Request):
|
||||||
"""Get active wildfire perimeters."""
|
"""Get tropospheric ducting assessment."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return {"enabled": False}
|
||||||
|
|
||||||
return env_store.get_active(source="nifc")
|
status = env_store.get_ducting_status()
|
||||||
|
if not status:
|
||||||
|
return {"enabled": False}
|
||||||
@router.get("/env/avalanche")
|
|
||||||
async def get_avalanche_data(request: Request):
|
return {
|
||||||
"""Get avalanche advisories."""
|
"enabled": True,
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
**status,
|
||||||
|
}
|
||||||
if not env_store:
|
|
||||||
return {"off_season": True, "advisories": []}
|
|
||||||
|
@router.get("/env/fires")
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
async def get_fires_data(request: Request):
|
||||||
avy_adapter = adapters.get("avalanche")
|
"""Get active wildfire perimeters."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
if avy_adapter and avy_adapter.is_off_season():
|
|
||||||
return {"off_season": True, "advisories": []}
|
if not env_store:
|
||||||
|
return []
|
||||||
return {
|
|
||||||
"off_season": False,
|
return env_store.get_active(source="nifc")
|
||||||
"advisories": env_store.get_active(source="avalanche"),
|
|
||||||
}
|
|
||||||
|
@router.get("/env/avalanche")
|
||||||
|
async def get_avalanche_data(request: Request):
|
||||||
@router.get("/env/streams")
|
"""Get avalanche advisories."""
|
||||||
async def get_streams_data(request: Request):
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
"""Get USGS stream gauge readings."""
|
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
if not env_store:
|
||||||
|
return {"off_season": True, "advisories": []}
|
||||||
if not env_store:
|
|
||||||
return []
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
|
avy_adapter = adapters.get("avalanche")
|
||||||
return env_store.get_active(source="usgs")
|
|
||||||
|
if avy_adapter and avy_adapter.is_off_season():
|
||||||
|
return {"off_season": True, "advisories": []}
|
||||||
@router.get("/env/usgs/lookup/{site_id}")
|
|
||||||
async def lookup_usgs_site(request: Request, site_id: str):
|
return {
|
||||||
"""Lookup USGS site metadata and NWS flood stages.
|
"off_season": False,
|
||||||
|
"advisories": env_store.get_active(source="avalanche"),
|
||||||
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
}
|
||||||
Used by the config UI to auto-populate fields when adding a new gauge.
|
|
||||||
"""
|
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
@router.get("/env/streams")
|
||||||
|
async def get_streams_data(request: Request):
|
||||||
if not env_store:
|
"""Get USGS stream gauge readings."""
|
||||||
return {"error": "Environmental feeds not enabled"}
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
if not env_store:
|
||||||
usgs_adapter = adapters.get("usgs")
|
return []
|
||||||
|
|
||||||
if not usgs_adapter:
|
return env_store.get_active(source="usgs")
|
||||||
# Create a temporary adapter for lookup
|
|
||||||
from meshai.env.usgs import USGSStreamsAdapter
|
|
||||||
from meshai.config import USGSConfig
|
@router.get("/env/usgs/lookup/{site_id}")
|
||||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
async def lookup_usgs_site(request: Request, site_id: str):
|
||||||
|
"""Lookup USGS site metadata and NWS flood stages.
|
||||||
try:
|
|
||||||
result = usgs_adapter.lookup_site(site_id)
|
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
||||||
return result
|
Used by the config UI to auto-populate fields when adding a new gauge.
|
||||||
except Exception as e:
|
"""
|
||||||
return {"error": str(e), "site_id": site_id}
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
@router.get("/env/traffic")
|
return {"error": "Environmental feeds not enabled"}
|
||||||
async def get_traffic_data(request: Request):
|
|
||||||
"""Get TomTom traffic flow data."""
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
usgs_adapter = adapters.get("usgs")
|
||||||
|
|
||||||
if not env_store:
|
if not usgs_adapter:
|
||||||
return []
|
# Create a temporary adapter for lookup
|
||||||
|
from meshai.env.usgs import USGSStreamsAdapter
|
||||||
return env_store.get_active(source="traffic")
|
from meshai.config import USGSConfig
|
||||||
|
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||||
|
|
||||||
@router.get("/env/roads")
|
try:
|
||||||
async def get_roads_data(request: Request):
|
result = usgs_adapter.lookup_site(site_id)
|
||||||
"""Get 511 road conditions."""
|
return result
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
except Exception as e:
|
||||||
|
return {"error": str(e), "site_id": site_id}
|
||||||
if not env_store:
|
|
||||||
return []
|
|
||||||
|
@router.get("/env/traffic")
|
||||||
return env_store.get_active(source="511")
|
async def get_traffic_data(request: Request):
|
||||||
|
"""Get TomTom traffic flow data."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
@router.get("/env/hotspots")
|
|
||||||
async def get_hotspots_data(request: Request):
|
if not env_store:
|
||||||
"""Get NASA FIRMS satellite fire hotspots."""
|
return []
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
|
||||||
|
return env_store.get_active(source="traffic")
|
||||||
if not env_store:
|
|
||||||
return {"hotspots": [], "new_ignitions": 0}
|
|
||||||
|
@router.get("/env/roads")
|
||||||
firms_adapter = getattr(env_store, "_firms", None)
|
async def get_roads_data(request: Request):
|
||||||
|
"""Get 511 road conditions."""
|
||||||
if not firms_adapter:
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
|
||||||
|
if not env_store:
|
||||||
hotspots = env_store.get_active(source="firms")
|
return []
|
||||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
|
||||||
|
return env_store.get_active(source="511")
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"hotspots": hotspots,
|
@router.get("/env/hotspots")
|
||||||
"new_ignitions": len(new_ignitions),
|
async def get_hotspots_data(request: Request):
|
||||||
}
|
"""Get NASA FIRMS satellite fire hotspots."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0}
|
||||||
|
|
||||||
|
firms_adapter = getattr(env_store, "_firms", None)
|
||||||
|
|
||||||
|
if not firms_adapter:
|
||||||
|
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
||||||
|
|
||||||
|
hotspots = env_store.get_active(source="firms")
|
||||||
|
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"hotspots": hotspots,
|
||||||
|
"new_ignitions": len(new_ignitions),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,305 +1,305 @@
|
||||||
"""Notification API routes with comprehensive testing."""
|
"""Notification API routes with comprehensive testing."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, HTTPException
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
class TestRequest(BaseModel):
|
class TestRequest(BaseModel):
|
||||||
"""Request body for test endpoint."""
|
"""Request body for test endpoint."""
|
||||||
send: bool = False # Legacy: True = send_test
|
send: bool = False # Legacy: True = send_test
|
||||||
action: str = "preview" # "preview", "send_test", "send_status", "send_live"
|
action: str = "preview" # "preview", "send_test", "send_status", "send_live"
|
||||||
|
|
||||||
|
|
||||||
class ChannelTestRequest(BaseModel):
|
class ChannelTestRequest(BaseModel):
|
||||||
"""Request body for channel connectivity test."""
|
"""Request body for channel connectivity test."""
|
||||||
type: str # mesh_broadcast, mesh_dm, email, webhook
|
type: str # mesh_broadcast, mesh_dm, email, webhook
|
||||||
# Mesh broadcast
|
# Mesh broadcast
|
||||||
channel_index: Optional[int] = 0
|
channel_index: Optional[int] = 0
|
||||||
# Mesh DM
|
# Mesh DM
|
||||||
node_ids: Optional[List[str]] = []
|
node_ids: Optional[List[str]] = []
|
||||||
# Email
|
# Email
|
||||||
smtp_host: Optional[str] = ""
|
smtp_host: Optional[str] = ""
|
||||||
smtp_port: Optional[int] = 587
|
smtp_port: Optional[int] = 587
|
||||||
smtp_user: Optional[str] = ""
|
smtp_user: Optional[str] = ""
|
||||||
smtp_password: Optional[str] = ""
|
smtp_password: Optional[str] = ""
|
||||||
smtp_tls: Optional[bool] = True
|
smtp_tls: Optional[bool] = True
|
||||||
from_address: Optional[str] = ""
|
from_address: Optional[str] = ""
|
||||||
recipients: Optional[List[str]] = []
|
recipients: Optional[List[str]] = []
|
||||||
# Webhook
|
# Webhook
|
||||||
url: Optional[str] = ""
|
url: Optional[str] = ""
|
||||||
headers: Optional[Dict[str, str]] = {}
|
headers: Optional[Dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
class RuleSourcesRequest(BaseModel):
|
class RuleSourcesRequest(BaseModel):
|
||||||
"""Request body for rule sources health check."""
|
"""Request body for rule sources health check."""
|
||||||
categories: List[str] = []
|
categories: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories")
|
@router.get("/categories")
|
||||||
async def get_categories():
|
async def get_categories():
|
||||||
"""Get all alert categories with descriptions."""
|
"""Get all alert categories with descriptions."""
|
||||||
try:
|
try:
|
||||||
from ...notifications.categories import list_categories
|
from ...notifications.categories import list_categories
|
||||||
return list_categories()
|
return list_categories()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules")
|
@router.get("/rules")
|
||||||
async def get_rules(request: Request):
|
async def get_rules(request: Request):
|
||||||
"""Get configured notification rules with stats."""
|
"""Get configured notification rules with stats."""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
rules = notification_router.get_rules()
|
rules = notification_router.get_rules()
|
||||||
|
|
||||||
# Enhance rules with stats
|
# Enhance rules with stats
|
||||||
result = []
|
result = []
|
||||||
for i, rule in enumerate(rules):
|
for i, rule in enumerate(rules):
|
||||||
rule_copy = dict(rule)
|
rule_copy = dict(rule)
|
||||||
stats = rule_copy.pop("_stats", {})
|
stats = rule_copy.pop("_stats", {})
|
||||||
rule_copy["stats"] = stats
|
rule_copy["stats"] = stats
|
||||||
rule_copy["index"] = i
|
rule_copy["index"] = i
|
||||||
result.append(rule_copy)
|
result.append(rule_copy)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules/{rule_index}/stats")
|
@router.get("/rules/{rule_index}/stats")
|
||||||
async def get_rule_stats(request: Request, rule_index: int):
|
async def get_rule_stats(request: Request, rule_index: int):
|
||||||
"""Get statistics for a specific rule."""
|
"""Get statistics for a specific rule."""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
rules_config = getattr(request.app.state, "config", None)
|
rules_config = getattr(request.app.state, "config", None)
|
||||||
if rules_config:
|
if rules_config:
|
||||||
rules_config = getattr(rules_config, "rules", [])
|
rules_config = getattr(rules_config, "rules", [])
|
||||||
if rule_index < 0 or rule_index >= len(rules_config):
|
if rule_index < 0 or rule_index >= len(rules_config):
|
||||||
raise HTTPException(status_code=404, detail="Rule not found")
|
raise HTTPException(status_code=404, detail="Rule not found")
|
||||||
|
|
||||||
rule = rules_config[rule_index]
|
rule = rules_config[rule_index]
|
||||||
if hasattr(rule, "__dict__"):
|
if hasattr(rule, "__dict__"):
|
||||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||||
else:
|
else:
|
||||||
rule_dict = dict(rule)
|
rule_dict = dict(rule)
|
||||||
|
|
||||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||||
return notification_router.get_rule_stats(rule_name)
|
return notification_router.get_rule_stats(rule_name)
|
||||||
|
|
||||||
return {"last_fired": None, "last_test": None, "fire_count": 0}
|
return {"last_fired": None, "last_test": None, "fire_count": 0}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/channels/test")
|
@router.post("/channels/test")
|
||||||
async def test_channel(request: Request, body: ChannelTestRequest):
|
async def test_channel(request: Request, body: ChannelTestRequest):
|
||||||
"""Test channel connectivity without sending actual alert content.
|
"""Test channel connectivity without sending actual alert content.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"success": bool,
|
"success": bool,
|
||||||
"message": str, # Human-readable result
|
"message": str, # Human-readable result
|
||||||
"error": str, # Detailed error if failed
|
"error": str, # Detailed error if failed
|
||||||
"details": {} # Channel-specific details
|
"details": {} # Channel-specific details
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
# Build channel config from request
|
# Build channel config from request
|
||||||
channel_config = {"type": body.type}
|
channel_config = {"type": body.type}
|
||||||
|
|
||||||
if body.type == "mesh_broadcast":
|
if body.type == "mesh_broadcast":
|
||||||
channel_config["channel_index"] = body.channel_index or 0
|
channel_config["channel_index"] = body.channel_index or 0
|
||||||
elif body.type == "mesh_dm":
|
elif body.type == "mesh_dm":
|
||||||
channel_config["node_ids"] = body.node_ids or []
|
channel_config["node_ids"] = body.node_ids or []
|
||||||
elif body.type == "email":
|
elif body.type == "email":
|
||||||
channel_config.update({
|
channel_config.update({
|
||||||
"smtp_host": body.smtp_host or "",
|
"smtp_host": body.smtp_host or "",
|
||||||
"smtp_port": body.smtp_port or 587,
|
"smtp_port": body.smtp_port or 587,
|
||||||
"smtp_user": body.smtp_user or "",
|
"smtp_user": body.smtp_user or "",
|
||||||
"smtp_password": body.smtp_password or "",
|
"smtp_password": body.smtp_password or "",
|
||||||
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
|
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
|
||||||
"from_address": body.from_address or "",
|
"from_address": body.from_address or "",
|
||||||
"recipients": body.recipients or [],
|
"recipients": body.recipients or [],
|
||||||
})
|
})
|
||||||
elif body.type == "webhook":
|
elif body.type == "webhook":
|
||||||
channel_config.update({
|
channel_config.update({
|
||||||
"url": body.url or "",
|
"url": body.url or "",
|
||||||
"headers": body.headers or {},
|
"headers": body.headers or {},
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Unknown channel type",
|
"message": "Unknown channel type",
|
||||||
"error": f"Channel type '{body.type}' is not supported",
|
"error": f"Channel type '{body.type}' is not supported",
|
||||||
"details": {}
|
"details": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await notification_router.test_channel(channel_config)
|
result = await notification_router.test_channel(channel_config)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_index}/test")
|
@router.post("/rules/{rule_index}/test")
|
||||||
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
|
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
|
||||||
"""Test a notification rule against current conditions.
|
"""Test a notification rule against current conditions.
|
||||||
|
|
||||||
Returns comprehensive test result including:
|
Returns comprehensive test result including:
|
||||||
- Live data from relevant environmental feeds
|
- Live data from relevant environmental feeds
|
||||||
- Matching alerts (conditions that would fire)
|
- Matching alerts (conditions that would fire)
|
||||||
- Near-misses (filtered by severity threshold)
|
- Near-misses (filtered by severity threshold)
|
||||||
- Preview messages and delivery status
|
- Preview messages and delivery status
|
||||||
- Source health (which feeds are enabled)
|
- Source health (which feeds are enabled)
|
||||||
- Rule statistics (last fired, fire count)
|
- Rule statistics (last fired, fire count)
|
||||||
"""
|
"""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
health_engine = getattr(request.app.state, "health_engine", None)
|
health_engine = getattr(request.app.state, "health_engine", None)
|
||||||
|
|
||||||
action = body.action if body else "preview"
|
action = body.action if body else "preview"
|
||||||
send = body.send if body else False
|
send = body.send if body else False
|
||||||
|
|
||||||
# Legacy support
|
# Legacy support
|
||||||
if send and action == "preview":
|
if send and action == "preview":
|
||||||
action = "send_test"
|
action = "send_test"
|
||||||
|
|
||||||
result = await notification_router.test_rule_with_conditions(
|
result = await notification_router.test_rule_with_conditions(
|
||||||
rule_index,
|
rule_index,
|
||||||
alert_engine=alert_engine,
|
alert_engine=alert_engine,
|
||||||
env_store=env_store,
|
env_store=env_store,
|
||||||
health_engine=health_engine,
|
health_engine=health_engine,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_index}/preview")
|
@router.post("/rules/{rule_index}/preview")
|
||||||
async def preview_rule(request: Request, rule_index: int):
|
async def preview_rule(request: Request, rule_index: int):
|
||||||
"""Preview what a rule would match right now (without sending)."""
|
"""Preview what a rule would match right now (without sending)."""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
health_engine = getattr(request.app.state, "health_engine", None)
|
health_engine = getattr(request.app.state, "health_engine", None)
|
||||||
|
|
||||||
result = await notification_router.test_rule_with_conditions(
|
result = await notification_router.test_rule_with_conditions(
|
||||||
rule_index,
|
rule_index,
|
||||||
alert_engine=alert_engine,
|
alert_engine=alert_engine,
|
||||||
env_store=env_store,
|
env_store=env_store,
|
||||||
health_engine=health_engine,
|
health_engine=health_engine,
|
||||||
action="preview",
|
action="preview",
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/sources")
|
@router.post("/rules/sources")
|
||||||
async def get_rule_sources(request: Request, body: RuleSourcesRequest):
|
async def get_rule_sources(request: Request, body: RuleSourcesRequest):
|
||||||
"""Get data source health for a set of categories.
|
"""Get data source health for a set of categories.
|
||||||
|
|
||||||
Returns per-category source status:
|
Returns per-category source status:
|
||||||
{
|
{
|
||||||
"category_id": {
|
"category_id": {
|
||||||
"enabled": true/false,
|
"enabled": true/false,
|
||||||
"active_events": number,
|
"active_events": number,
|
||||||
"source": "nws"/"swpc"/etc,
|
"source": "nws"/"swpc"/etc,
|
||||||
"status": "ok"/"disabled"/"no_data"
|
"status": "ok"/"disabled"/"no_data"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
return notification_router.get_source_health(body.categories, env_store)
|
return notification_router.get_source_health(body.categories, env_store)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_index}/send-status")
|
@router.post("/rules/{rule_index}/send-status")
|
||||||
async def send_rule_status(request: Request, rule_index: int):
|
async def send_rule_status(request: Request, rule_index: int):
|
||||||
"""Send current conditions summary through a rule's channel.
|
"""Send current conditions summary through a rule's channel.
|
||||||
|
|
||||||
Formats current live data as a readable message and delivers
|
Formats current live data as a readable message and delivers
|
||||||
through the rule's configured channel with [STATUS] prefix.
|
through the rule's configured channel with [STATUS] prefix.
|
||||||
"""
|
"""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
health_engine = getattr(request.app.state, "health_engine", None)
|
health_engine = getattr(request.app.state, "health_engine", None)
|
||||||
|
|
||||||
result = await notification_router.test_rule_with_conditions(
|
result = await notification_router.test_rule_with_conditions(
|
||||||
rule_index,
|
rule_index,
|
||||||
alert_engine=alert_engine,
|
alert_engine=alert_engine,
|
||||||
env_store=env_store,
|
env_store=env_store,
|
||||||
health_engine=health_engine,
|
health_engine=health_engine,
|
||||||
action="send_status",
|
action="send_status",
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_index}/send-test")
|
@router.post("/rules/{rule_index}/send-test")
|
||||||
async def send_rule_test(request: Request, rule_index: int):
|
async def send_rule_test(request: Request, rule_index: int):
|
||||||
"""Send example alert message through a rule's channel.
|
"""Send example alert message through a rule's channel.
|
||||||
|
|
||||||
Sends the example_message from the rule's first category
|
Sends the example_message from the rule's first category
|
||||||
through the configured channel with [TEST] prefix.
|
through the configured channel with [TEST] prefix.
|
||||||
"""
|
"""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
health_engine = getattr(request.app.state, "health_engine", None)
|
health_engine = getattr(request.app.state, "health_engine", None)
|
||||||
|
|
||||||
result = await notification_router.test_rule_with_conditions(
|
result = await notification_router.test_rule_with_conditions(
|
||||||
rule_index,
|
rule_index,
|
||||||
alert_engine=alert_engine,
|
alert_engine=alert_engine,
|
||||||
env_store=env_store,
|
env_store=env_store,
|
||||||
health_engine=health_engine,
|
health_engine=health_engine,
|
||||||
action="send_test",
|
action="send_test",
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules/{rule_index}/send-live")
|
@router.post("/rules/{rule_index}/send-live")
|
||||||
async def send_rule_live(request: Request, rule_index: int):
|
async def send_rule_live(request: Request, rule_index: int):
|
||||||
"""Send actual live alert through a rule's channel.
|
"""Send actual live alert through a rule's channel.
|
||||||
|
|
||||||
Only available when there are matching conditions.
|
Only available when there are matching conditions.
|
||||||
Sends one of the actual matching alerts with [LIVE TEST] prefix.
|
Sends one of the actual matching alerts with [LIVE TEST] prefix.
|
||||||
"""
|
"""
|
||||||
notification_router = getattr(request.app.state, "notification_router", None)
|
notification_router = getattr(request.app.state, "notification_router", None)
|
||||||
if not notification_router:
|
if not notification_router:
|
||||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||||
|
|
||||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
health_engine = getattr(request.app.state, "health_engine", None)
|
health_engine = getattr(request.app.state, "health_engine", None)
|
||||||
|
|
||||||
result = await notification_router.test_rule_with_conditions(
|
result = await notification_router.test_rule_with_conditions(
|
||||||
rule_index,
|
rule_index,
|
||||||
alert_engine=alert_engine,
|
alert_engine=alert_engine,
|
||||||
env_store=env_store,
|
env_store=env_store,
|
||||||
health_engine=health_engine,
|
health_engine=health_engine,
|
||||||
action="send_live",
|
action="send_live",
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
523
meshai/dashboard/static/assets/index-Bildyb1E.js
Normal file
523
meshai/dashboard/static/assets/index-Bildyb1E.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-QhNRb-ap.css
Normal file
1
meshai/dashboard/static/assets/index-QhNRb-ap.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -8,8 +8,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-BXyt_EfK.js"></script>
|
<script type="module" crossorigin src="/assets/index-Bildyb1E.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-QhNRb-ap.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
109
meshai/main.py
109
meshai/main.py
|
|
@ -265,6 +265,7 @@ class MeshAI:
|
||||||
self.data_store = MeshDataStore(
|
self.data_store = MeshDataStore(
|
||||||
source_configs=enabled_sources,
|
source_configs=enabled_sources,
|
||||||
db_path="/data/mesh_history.db",
|
db_path="/data/mesh_history.db",
|
||||||
|
offline_threshold_hours=self.config.mesh_intelligence.offline_threshold_hours,
|
||||||
)
|
)
|
||||||
# Initial fetch and backfill
|
# Initial fetch and backfill
|
||||||
self.data_store.force_refresh()
|
self.data_store.force_refresh()
|
||||||
|
|
@ -338,18 +339,18 @@ class MeshAI:
|
||||||
)
|
)
|
||||||
logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})")
|
logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})")
|
||||||
|
|
||||||
|
|
||||||
# Notification router
|
# Notification router
|
||||||
if self.config.notifications.enabled:
|
if self.config.notifications.enabled:
|
||||||
from .notifications.router import NotificationRouter
|
from .notifications.router import NotificationRouter
|
||||||
self.notification_router = NotificationRouter(
|
self.notification_router = NotificationRouter(
|
||||||
config=self.config.notifications,
|
config=self.config.notifications,
|
||||||
connector=self.connector,
|
connector=self.connector,
|
||||||
llm_backend=self.llm,
|
llm_backend=self.llm,
|
||||||
timezone=self.config.timezone,
|
timezone=self.config.timezone,
|
||||||
)
|
)
|
||||||
logger.info("Notification router initialized")
|
logger.info("Notification router initialized")
|
||||||
|
|
||||||
# Environmental feeds
|
# Environmental feeds
|
||||||
env_cfg = self.config.environmental
|
env_cfg = self.config.environmental
|
||||||
if env_cfg.enabled:
|
if env_cfg.enabled:
|
||||||
|
|
@ -554,48 +555,48 @@ class MeshAI:
|
||||||
if pid_file.exists():
|
if pid_file.exists():
|
||||||
pid_file.unlink()
|
pid_file.unlink()
|
||||||
|
|
||||||
async def _dispatch_alerts(self, alerts: list[dict]) -> None:
|
async def _dispatch_alerts(self, alerts: list[dict]) -> None:
|
||||||
"""Dispatch alerts to subscribers and alert channel."""
|
"""Dispatch alerts to subscribers and alert channel."""
|
||||||
mi = self.config.mesh_intelligence
|
mi = self.config.mesh_intelligence
|
||||||
alert_channel = getattr(mi, 'alert_channel', -1)
|
alert_channel = getattr(mi, 'alert_channel', -1)
|
||||||
|
|
||||||
for alert in alerts:
|
for alert in alerts:
|
||||||
message = alert["message"]
|
message = alert["message"]
|
||||||
logger.info(f"ALERT: {message}")
|
logger.info(f"ALERT: {message}")
|
||||||
|
|
||||||
# Route through notification router if enabled
|
# Route through notification router if enabled
|
||||||
if self.notification_router:
|
if self.notification_router:
|
||||||
try:
|
try:
|
||||||
await self.notification_router.process_alert(alert)
|
await self.notification_router.process_alert(alert)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Notification router error: {e}")
|
logger.error(f"Notification router error: {e}")
|
||||||
|
|
||||||
# Fallback: Send to alert channel if no notification router
|
# Fallback: Send to alert channel if no notification router
|
||||||
elif alert_channel >= 0 and self.connector:
|
elif alert_channel >= 0 and self.connector:
|
||||||
try:
|
try:
|
||||||
self.connector.send_message(
|
self.connector.send_message(
|
||||||
text=message,
|
text=message,
|
||||||
destination=None,
|
destination=None,
|
||||||
channel=alert_channel,
|
channel=alert_channel,
|
||||||
)
|
)
|
||||||
logger.info(f"Alert sent to channel {alert_channel}")
|
logger.info(f"Alert sent to channel {alert_channel}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send channel alert: {e}")
|
logger.error(f"Failed to send channel alert: {e}")
|
||||||
|
|
||||||
# Fallback: Send DMs to matching subscribers
|
# Fallback: Send DMs to matching subscribers
|
||||||
if self.alert_engine and self.subscription_manager:
|
if self.alert_engine and self.subscription_manager:
|
||||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
||||||
for sub in subscribers:
|
for sub in subscribers:
|
||||||
user_id = sub["user_id"]
|
user_id = sub["user_id"]
|
||||||
try:
|
try:
|
||||||
await self._send_sub_dm(user_id, message)
|
await self._send_sub_dm(user_id, message)
|
||||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
||||||
|
|
||||||
if self.alert_engine:
|
if self.alert_engine:
|
||||||
self.alert_engine.clear_pending()
|
self.alert_engine.clear_pending()
|
||||||
|
|
||||||
async def _check_scheduled_subs(self) -> None:
|
async def _check_scheduled_subs(self) -> None:
|
||||||
"""Check for and deliver due scheduled reports."""
|
"""Check for and deliver due scheduled reports."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
|
||||||
|
|
@ -230,16 +230,19 @@ class MeshDataStore:
|
||||||
self,
|
self,
|
||||||
source_configs: list[MeshSourceConfig],
|
source_configs: list[MeshSourceConfig],
|
||||||
db_path: str = "/data/mesh_history.db",
|
db_path: str = "/data/mesh_history.db",
|
||||||
|
offline_threshold_hours: int = 2,
|
||||||
):
|
):
|
||||||
"""Initialize the data store.
|
"""Initialize the data store.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
source_configs: List of source configurations
|
source_configs: List of source configurations
|
||||||
db_path: Path to SQLite database for historical data
|
db_path: Path to SQLite database for historical data
|
||||||
|
offline_threshold_hours: Hours before a node is considered offline
|
||||||
"""
|
"""
|
||||||
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
|
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._db: Optional[sqlite3.Connection] = None
|
self._db: Optional[sqlite3.Connection] = None
|
||||||
|
self._offline_threshold_hours = offline_threshold_hours
|
||||||
|
|
||||||
# Live state
|
# Live state
|
||||||
self._nodes: dict[int, UnifiedNode] = {}
|
self._nodes: dict[int, UnifiedNode] = {}
|
||||||
|
|
@ -745,11 +748,13 @@ class MeshDataStore:
|
||||||
|
|
||||||
node.last_heard = ts or 0.0
|
node.last_heard = ts or 0.0
|
||||||
|
|
||||||
# NOTE: is_online is set by MeshHealthEngine.compute() using the
|
# Compute is_online based on configured threshold
|
||||||
# configured offline_threshold_hours. Don't set it here with a
|
# This ensures correct status immediately, before health engine runs
|
||||||
# hardcoded value - let the health engine determine online status.
|
if node.last_heard:
|
||||||
# The health engine runs on every refresh cycle and will set is_online
|
offline_threshold = time.time() - (self._offline_threshold_hours * 3600)
|
||||||
# based on: (now - last_heard) < (offline_threshold_hours * 3600)
|
node.is_online = node.last_heard > offline_threshold
|
||||||
|
else:
|
||||||
|
node.is_online = False
|
||||||
|
|
||||||
# Hops, SNR, RSSI (MM)
|
# Hops, SNR, RSSI (MM)
|
||||||
node.hops_away = raw.get("hopsAway")
|
node.hops_away = raw.get("hopsAway")
|
||||||
|
|
@ -2111,7 +2116,7 @@ class MeshDataStore:
|
||||||
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
|
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
|
||||||
return [n for n in self._nodes.values() if n.role in infra_roles]
|
return [n for n in self._nodes.values() if n.role in infra_roles]
|
||||||
|
|
||||||
def get_low_battery_nodes(self, threshold: float = 20.0) -> list[UnifiedNode]:
|
def get_low_battery_nodes(self, threshold: float = 30.0) -> list[UnifiedNode]:
|
||||||
"""Get nodes with low battery."""
|
"""Get nodes with low battery."""
|
||||||
return [
|
return [
|
||||||
n
|
n
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
|
||||||
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
|
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
|
||||||
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
|
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
|
||||||
DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg)
|
DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg)
|
||||||
|
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
|
||||||
# NOTE: This is aligned with notification config's packet_flood threshold.
|
# NOTE: This is aligned with notification config's packet_flood threshold.
|
||||||
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
|
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
|
||||||
# A node averaging 5+ non-text packets/min is misbehaving.
|
# A node averaging 5+ non-text packets/min is misbehaving.
|
||||||
|
|
|
||||||
|
|
@ -630,7 +630,7 @@ class MeshReporter:
|
||||||
usb += 1
|
usb += 1
|
||||||
elif node.battery_percent >= 50:
|
elif node.battery_percent >= 50:
|
||||||
ok += 1
|
ok += 1
|
||||||
elif node.battery_percent >= 20:
|
elif node.battery_percent >= 30:
|
||||||
low += 1
|
low += 1
|
||||||
else:
|
else:
|
||||||
critical += 1
|
critical += 1
|
||||||
|
|
|
||||||
|
|
@ -1,242 +1,242 @@
|
||||||
"""Alert category registry.
|
"""Alert category registry.
|
||||||
|
|
||||||
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):
|
Severity levels (military/intelligence precedence):
|
||||||
routine - Informational, no time pressure
|
routine - Informational, no time pressure
|
||||||
priority - Needs attention soon
|
priority - Needs attention soon
|
||||||
immediate - Act now, drop everything
|
immediate - Act now, drop everything
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALERT_CATEGORIES = {
|
ALERT_CATEGORIES = {
|
||||||
# Infrastructure alerts
|
# Infrastructure alerts
|
||||||
"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": "priority",
|
"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": "immediate",
|
"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": "routine",
|
"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": "routine",
|
"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",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Power alerts
|
# Power alerts
|
||||||
"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": "routine",
|
"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": "priority",
|
"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": "immediate",
|
"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": "routine",
|
"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": "priority",
|
"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": "priority",
|
"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)",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Utilization alerts
|
# Utilization alerts
|
||||||
"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": "routine",
|
"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": "priority",
|
"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": "priority",
|
"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?",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Coverage alerts
|
# Coverage alerts
|
||||||
"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": "priority",
|
"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": "priority",
|
"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": "immediate",
|
"default_severity": "immediate",
|
||||||
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Health score alerts
|
# Health score alerts
|
||||||
"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": "priority",
|
"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": "priority",
|
"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.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Weather
|
# Environmental - Weather
|
||||||
"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": "priority",
|
"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",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Space Weather
|
# Environmental - Space Weather
|
||||||
"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": "priority",
|
"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": "priority",
|
"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°.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Tropospheric
|
# Environmental - Tropospheric
|
||||||
"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": "routine",
|
"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.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Fire
|
# Environmental - Fire
|
||||||
"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": "priority",
|
"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": "priority",
|
"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": "priority",
|
"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.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Flood
|
# Environmental - Flood
|
||||||
"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": "priority",
|
"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": "routine",
|
"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.",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Roads
|
# Environmental - Roads
|
||||||
"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": "priority",
|
"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": "routine",
|
"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",
|
||||||
},
|
},
|
||||||
|
|
||||||
# Environmental - Avalanche
|
# Environmental - Avalanche
|
||||||
"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": "priority",
|
"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": "routine",
|
"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.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_category(category_id: str) -> dict:
|
def get_category(category_id: str) -> dict:
|
||||||
"""Get category info by ID, with fallback for unknown categories."""
|
"""Get category info by ID, with fallback for unknown categories."""
|
||||||
if category_id in ALERT_CATEGORIES:
|
if category_id in ALERT_CATEGORIES:
|
||||||
return ALERT_CATEGORIES[category_id]
|
return ALERT_CATEGORIES[category_id]
|
||||||
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": "routine",
|
"default_severity": "routine",
|
||||||
"example_message": f"Alert: {category_id}",
|
"example_message": f"Alert: {category_id}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_categories() -> list[dict]:
|
def list_categories() -> list[dict]:
|
||||||
"""List all categories with their IDs."""
|
"""List all categories with their IDs."""
|
||||||
return [
|
return [
|
||||||
{"id": cat_id, **cat_info}
|
{"id": cat_id, **cat_info}
|
||||||
for cat_id, cat_info in ALERT_CATEGORIES.items()
|
for cat_id, cat_info in ALERT_CATEGORIES.items()
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ class EmailChannel(NotificationChannel):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
alert_type = alert.get("type", "alert")
|
alert_type = alert.get("type", "alert")
|
||||||
severity = alert.get("severity", "info").upper()
|
severity = alert.get("severity", "routine").upper()
|
||||||
message = alert.get("message", "")
|
message = alert.get("message", "")
|
||||||
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
|
||||||
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
|
||||||
|
|
@ -518,7 +518,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
"""POST alert to webhook URL."""
|
"""POST alert to webhook URL."""
|
||||||
payload = {
|
payload = {
|
||||||
"type": alert.get("type"),
|
"type": alert.get("type"),
|
||||||
"severity": alert.get("severity", "info"),
|
"severity": alert.get("severity", "routine"),
|
||||||
"message": alert.get("message", ""),
|
"message": alert.get("message", ""),
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"node_name": alert.get("node_name"),
|
"node_name": alert.get("node_name"),
|
||||||
|
|
@ -527,7 +527,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
|
|
||||||
# Discord/Slack format
|
# Discord/Slack format
|
||||||
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", "routine")
|
||||||
color = {
|
color = {
|
||||||
"immediate": 0xFF0000,
|
"immediate": 0xFF0000,
|
||||||
"priority": 0xFFAA00,
|
"priority": 0xFFAA00,
|
||||||
|
|
@ -669,7 +669,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
else:
|
else:
|
||||||
payload = {
|
payload = {
|
||||||
"type": "test",
|
"type": "test",
|
||||||
"severity": "info",
|
"severity": "routine",
|
||||||
"message": "MeshAI channel connectivity test",
|
"message": "MeshAI channel connectivity test",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
@ -730,7 +730,7 @@ class WebhookChannel(NotificationChannel):
|
||||||
async def deliver_test(self, message: str) -> tuple[bool, str]:
|
async def deliver_test(self, message: str) -> tuple[bool, str]:
|
||||||
"""Deliver a specific test message via webhook."""
|
"""Deliver a specific test message via webhook."""
|
||||||
try:
|
try:
|
||||||
test_alert = {"type": "test", "severity": "info", "message": message}
|
test_alert = {"type": "test", "severity": "routine", "message": message}
|
||||||
success = await self.deliver(test_alert, {})
|
success = await self.deliver(test_alert, {})
|
||||||
if success:
|
if success:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class NotificationRouter:
|
||||||
self._timezone = timezone
|
self._timezone = timezone
|
||||||
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
||||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||||
|
self._llm = llm_backend
|
||||||
self._connector = connector
|
self._connector = connector
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
|
|
@ -149,7 +150,7 @@ class NotificationRouter:
|
||||||
async def process_alert(self, alert: dict) -> bool:
|
async def process_alert(self, alert: dict) -> bool:
|
||||||
"""Route an alert through matching rules."""
|
"""Route an alert through matching rules."""
|
||||||
category = alert.get("type", "")
|
category = alert.get("type", "")
|
||||||
severity = alert.get("severity", "info")
|
severity = alert.get("severity", "routine")
|
||||||
delivered = False
|
delivered = False
|
||||||
|
|
||||||
for rule in self._rules:
|
for rule in self._rules:
|
||||||
|
|
@ -159,7 +160,7 @@ class NotificationRouter:
|
||||||
if rule_categories and category not in rule_categories:
|
if rule_categories and category not in rule_categories:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
min_severity = rule.get("min_severity", "info")
|
min_severity = rule.get("min_severity", "routine")
|
||||||
if not self._severity_meets(severity, min_severity):
|
if not self._severity_meets(severity, min_severity):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -391,7 +392,7 @@ class NotificationRouter:
|
||||||
|
|
||||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||||
rule_categories = rule_dict.get("categories", [])
|
rule_categories = rule_dict.get("categories", [])
|
||||||
min_severity = rule_dict.get("min_severity", "info")
|
min_severity = rule_dict.get("min_severity", "routine")
|
||||||
delivery_type = rule_dict.get("delivery_type", "")
|
delivery_type = rule_dict.get("delivery_type", "")
|
||||||
|
|
||||||
# Legacy support
|
# Legacy support
|
||||||
|
|
@ -535,7 +536,7 @@ class NotificationRouter:
|
||||||
for alert in alert_engine.get_pending_alerts():
|
for alert in alert_engine.get_pending_alerts():
|
||||||
all_events.append({
|
all_events.append({
|
||||||
"type": alert.get("type", ""),
|
"type": alert.get("type", ""),
|
||||||
"severity": alert.get("severity", "info"),
|
"severity": alert.get("severity", "routine"),
|
||||||
"message": alert.get("message", ""),
|
"message": alert.get("message", ""),
|
||||||
"headline": alert.get("message", "")[:80],
|
"headline": alert.get("message", "")[:80],
|
||||||
})
|
})
|
||||||
|
|
@ -547,7 +548,7 @@ class NotificationRouter:
|
||||||
for event in env_store.get_active():
|
for event in env_store.get_active():
|
||||||
all_events.append({
|
all_events.append({
|
||||||
"type": event.get("type", event.get("category", "")),
|
"type": event.get("type", event.get("category", "")),
|
||||||
"severity": event.get("severity", "info"),
|
"severity": event.get("severity", "routine"),
|
||||||
"message": event.get("message", event.get("headline", str(event))),
|
"message": event.get("message", event.get("headline", str(event))),
|
||||||
"headline": event.get("headline", event.get("message", "Event"))[:80],
|
"headline": event.get("headline", event.get("message", "Event"))[:80],
|
||||||
})
|
})
|
||||||
|
|
@ -624,12 +625,20 @@ class NotificationRouter:
|
||||||
channel = self._create_channel_for_rule(rule_dict)
|
channel = self._create_channel_for_rule(rule_dict)
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
if action == "send_status" and live_data_lines:
|
if action == "send_status":
|
||||||
# Filter out the warning line for status message
|
# Determine report type from rule categories
|
||||||
data_lines = [l for l in live_data_lines if not l.startswith("[!]")]
|
report_type = "all"
|
||||||
status_msg = "[STATUS] " + " | ".join(data_lines[:4])
|
if rule_categories:
|
||||||
if len(status_msg) > 200:
|
if any(c in rule_categories for c in ["hf_blackout", "geomagnetic_storm", "tropospheric_ducting"]):
|
||||||
status_msg = status_msg[:195] + "..."
|
report_type = "rf_propagation"
|
||||||
|
elif any(c in rule_categories for c in ["infra_offline", "critical_node_down", "mesh_score_low", "battery_warning"]):
|
||||||
|
report_type = "mesh_health"
|
||||||
|
elif any(c in rule_categories for c in ["weather_warning", "fire_proximity", "new_ignition"]):
|
||||||
|
report_type = "weather_fire"
|
||||||
|
|
||||||
|
status_msg = await self.generate_report(report_type, env_store, health_engine)
|
||||||
|
if len(status_msg) > 195:
|
||||||
|
status_msg = status_msg[:192] + "..."
|
||||||
success, result = await channel.deliver_test(status_msg)
|
success, result = await channel.deliver_test(status_msg)
|
||||||
delivered = success
|
delivered = success
|
||||||
delivery_result = result if success else f"Failed: {result}"
|
delivery_result = result if success else f"Failed: {result}"
|
||||||
|
|
@ -637,9 +646,9 @@ class NotificationRouter:
|
||||||
delivery_error = result
|
delivery_error = result
|
||||||
|
|
||||||
elif action == "send_live" and matching_alerts:
|
elif action == "send_live" and matching_alerts:
|
||||||
live_msg = f"[LIVE TEST] {matching_alerts[0].get('message', '')}"
|
live_msg = matching_alerts[0].get('message', '')
|
||||||
if len(live_msg) > 200:
|
if len(live_msg) > 195:
|
||||||
live_msg = live_msg[:195] + "..."
|
live_msg = live_msg[:192] + "..."
|
||||||
success, result = await channel.deliver_test(live_msg)
|
success, result = await channel.deliver_test(live_msg)
|
||||||
delivered = success
|
delivered = success
|
||||||
delivery_result = result if success else f"Failed: {result}"
|
delivery_result = result if success else f"Failed: {result}"
|
||||||
|
|
@ -752,7 +761,7 @@ class NotificationRouter:
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"trigger_type": "condition",
|
"trigger_type": "condition",
|
||||||
"categories": categories if categories else [],
|
"categories": categories if categories else [],
|
||||||
"min_severity": "warning",
|
"min_severity": "priority",
|
||||||
"delivery_type": "mesh_dm",
|
"delivery_type": "mesh_dm",
|
||||||
"node_ids": [node_id],
|
"node_ids": [node_id],
|
||||||
"cooldown_minutes": 10,
|
"cooldown_minutes": 10,
|
||||||
|
|
@ -776,6 +785,264 @@ class NotificationRouter:
|
||||||
return categories if categories else ["all"]
|
return categories if categories else ["all"]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
|
||||||
|
"""Generate an LLM-summarized report from current data."""
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# For RF propagation, use deterministic formatter
|
||||||
|
swpc_data = None
|
||||||
|
ducting_data = None
|
||||||
|
|
||||||
|
if report_type in ("rf_propagation", "all"):
|
||||||
|
if env_store:
|
||||||
|
adapters = getattr(env_store, '_adapters', {})
|
||||||
|
if "swpc" in adapters and hasattr(env_store, 'get_swpc_status'):
|
||||||
|
swpc_data = env_store.get_swpc_status()
|
||||||
|
if "ducting" in adapters and hasattr(env_store, 'get_ducting_status'):
|
||||||
|
ducting_data = env_store.get_ducting_status()
|
||||||
|
|
||||||
|
# If this is an RF-only report, return deterministic format immediately
|
||||||
|
if report_type == "rf_propagation" and swpc_data:
|
||||||
|
sfi = swpc_data.get('sfi', 100)
|
||||||
|
kp = swpc_data.get('kp_current', 3)
|
||||||
|
try:
|
||||||
|
band_conditions = self._compute_band_conditions(float(sfi), float(kp))
|
||||||
|
return self._format_propagation_report(swpc_data, ducting_data, band_conditions)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Band condition calc failed: %s", e)
|
||||||
|
# Fall through to context-based approach
|
||||||
|
|
||||||
|
# For "all" report type, add RF data to context
|
||||||
|
if swpc_data:
|
||||||
|
context_parts.append(
|
||||||
|
f"Solar/Geomagnetic: SFI {swpc_data.get('sfi')}, "
|
||||||
|
f"Kp {swpc_data.get('kp_current')}, "
|
||||||
|
f"R{swpc_data.get('r_scale', 0)}/S{swpc_data.get('s_scale', 0)}/G{swpc_data.get('g_scale', 0)}"
|
||||||
|
)
|
||||||
|
if ducting_data:
|
||||||
|
context_parts.append(
|
||||||
|
f"Tropospheric: {ducting_data.get('condition', 'unknown')}, "
|
||||||
|
f"dM/dz {ducting_data.get('min_gradient', 'N/A')} M-units/km"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report_type in ("mesh_health", "all"):
|
||||||
|
if health_engine:
|
||||||
|
health = getattr(health_engine, 'mesh_health', None)
|
||||||
|
if health and hasattr(health, 'score'):
|
||||||
|
score = health.score
|
||||||
|
context_parts.append(
|
||||||
|
f"Mesh: score {score.composite:.0f}/100, "
|
||||||
|
f"tier {score.tier}, "
|
||||||
|
f"{score.infra_online}/{score.infra_total} infra online, "
|
||||||
|
f"utilization {score.util_percent:.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
if report_type in ("weather_fire", "all"):
|
||||||
|
if env_store and hasattr(env_store, 'get_active'):
|
||||||
|
nws = env_store.get_active(source="nws")
|
||||||
|
fires = env_store.get_active(source="nifc")
|
||||||
|
if nws:
|
||||||
|
headlines = [e.get("headline", "")[:80] for e in nws[:3]]
|
||||||
|
context_parts.append(f"Weather: {len(nws)} active alerts: {'; '.join(headlines)}")
|
||||||
|
else:
|
||||||
|
context_parts.append("Weather: No active alerts")
|
||||||
|
if fires:
|
||||||
|
context_parts.append(f"Fires: {len(fires)} active")
|
||||||
|
else:
|
||||||
|
context_parts.append("Fires: None active")
|
||||||
|
|
||||||
|
if report_type in ("environmental", "all"):
|
||||||
|
if env_store and hasattr(env_store, 'get_active'):
|
||||||
|
for source in ["usgs", "traffic", "roads511", "avalanche"]:
|
||||||
|
events = env_store.get_active(source=source)
|
||||||
|
if events:
|
||||||
|
context_parts.append(f"{source.upper()}: {len(events)} events")
|
||||||
|
|
||||||
|
if not context_parts:
|
||||||
|
# Return a graceful message for the specific report type
|
||||||
|
no_data_messages = {
|
||||||
|
"rf_propagation": "RF propagation data not available",
|
||||||
|
"mesh_health": "Mesh health data not available",
|
||||||
|
"weather_fire": "Weather/fire monitoring not configured",
|
||||||
|
"environmental": "Environmental monitoring not configured",
|
||||||
|
"all": "No monitoring data available",
|
||||||
|
}
|
||||||
|
return no_data_messages.get(report_type, "No data available")
|
||||||
|
|
||||||
|
raw_data = "\n".join(context_parts)
|
||||||
|
|
||||||
|
# Generate LLM summary
|
||||||
|
if self._llm:
|
||||||
|
prompt = self._build_report_prompt(report_type, raw_data)
|
||||||
|
try:
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
summary = await self._llm.generate(
|
||||||
|
messages=messages,
|
||||||
|
system_prompt="You are a concise infrastructure status reporter. Format data clearly and briefly. Output only the formatted report, no preamble or explanation.",
|
||||||
|
|
||||||
|
)
|
||||||
|
return summary.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("LLM report generation failed: %s", e)
|
||||||
|
return raw_data
|
||||||
|
else:
|
||||||
|
return raw_data
|
||||||
|
|
||||||
|
def _compute_band_conditions(self, sfi: float, kp: float) -> dict:
|
||||||
|
"""Deterministic band conditions from SFI and Kp."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
hour_utc = datetime.now(timezone.utc).hour
|
||||||
|
is_day = 12 <= hour_utc or hour_utc <= 3 # rough UTC daytime for US
|
||||||
|
|
||||||
|
bands = {}
|
||||||
|
|
||||||
|
# 10m (28 MHz) - needs high SFI, daytime only
|
||||||
|
if sfi > 140 and kp <= 2:
|
||||||
|
bands["10m"] = "Good"
|
||||||
|
elif sfi > 100 and kp <= 4 and is_day:
|
||||||
|
bands["10m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["10m"] = "Poor"
|
||||||
|
|
||||||
|
# 12m (24 MHz)
|
||||||
|
if sfi > 120 and kp <= 3:
|
||||||
|
bands["12m"] = "Good"
|
||||||
|
elif sfi > 90 and kp <= 4 and is_day:
|
||||||
|
bands["12m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["12m"] = "Poor"
|
||||||
|
|
||||||
|
# 15m (21 MHz)
|
||||||
|
if sfi > 100 and kp <= 3:
|
||||||
|
bands["15m"] = "Good"
|
||||||
|
elif sfi > 80 and kp <= 5:
|
||||||
|
bands["15m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["15m"] = "Poor"
|
||||||
|
|
||||||
|
# 17m (18 MHz)
|
||||||
|
if sfi > 90 and kp <= 3:
|
||||||
|
bands["17m"] = "Good"
|
||||||
|
elif sfi > 70 and kp <= 5:
|
||||||
|
bands["17m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["17m"] = "Poor"
|
||||||
|
|
||||||
|
# 20m (14 MHz) - almost always usable
|
||||||
|
if sfi > 80 and kp <= 3:
|
||||||
|
bands["20m"] = "Good"
|
||||||
|
elif kp <= 6:
|
||||||
|
bands["20m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["20m"] = "Poor"
|
||||||
|
|
||||||
|
# 30m (10 MHz) - very reliable
|
||||||
|
if kp <= 4:
|
||||||
|
bands["30m"] = "Good"
|
||||||
|
elif kp <= 6:
|
||||||
|
bands["30m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["30m"] = "Poor"
|
||||||
|
|
||||||
|
# 40m (7 MHz) - reliable, better at night
|
||||||
|
if kp <= 4:
|
||||||
|
bands["40m"] = "Good"
|
||||||
|
elif kp <= 6:
|
||||||
|
bands["40m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["40m"] = "Poor"
|
||||||
|
|
||||||
|
# 80m (3.5 MHz) - night band
|
||||||
|
if kp <= 3:
|
||||||
|
bands["80m"] = "Good"
|
||||||
|
elif kp <= 5:
|
||||||
|
bands["80m"] = "Fair"
|
||||||
|
else:
|
||||||
|
bands["80m"] = "Poor"
|
||||||
|
|
||||||
|
return bands
|
||||||
|
|
||||||
|
def _format_propagation_report(self, swpc: dict, ducting: dict, band_conditions: dict) -> str:
|
||||||
|
"""Format propagation report deterministically."""
|
||||||
|
sfi = swpc.get('sfi', 'N/A')
|
||||||
|
kp = swpc.get('kp_current', 'N/A')
|
||||||
|
|
||||||
|
lines = [f"Band Conditions (SFI {sfi}, Kp {kp}):"]
|
||||||
|
|
||||||
|
for band_list, label in [
|
||||||
|
(["80m", "40m"], "80-40m"),
|
||||||
|
(["30m", "20m"], "30-20m"),
|
||||||
|
(["17m", "15m"], "17-15m"),
|
||||||
|
(["12m", "10m"], "12-10m"),
|
||||||
|
]:
|
||||||
|
# Take the better of the two bands in range
|
||||||
|
ratings = [band_conditions.get(b, "Poor") for b in band_list]
|
||||||
|
if "Good" in ratings:
|
||||||
|
rating = "Good"
|
||||||
|
elif "Fair" in ratings:
|
||||||
|
rating = "Fair"
|
||||||
|
else:
|
||||||
|
rating = "Poor"
|
||||||
|
lines.append(f"{label}: {rating}")
|
||||||
|
|
||||||
|
if ducting and ducting.get("condition") and ducting.get("condition") != "normal":
|
||||||
|
cond = ducting["condition"].replace("_", " ").title()
|
||||||
|
lines.append(f"Tropo: {cond}")
|
||||||
|
else:
|
||||||
|
lines.append("Tropo: Normal")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _build_report_prompt(self, report_type: str, raw_data: str) -> str:
|
||||||
|
"""Build the LLM prompt for report generation."""
|
||||||
|
prompts = {
|
||||||
|
"rf_propagation": (
|
||||||
|
"Format this propagation data as a band-by-band HF report. "
|
||||||
|
"Output format:\n"
|
||||||
|
"Band Conditions (SFI X, Kp Y):\n"
|
||||||
|
"80-40m: [Good/Fair/Poor]\n"
|
||||||
|
"30-20m: [Good/Fair/Poor]\n"
|
||||||
|
"17-15m: [Good/Fair/Poor]\n"
|
||||||
|
"12-10m: [Good/Fair/Poor]\n"
|
||||||
|
"Tropo: [Normal/Enhanced/Ducting]\n\n"
|
||||||
|
"Determine ratings from SFI and Kp values. "
|
||||||
|
"Output ONLY the formatted report.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"mesh_health": (
|
||||||
|
"Format this mesh network health data as a brief status. "
|
||||||
|
"Include: score out of 100, tier name, infrastructure count, "
|
||||||
|
"and any problems. If healthy, say 'Mesh healthy' with key stats. "
|
||||||
|
"Example: 'Mesh healthy: 90/100, 16/16 infra online, 20% util'\n"
|
||||||
|
"Keep it concise. Output ONLY the status line.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"weather_fire": (
|
||||||
|
"Format these weather/fire alerts as a brief summary. "
|
||||||
|
"List count of alerts and most severe conditions. "
|
||||||
|
"Example: '3 weather alerts: Winter Storm Warning (ID), "
|
||||||
|
"Wind Advisory (OR). No active fires.'\n"
|
||||||
|
"Keep it concise. Output ONLY the summary.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"environmental": (
|
||||||
|
"Summarize all environmental conditions briefly. "
|
||||||
|
"Cover weather alerts, fires, streams, roads. "
|
||||||
|
"Example: 'Weather clear, no fires, 2 streams elevated, "
|
||||||
|
"roads open.'\n"
|
||||||
|
"Keep it concise. Output ONLY the summary.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
"all": (
|
||||||
|
"Summarize all conditions for a mesh network operator. "
|
||||||
|
"Cover: mesh health score, any infrastructure issues, "
|
||||||
|
"weather alerts, fire status, propagation conditions. "
|
||||||
|
"Be brief but complete.\n\n"
|
||||||
|
f"Data:\n{raw_data}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return prompts.get(report_type, prompts["all"])
|
||||||
|
|
||||||
def cleanup_recent(self, max_age: int = 3600):
|
def cleanup_recent(self, max_age: int = 3600):
|
||||||
"""Clean up old entries from recent alerts cache."""
|
"""Clean up old entries from recent alerts cache."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,64 @@
|
||||||
"""Message summarizer for mesh delivery."""
|
"""Message summarizer for mesh delivery."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..backends import LLMBackend
|
from ..backends import LLMBackend
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MessageSummarizer:
|
class MessageSummarizer:
|
||||||
"""Summarizes long messages for mesh delivery.
|
"""Summarizes long messages for mesh delivery.
|
||||||
|
|
||||||
Only used when:
|
Only used when:
|
||||||
- Delivering to mesh channels (broadcast or DM)
|
- Delivering to mesh channels (broadcast or DM)
|
||||||
- Message exceeds max_chars (default 200)
|
- Message exceeds max_chars (default 200)
|
||||||
- LLM backend is available
|
- LLM backend is available
|
||||||
|
|
||||||
Email and webhook channels receive full messages.
|
Email and webhook channels receive full messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, llm_backend: Optional["LLMBackend"] = None):
|
def __init__(self, llm_backend: Optional["LLMBackend"] = None):
|
||||||
self._llm = llm_backend
|
self._llm = llm_backend
|
||||||
|
|
||||||
async def summarize(self, message: str, max_chars: int = 195) -> str:
|
async def summarize(self, message: str, max_chars: int = 195) -> str:
|
||||||
"""Summarize a message to fit within max_chars.
|
"""Summarize a message to fit within max_chars.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message: Original message text
|
message: Original message text
|
||||||
max_chars: Maximum characters for summary
|
max_chars: Maximum characters for summary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Summarized message, or truncated original if LLM unavailable
|
Summarized message, or truncated original if LLM unavailable
|
||||||
"""
|
"""
|
||||||
if len(message) <= max_chars:
|
if len(message) <= max_chars:
|
||||||
return message
|
return message
|
||||||
|
|
||||||
if not self._llm:
|
if not self._llm:
|
||||||
return message[:max_chars - 3] + "..."
|
return message[:max_chars - 3] + "..."
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
"Summarize this alert in under %d characters. "
|
"Summarize this alert in under %d characters. "
|
||||||
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
|
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
|
||||||
% (max_chars, message)
|
% (max_chars, message)
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use the LLM to generate a summary
|
# Use the LLM to generate a summary
|
||||||
response = await self._llm.generate(
|
response = await self._llm.generate(
|
||||||
prompt,
|
prompt,
|
||||||
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
|
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
|
||||||
max_tokens=100,
|
|
||||||
)
|
)
|
||||||
summary = response.strip()
|
summary = response.strip()
|
||||||
|
|
||||||
# Ensure it fits
|
# Ensure it fits
|
||||||
if len(summary) <= max_chars:
|
if len(summary) <= max_chars:
|
||||||
return summary
|
return summary
|
||||||
return summary[:max_chars - 3] + "..."
|
return summary[:max_chars - 3] + "..."
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("LLM summarization failed: %s", e)
|
logger.debug("LLM summarization failed: %s", e)
|
||||||
return message[:max_chars - 3] + "..."
|
return message[:max_chars - 3] + "..."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue