mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +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
|
||||
# region_radius_miles: 40.0 # Radius for region 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
|
||||
# battery_warning_percent: 20 # Battery level for warnings
|
||||
# battery_warning_percent: 30 # Battery level for warnings
|
||||
# infra_overrides: [] # Node IDs to exclude from infrastructure
|
||||
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
|
||||
mesh_intelligence:
|
||||
enabled: false
|
||||
region_radius_miles: 40.0
|
||||
locality_radius_miles: 8.0
|
||||
offline_threshold_hours: 24
|
||||
offline_threshold_hours: 2
|
||||
packet_threshold: 500
|
||||
battery_warning_percent: 20
|
||||
battery_warning_percent: 30
|
||||
infra_overrides: []
|
||||
region_labels: {}
|
||||
|
||||
|
|
@ -217,11 +217,13 @@ environmental:
|
|||
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.
|
||||
# Categories match alert types from alert_engine.py.
|
||||
# Severity levels: info, advisory, watch, warning, critical, emergency
|
||||
#
|
||||
notifications:
|
||||
enabled: false
|
||||
quiet_hours_enabled: true # Master toggle for quiet hours feature
|
||||
|
|
@ -236,7 +238,7 @@ notifications:
|
|||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: [] # Empty = all categories
|
||||
min_severity: "emergency"
|
||||
min_severity: "immediate"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 5
|
||||
|
|
@ -247,7 +249,7 @@ notifications:
|
|||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["infra_offline", "critical_node_down"]
|
||||
min_severity: "warning"
|
||||
min_severity: "priority"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 30
|
||||
|
|
@ -258,7 +260,7 @@ notifications:
|
|||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["wildfire_proximity", "new_ignition"]
|
||||
min_severity: "advisory"
|
||||
min_severity: "routine"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 60
|
||||
|
|
@ -269,7 +271,7 @@ notifications:
|
|||
enabled: true
|
||||
trigger_type: condition
|
||||
categories: ["weather_warning"]
|
||||
min_severity: "warning"
|
||||
min_severity: "priority"
|
||||
delivery_type: mesh_broadcast
|
||||
broadcast_channel: 0
|
||||
cooldown_minutes: 30
|
||||
|
|
@ -280,7 +282,7 @@ notifications:
|
|||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["wildfire_proximity", "new_ignition"]
|
||||
# min_severity: "advisory"
|
||||
# min_severity: "routine"
|
||||
# delivery_type: email
|
||||
# smtp_host: "smtp.gmail.com"
|
||||
# smtp_port: 587
|
||||
|
|
@ -296,7 +298,7 @@ notifications:
|
|||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: []
|
||||
# min_severity: "warning"
|
||||
# min_severity: "priority"
|
||||
# delivery_type: webhook
|
||||
# webhook_url: "https://discord.com/api/webhooks/..."
|
||||
# cooldown_minutes: 10
|
||||
|
|
@ -316,7 +318,7 @@ notifications:
|
|||
# enabled: true
|
||||
# trigger_type: condition
|
||||
# categories: ["battery_warning"]
|
||||
# min_severity: "warning"
|
||||
# min_severity: "priority"
|
||||
# delivery_type: "" # Empty = no delivery, just tracks matches
|
||||
|
||||
# === WEB DASHBOARD ===
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
|
|||
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 Icon = sourceConfig.icon
|
||||
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' })
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||
{event.severity || 'info'}
|
||||
</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-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||
</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>
|
||||
)
|
||||
|
|
@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
|
|||
|
||||
// Live Event Feed Card
|
||||
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(() => {
|
||||
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])
|
||||
|
||||
// Calculate feed health summary
|
||||
|
|
@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
|
|||
{sortedEvents.length > 0 ? (
|
||||
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||
{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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ class MeshIntelligenceConfig:
|
|||
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
|
||||
offline_threshold_hours: int = 2 # Hours before node considered offline
|
||||
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
|
||||
|
||||
# Alert settings
|
||||
|
|
@ -577,7 +578,7 @@ def _migrate_legacy_channels(notifications, data: dict):
|
|||
enabled=ch.get("enabled", True),
|
||||
trigger_type="condition",
|
||||
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"),
|
||||
broadcast_channel=ch.get("channel_index", 0),
|
||||
node_ids=ch.get("node_ids", []),
|
||||
|
|
|
|||
|
|
@ -1,192 +1,210 @@
|
|||
"""Environmental data API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["environment"])
|
||||
|
||||
|
||||
@router.get("/env/status")
|
||||
async def get_env_status(request: Request):
|
||||
"""Get environmental feeds status."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False, "feeds": []}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"feeds": env_store.get_source_health(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/active")
|
||||
async def get_active_env(request: Request):
|
||||
"""Get active environmental events."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active()
|
||||
|
||||
|
||||
@router.get("/env/swpc")
|
||||
async def get_swpc_data(request: Request):
|
||||
"""Get SWPC space weather data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_swpc_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/propagation")
|
||||
async def get_rf_propagation(request: Request):
|
||||
"""Get combined HF + UHF propagation data for dashboard."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"hf": {}, "uhf_ducting": {}}
|
||||
|
||||
return env_store.get_rf_propagation()
|
||||
|
||||
|
||||
@router.get("/env/ducting")
|
||||
async def get_ducting_data(request: Request):
|
||||
"""Get tropospheric ducting assessment."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_ducting_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/fires")
|
||||
async def get_fires_data(request: Request):
|
||||
"""Get active wildfire perimeters."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="nifc")
|
||||
|
||||
|
||||
@router.get("/env/avalanche")
|
||||
async def get_avalanche_data(request: Request):
|
||||
"""Get avalanche advisories."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
avy_adapter = adapters.get("avalanche")
|
||||
|
||||
if avy_adapter and avy_adapter.is_off_season():
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
return {
|
||||
"off_season": False,
|
||||
"advisories": env_store.get_active(source="avalanche"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/streams")
|
||||
async def get_streams_data(request: Request):
|
||||
"""Get USGS stream gauge readings."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="usgs")
|
||||
|
||||
|
||||
@router.get("/env/usgs/lookup/{site_id}")
|
||||
async def lookup_usgs_site(request: Request, site_id: str):
|
||||
"""Lookup USGS site metadata and NWS flood stages.
|
||||
|
||||
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)
|
||||
|
||||
if not env_store:
|
||||
return {"error": "Environmental feeds not enabled"}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
usgs_adapter = adapters.get("usgs")
|
||||
|
||||
if not usgs_adapter:
|
||||
# Create a temporary adapter for lookup
|
||||
from meshai.env.usgs import USGSStreamsAdapter
|
||||
from meshai.config import USGSConfig
|
||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||
|
||||
try:
|
||||
result = usgs_adapter.lookup_site(site_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e), "site_id": site_id}
|
||||
|
||||
|
||||
@router.get("/env/traffic")
|
||||
async def get_traffic_data(request: Request):
|
||||
"""Get TomTom traffic flow data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="traffic")
|
||||
|
||||
|
||||
@router.get("/env/roads")
|
||||
async def get_roads_data(request: Request):
|
||||
"""Get 511 road conditions."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="511")
|
||||
|
||||
|
||||
@router.get("/env/hotspots")
|
||||
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),
|
||||
}
|
||||
"""Environmental data API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["environment"])
|
||||
|
||||
|
||||
@router.get("/env/status")
|
||||
async def get_env_status(request: Request):
|
||||
"""Get environmental feeds status."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False, "feeds": []}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"feeds": env_store.get_source_health(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/active")
|
||||
async def get_active_env(request: Request):
|
||||
"""Get active environmental events with local zone marking."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
events = env_store.get_active()
|
||||
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
|
||||
|
||||
# Dedup by event_id and add is_local field
|
||||
seen_ids = set()
|
||||
result = []
|
||||
for event in events:
|
||||
event_id = event.get("event_id")
|
||||
if event_id and event_id in seen_ids:
|
||||
continue
|
||||
if event_id:
|
||||
seen_ids.add(event_id)
|
||||
|
||||
# Mark as local if event zones overlap with configured mesh zones
|
||||
event_zones = set(event.get("areas", []))
|
||||
event["is_local"] = bool(event_zones & mesh_zones)
|
||||
result.append(event)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/env/swpc")
|
||||
async def get_swpc_data(request: Request):
|
||||
"""Get SWPC space weather data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_swpc_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/propagation")
|
||||
async def get_rf_propagation(request: Request):
|
||||
"""Get combined HF + UHF propagation data for dashboard."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"hf": {}, "uhf_ducting": {}}
|
||||
|
||||
return env_store.get_rf_propagation()
|
||||
|
||||
|
||||
@router.get("/env/ducting")
|
||||
async def get_ducting_data(request: Request):
|
||||
"""Get tropospheric ducting assessment."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"enabled": False}
|
||||
|
||||
status = env_store.get_ducting_status()
|
||||
if not status:
|
||||
return {"enabled": False}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
**status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/fires")
|
||||
async def get_fires_data(request: Request):
|
||||
"""Get active wildfire perimeters."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="nifc")
|
||||
|
||||
|
||||
@router.get("/env/avalanche")
|
||||
async def get_avalanche_data(request: Request):
|
||||
"""Get avalanche advisories."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
avy_adapter = adapters.get("avalanche")
|
||||
|
||||
if avy_adapter and avy_adapter.is_off_season():
|
||||
return {"off_season": True, "advisories": []}
|
||||
|
||||
return {
|
||||
"off_season": False,
|
||||
"advisories": env_store.get_active(source="avalanche"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/env/streams")
|
||||
async def get_streams_data(request: Request):
|
||||
"""Get USGS stream gauge readings."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="usgs")
|
||||
|
||||
|
||||
@router.get("/env/usgs/lookup/{site_id}")
|
||||
async def lookup_usgs_site(request: Request, site_id: str):
|
||||
"""Lookup USGS site metadata and NWS flood stages.
|
||||
|
||||
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)
|
||||
|
||||
if not env_store:
|
||||
return {"error": "Environmental feeds not enabled"}
|
||||
|
||||
adapters = getattr(env_store, "_adapters", {})
|
||||
usgs_adapter = adapters.get("usgs")
|
||||
|
||||
if not usgs_adapter:
|
||||
# Create a temporary adapter for lookup
|
||||
from meshai.env.usgs import USGSStreamsAdapter
|
||||
from meshai.config import USGSConfig
|
||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||
|
||||
try:
|
||||
result = usgs_adapter.lookup_site(site_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e), "site_id": site_id}
|
||||
|
||||
|
||||
@router.get("/env/traffic")
|
||||
async def get_traffic_data(request: Request):
|
||||
"""Get TomTom traffic flow data."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="traffic")
|
||||
|
||||
|
||||
@router.get("/env/roads")
|
||||
async def get_roads_data(request: Request):
|
||||
"""Get 511 road conditions."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active(source="511")
|
||||
|
||||
|
||||
@router.get("/env/hotspots")
|
||||
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."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class TestRequest(BaseModel):
|
||||
"""Request body for test endpoint."""
|
||||
send: bool = False # Legacy: True = send_test
|
||||
action: str = "preview" # "preview", "send_test", "send_status", "send_live"
|
||||
|
||||
|
||||
class ChannelTestRequest(BaseModel):
|
||||
"""Request body for channel connectivity test."""
|
||||
type: str # mesh_broadcast, mesh_dm, email, webhook
|
||||
# Mesh broadcast
|
||||
channel_index: Optional[int] = 0
|
||||
# Mesh DM
|
||||
node_ids: Optional[List[str]] = []
|
||||
# Email
|
||||
smtp_host: Optional[str] = ""
|
||||
smtp_port: Optional[int] = 587
|
||||
smtp_user: Optional[str] = ""
|
||||
smtp_password: Optional[str] = ""
|
||||
smtp_tls: Optional[bool] = True
|
||||
from_address: Optional[str] = ""
|
||||
recipients: Optional[List[str]] = []
|
||||
# Webhook
|
||||
url: Optional[str] = ""
|
||||
headers: Optional[Dict[str, str]] = {}
|
||||
|
||||
|
||||
class RuleSourcesRequest(BaseModel):
|
||||
"""Request body for rule sources health check."""
|
||||
categories: List[str] = []
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_categories():
|
||||
"""Get all alert categories with descriptions."""
|
||||
try:
|
||||
from ...notifications.categories import list_categories
|
||||
return list_categories()
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules with stats."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
return []
|
||||
|
||||
rules = notification_router.get_rules()
|
||||
|
||||
# Enhance rules with stats
|
||||
result = []
|
||||
for i, rule in enumerate(rules):
|
||||
rule_copy = dict(rule)
|
||||
stats = rule_copy.pop("_stats", {})
|
||||
rule_copy["stats"] = stats
|
||||
rule_copy["index"] = i
|
||||
result.append(rule_copy)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/rules/{rule_index}/stats")
|
||||
async def get_rule_stats(request: Request, rule_index: int):
|
||||
"""Get statistics for a specific rule."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
rules_config = getattr(request.app.state, "config", None)
|
||||
if rules_config:
|
||||
rules_config = getattr(rules_config, "rules", [])
|
||||
if rule_index < 0 or rule_index >= len(rules_config):
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
rule = rules_config[rule_index]
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = dict(rule)
|
||||
|
||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||
return notification_router.get_rule_stats(rule_name)
|
||||
|
||||
return {"last_fired": None, "last_test": None, "fire_count": 0}
|
||||
|
||||
|
||||
@router.post("/channels/test")
|
||||
async def test_channel(request: Request, body: ChannelTestRequest):
|
||||
"""Test channel connectivity without sending actual alert content.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str, # Human-readable result
|
||||
"error": str, # Detailed error if failed
|
||||
"details": {} # Channel-specific details
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
# Build channel config from request
|
||||
channel_config = {"type": body.type}
|
||||
|
||||
if body.type == "mesh_broadcast":
|
||||
channel_config["channel_index"] = body.channel_index or 0
|
||||
elif body.type == "mesh_dm":
|
||||
channel_config["node_ids"] = body.node_ids or []
|
||||
elif body.type == "email":
|
||||
channel_config.update({
|
||||
"smtp_host": body.smtp_host or "",
|
||||
"smtp_port": body.smtp_port or 587,
|
||||
"smtp_user": body.smtp_user or "",
|
||||
"smtp_password": body.smtp_password or "",
|
||||
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
|
||||
"from_address": body.from_address or "",
|
||||
"recipients": body.recipients or [],
|
||||
})
|
||||
elif body.type == "webhook":
|
||||
channel_config.update({
|
||||
"url": body.url or "",
|
||||
"headers": body.headers or {},
|
||||
})
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Unknown channel type",
|
||||
"error": f"Channel type '{body.type}' is not supported",
|
||||
"details": {}
|
||||
}
|
||||
|
||||
result = await notification_router.test_channel(channel_config)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/test")
|
||||
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
|
||||
"""Test a notification rule against current conditions.
|
||||
|
||||
Returns comprehensive test result including:
|
||||
- Live data from relevant environmental feeds
|
||||
- Matching alerts (conditions that would fire)
|
||||
- Near-misses (filtered by severity threshold)
|
||||
- Preview messages and delivery status
|
||||
- Source health (which feeds are enabled)
|
||||
- Rule statistics (last fired, fire count)
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
action = body.action if body else "preview"
|
||||
send = body.send if body else False
|
||||
|
||||
# Legacy support
|
||||
if send and action == "preview":
|
||||
action = "send_test"
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/preview")
|
||||
async def preview_rule(request: Request, rule_index: int):
|
||||
"""Preview what a rule would match right now (without sending)."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="preview",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/sources")
|
||||
async def get_rule_sources(request: Request, body: RuleSourcesRequest):
|
||||
"""Get data source health for a set of categories.
|
||||
|
||||
Returns per-category source status:
|
||||
{
|
||||
"category_id": {
|
||||
"enabled": true/false,
|
||||
"active_events": number,
|
||||
"source": "nws"/"swpc"/etc,
|
||||
"status": "ok"/"disabled"/"no_data"
|
||||
}
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
return notification_router.get_source_health(body.categories, env_store)
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-status")
|
||||
async def send_rule_status(request: Request, rule_index: int):
|
||||
"""Send current conditions summary through a rule's channel.
|
||||
|
||||
Formats current live data as a readable message and delivers
|
||||
through the rule's configured channel with [STATUS] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_status",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-test")
|
||||
async def send_rule_test(request: Request, rule_index: int):
|
||||
"""Send example alert message through a rule's channel.
|
||||
|
||||
Sends the example_message from the rule's first category
|
||||
through the configured channel with [TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_test",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-live")
|
||||
async def send_rule_live(request: Request, rule_index: int):
|
||||
"""Send actual live alert through a rule's channel.
|
||||
|
||||
Only available when there are matching conditions.
|
||||
Sends one of the actual matching alerts with [LIVE TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_live",
|
||||
)
|
||||
|
||||
return result
|
||||
"""Notification API routes with comprehensive testing."""
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class TestRequest(BaseModel):
|
||||
"""Request body for test endpoint."""
|
||||
send: bool = False # Legacy: True = send_test
|
||||
action: str = "preview" # "preview", "send_test", "send_status", "send_live"
|
||||
|
||||
|
||||
class ChannelTestRequest(BaseModel):
|
||||
"""Request body for channel connectivity test."""
|
||||
type: str # mesh_broadcast, mesh_dm, email, webhook
|
||||
# Mesh broadcast
|
||||
channel_index: Optional[int] = 0
|
||||
# Mesh DM
|
||||
node_ids: Optional[List[str]] = []
|
||||
# Email
|
||||
smtp_host: Optional[str] = ""
|
||||
smtp_port: Optional[int] = 587
|
||||
smtp_user: Optional[str] = ""
|
||||
smtp_password: Optional[str] = ""
|
||||
smtp_tls: Optional[bool] = True
|
||||
from_address: Optional[str] = ""
|
||||
recipients: Optional[List[str]] = []
|
||||
# Webhook
|
||||
url: Optional[str] = ""
|
||||
headers: Optional[Dict[str, str]] = {}
|
||||
|
||||
|
||||
class RuleSourcesRequest(BaseModel):
|
||||
"""Request body for rule sources health check."""
|
||||
categories: List[str] = []
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def get_categories():
|
||||
"""Get all alert categories with descriptions."""
|
||||
try:
|
||||
from ...notifications.categories import list_categories
|
||||
return list_categories()
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules with stats."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
return []
|
||||
|
||||
rules = notification_router.get_rules()
|
||||
|
||||
# Enhance rules with stats
|
||||
result = []
|
||||
for i, rule in enumerate(rules):
|
||||
rule_copy = dict(rule)
|
||||
stats = rule_copy.pop("_stats", {})
|
||||
rule_copy["stats"] = stats
|
||||
rule_copy["index"] = i
|
||||
result.append(rule_copy)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/rules/{rule_index}/stats")
|
||||
async def get_rule_stats(request: Request, rule_index: int):
|
||||
"""Get statistics for a specific rule."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
rules_config = getattr(request.app.state, "config", None)
|
||||
if rules_config:
|
||||
rules_config = getattr(rules_config, "rules", [])
|
||||
if rule_index < 0 or rule_index >= len(rules_config):
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
rule = rules_config[rule_index]
|
||||
if hasattr(rule, "__dict__"):
|
||||
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
|
||||
else:
|
||||
rule_dict = dict(rule)
|
||||
|
||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||
return notification_router.get_rule_stats(rule_name)
|
||||
|
||||
return {"last_fired": None, "last_test": None, "fire_count": 0}
|
||||
|
||||
|
||||
@router.post("/channels/test")
|
||||
async def test_channel(request: Request, body: ChannelTestRequest):
|
||||
"""Test channel connectivity without sending actual alert content.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str, # Human-readable result
|
||||
"error": str, # Detailed error if failed
|
||||
"details": {} # Channel-specific details
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
# Build channel config from request
|
||||
channel_config = {"type": body.type}
|
||||
|
||||
if body.type == "mesh_broadcast":
|
||||
channel_config["channel_index"] = body.channel_index or 0
|
||||
elif body.type == "mesh_dm":
|
||||
channel_config["node_ids"] = body.node_ids or []
|
||||
elif body.type == "email":
|
||||
channel_config.update({
|
||||
"smtp_host": body.smtp_host or "",
|
||||
"smtp_port": body.smtp_port or 587,
|
||||
"smtp_user": body.smtp_user or "",
|
||||
"smtp_password": body.smtp_password or "",
|
||||
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
|
||||
"from_address": body.from_address or "",
|
||||
"recipients": body.recipients or [],
|
||||
})
|
||||
elif body.type == "webhook":
|
||||
channel_config.update({
|
||||
"url": body.url or "",
|
||||
"headers": body.headers or {},
|
||||
})
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Unknown channel type",
|
||||
"error": f"Channel type '{body.type}' is not supported",
|
||||
"details": {}
|
||||
}
|
||||
|
||||
result = await notification_router.test_channel(channel_config)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/test")
|
||||
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
|
||||
"""Test a notification rule against current conditions.
|
||||
|
||||
Returns comprehensive test result including:
|
||||
- Live data from relevant environmental feeds
|
||||
- Matching alerts (conditions that would fire)
|
||||
- Near-misses (filtered by severity threshold)
|
||||
- Preview messages and delivery status
|
||||
- Source health (which feeds are enabled)
|
||||
- Rule statistics (last fired, fire count)
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
action = body.action if body else "preview"
|
||||
send = body.send if body else False
|
||||
|
||||
# Legacy support
|
||||
if send and action == "preview":
|
||||
action = "send_test"
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/preview")
|
||||
async def preview_rule(request: Request, rule_index: int):
|
||||
"""Preview what a rule would match right now (without sending)."""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="preview",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/sources")
|
||||
async def get_rule_sources(request: Request, body: RuleSourcesRequest):
|
||||
"""Get data source health for a set of categories.
|
||||
|
||||
Returns per-category source status:
|
||||
{
|
||||
"category_id": {
|
||||
"enabled": true/false,
|
||||
"active_events": number,
|
||||
"source": "nws"/"swpc"/etc,
|
||||
"status": "ok"/"disabled"/"no_data"
|
||||
}
|
||||
}
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
return notification_router.get_source_health(body.categories, env_store)
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-status")
|
||||
async def send_rule_status(request: Request, rule_index: int):
|
||||
"""Send current conditions summary through a rule's channel.
|
||||
|
||||
Formats current live data as a readable message and delivers
|
||||
through the rule's configured channel with [STATUS] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_status",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-test")
|
||||
async def send_rule_test(request: Request, rule_index: int):
|
||||
"""Send example alert message through a rule's channel.
|
||||
|
||||
Sends the example_message from the rule's first category
|
||||
through the configured channel with [TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_test",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rules/{rule_index}/send-live")
|
||||
async def send_rule_live(request: Request, rule_index: int):
|
||||
"""Send actual live alert through a rule's channel.
|
||||
|
||||
Only available when there are matching conditions.
|
||||
Sends one of the actual matching alerts with [LIVE TEST] prefix.
|
||||
"""
|
||||
notification_router = getattr(request.app.state, "notification_router", None)
|
||||
if not notification_router:
|
||||
raise HTTPException(status_code=404, detail="Notification router not configured")
|
||||
|
||||
alert_engine = getattr(request.app.state, "alert_engine", None)
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
health_engine = getattr(request.app.state, "health_engine", None)
|
||||
|
||||
result = await notification_router.test_rule_with_conditions(
|
||||
rule_index,
|
||||
alert_engine=alert_engine,
|
||||
env_store=env_store,
|
||||
health_engine=health_engine,
|
||||
action="send_live",
|
||||
)
|
||||
|
||||
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.gstatic.com" crossorigin>
|
||||
<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>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bildyb1E.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-QhNRb-ap.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
109
meshai/main.py
109
meshai/main.py
|
|
@ -265,6 +265,7 @@ class MeshAI:
|
|||
self.data_store = MeshDataStore(
|
||||
source_configs=enabled_sources,
|
||||
db_path="/data/mesh_history.db",
|
||||
offline_threshold_hours=self.config.mesh_intelligence.offline_threshold_hours,
|
||||
)
|
||||
# Initial fetch and backfill
|
||||
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})")
|
||||
|
||||
|
||||
# Notification router
|
||||
if self.config.notifications.enabled:
|
||||
from .notifications.router import NotificationRouter
|
||||
self.notification_router = NotificationRouter(
|
||||
config=self.config.notifications,
|
||||
connector=self.connector,
|
||||
llm_backend=self.llm,
|
||||
timezone=self.config.timezone,
|
||||
)
|
||||
logger.info("Notification router initialized")
|
||||
|
||||
|
||||
# Notification router
|
||||
if self.config.notifications.enabled:
|
||||
from .notifications.router import NotificationRouter
|
||||
self.notification_router = NotificationRouter(
|
||||
config=self.config.notifications,
|
||||
connector=self.connector,
|
||||
llm_backend=self.llm,
|
||||
timezone=self.config.timezone,
|
||||
)
|
||||
logger.info("Notification router initialized")
|
||||
|
||||
# Environmental feeds
|
||||
env_cfg = self.config.environmental
|
||||
if env_cfg.enabled:
|
||||
|
|
@ -554,48 +555,48 @@ class MeshAI:
|
|||
if pid_file.exists():
|
||||
pid_file.unlink()
|
||||
|
||||
async def _dispatch_alerts(self, alerts: list[dict]) -> None:
|
||||
"""Dispatch alerts to subscribers and alert channel."""
|
||||
mi = self.config.mesh_intelligence
|
||||
alert_channel = getattr(mi, 'alert_channel', -1)
|
||||
|
||||
for alert in alerts:
|
||||
message = alert["message"]
|
||||
logger.info(f"ALERT: {message}")
|
||||
|
||||
# Route through notification router if enabled
|
||||
if self.notification_router:
|
||||
try:
|
||||
await self.notification_router.process_alert(alert)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification router error: {e}")
|
||||
|
||||
# Fallback: Send to alert channel if no notification router
|
||||
elif alert_channel >= 0 and self.connector:
|
||||
try:
|
||||
self.connector.send_message(
|
||||
text=message,
|
||||
destination=None,
|
||||
channel=alert_channel,
|
||||
)
|
||||
logger.info(f"Alert sent to channel {alert_channel}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel alert: {e}")
|
||||
|
||||
# Fallback: Send DMs to matching subscribers
|
||||
if self.alert_engine and self.subscription_manager:
|
||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
||||
for sub in subscribers:
|
||||
user_id = sub["user_id"]
|
||||
try:
|
||||
await self._send_sub_dm(user_id, message)
|
||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
||||
|
||||
if self.alert_engine:
|
||||
self.alert_engine.clear_pending()
|
||||
|
||||
async def _dispatch_alerts(self, alerts: list[dict]) -> None:
|
||||
"""Dispatch alerts to subscribers and alert channel."""
|
||||
mi = self.config.mesh_intelligence
|
||||
alert_channel = getattr(mi, 'alert_channel', -1)
|
||||
|
||||
for alert in alerts:
|
||||
message = alert["message"]
|
||||
logger.info(f"ALERT: {message}")
|
||||
|
||||
# Route through notification router if enabled
|
||||
if self.notification_router:
|
||||
try:
|
||||
await self.notification_router.process_alert(alert)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification router error: {e}")
|
||||
|
||||
# Fallback: Send to alert channel if no notification router
|
||||
elif alert_channel >= 0 and self.connector:
|
||||
try:
|
||||
self.connector.send_message(
|
||||
text=message,
|
||||
destination=None,
|
||||
channel=alert_channel,
|
||||
)
|
||||
logger.info(f"Alert sent to channel {alert_channel}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send channel alert: {e}")
|
||||
|
||||
# Fallback: Send DMs to matching subscribers
|
||||
if self.alert_engine and self.subscription_manager:
|
||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
||||
for sub in subscribers:
|
||||
user_id = sub["user_id"]
|
||||
try:
|
||||
await self._send_sub_dm(user_id, message)
|
||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
||||
|
||||
if self.alert_engine:
|
||||
self.alert_engine.clear_pending()
|
||||
|
||||
async def _check_scheduled_subs(self) -> None:
|
||||
"""Check for and deliver due scheduled reports."""
|
||||
from datetime import datetime
|
||||
|
|
|
|||
|
|
@ -230,16 +230,19 @@ class MeshDataStore:
|
|||
self,
|
||||
source_configs: list[MeshSourceConfig],
|
||||
db_path: str = "/data/mesh_history.db",
|
||||
offline_threshold_hours: int = 2,
|
||||
):
|
||||
"""Initialize the data store.
|
||||
|
||||
Args:
|
||||
source_configs: List of source configurations
|
||||
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._db_path = db_path
|
||||
self._db: Optional[sqlite3.Connection] = None
|
||||
self._offline_threshold_hours = offline_threshold_hours
|
||||
|
||||
# Live state
|
||||
self._nodes: dict[int, UnifiedNode] = {}
|
||||
|
|
@ -745,11 +748,13 @@ class MeshDataStore:
|
|||
|
||||
node.last_heard = ts or 0.0
|
||||
|
||||
# NOTE: is_online is set by MeshHealthEngine.compute() using the
|
||||
# configured offline_threshold_hours. Don't set it here with a
|
||||
# hardcoded value - let the health engine determine online status.
|
||||
# The health engine runs on every refresh cycle and will set is_online
|
||||
# based on: (now - last_heard) < (offline_threshold_hours * 3600)
|
||||
# Compute is_online based on configured threshold
|
||||
# This ensures correct status immediately, before health engine runs
|
||||
if node.last_heard:
|
||||
offline_threshold = time.time() - (self._offline_threshold_hours * 3600)
|
||||
node.is_online = node.last_heard > offline_threshold
|
||||
else:
|
||||
node.is_online = False
|
||||
|
||||
# Hops, SNR, RSSI (MM)
|
||||
node.hops_away = raw.get("hopsAway")
|
||||
|
|
@ -2111,7 +2116,7 @@ class MeshDataStore:
|
|||
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
|
||||
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."""
|
||||
return [
|
||||
n
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
|
|||
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
|
||||
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
|
||||
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.
|
||||
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
|
||||
# A node averaging 5+ non-text packets/min is misbehaving.
|
||||
|
|
|
|||
|
|
@ -630,7 +630,7 @@ class MeshReporter:
|
|||
usb += 1
|
||||
elif node.battery_percent >= 50:
|
||||
ok += 1
|
||||
elif node.battery_percent >= 20:
|
||||
elif node.battery_percent >= 30:
|
||||
low += 1
|
||||
else:
|
||||
critical += 1
|
||||
|
|
|
|||
|
|
@ -1,242 +1,242 @@
|
|||
"""Alert category registry.
|
||||
|
||||
Defines all alertable conditions with human-readable names, descriptions,
|
||||
and example messages showing what users will receive.
|
||||
|
||||
Severity levels (military/intelligence precedence):
|
||||
routine - Informational, no time pressure
|
||||
priority - Needs attention soon
|
||||
immediate - Act now, drop everything
|
||||
"""
|
||||
|
||||
ALERT_CATEGORIES = {
|
||||
# Infrastructure alerts
|
||||
"infra_offline": {
|
||||
"name": "Infrastructure Node Offline",
|
||||
"description": "An infrastructure node (router/repeater) stopped responding",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
||||
},
|
||||
"critical_node_down": {
|
||||
"name": "Critical Node Down",
|
||||
"description": "A node you marked as critical went offline",
|
||||
"default_severity": "immediate",
|
||||
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
||||
},
|
||||
"infra_recovery": {
|
||||
"name": "Infrastructure Recovery",
|
||||
"description": "An offline infrastructure node came back online",
|
||||
"default_severity": "routine",
|
||||
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
||||
},
|
||||
"new_router": {
|
||||
"name": "New Router",
|
||||
"description": "A new router appeared on the mesh",
|
||||
"default_severity": "routine",
|
||||
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
||||
},
|
||||
|
||||
# Power alerts
|
||||
"battery_warning": {
|
||||
"name": "Battery Warning",
|
||||
"description": "Infrastructure node battery below 30% (3.60V)",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
||||
},
|
||||
"battery_critical": {
|
||||
"name": "Battery Critical",
|
||||
"description": "Infrastructure node battery below 15% (3.50V)",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
||||
},
|
||||
"battery_emergency": {
|
||||
"name": "Battery Emergency",
|
||||
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
||||
"default_severity": "immediate",
|
||||
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
||||
},
|
||||
"battery_trend": {
|
||||
"name": "Battery Declining",
|
||||
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
||||
},
|
||||
"power_source_change": {
|
||||
"name": "Power Source Change",
|
||||
"description": "Node switched from USB to battery — possible power outage at site",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
||||
},
|
||||
"solar_not_charging": {
|
||||
"name": "Solar Not Charging",
|
||||
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
||||
"default_severity": "priority",
|
||||
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
||||
},
|
||||
|
||||
# Utilization alerts
|
||||
"high_utilization": {
|
||||
"name": "Channel Airtime High",
|
||||
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
||||
"default_severity": "routine",
|
||||
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
||||
},
|
||||
"sustained_high_util": {
|
||||
"name": "Sustained High Utilization",
|
||||
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
||||
},
|
||||
"packet_flood": {
|
||||
"name": "Packet Flood",
|
||||
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
||||
},
|
||||
|
||||
# Coverage alerts
|
||||
"infra_single_gateway": {
|
||||
"name": "Single Gateway",
|
||||
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
||||
},
|
||||
"feeder_offline": {
|
||||
"name": "Feeder Offline",
|
||||
"description": "A feeder gateway stopped responding — coverage gap possible",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
||||
},
|
||||
"region_total_blackout": {
|
||||
"name": "Region Blackout",
|
||||
"description": "All infrastructure in a region is offline — complete coverage loss",
|
||||
"default_severity": "immediate",
|
||||
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||
},
|
||||
|
||||
# Health score alerts
|
||||
"mesh_score_low": {
|
||||
"name": "Mesh Health Low",
|
||||
"description": "Overall mesh health score dropped below threshold — multiple issues likely",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
||||
},
|
||||
"region_score_low": {
|
||||
"name": "Region Health Low",
|
||||
"description": "A region's health score below threshold — localized issues",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
||||
},
|
||||
|
||||
# Environmental - Weather
|
||||
"weather_warning": {
|
||||
"name": "Severe Weather",
|
||||
"description": "NWS warning or advisory affecting your mesh area",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
||||
},
|
||||
|
||||
# Environmental - Space Weather
|
||||
"hf_blackout": {
|
||||
"name": "HF Radio Blackout",
|
||||
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
||||
},
|
||||
"geomagnetic_storm": {
|
||||
"name": "Geomagnetic Storm",
|
||||
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
||||
},
|
||||
|
||||
# Environmental - Tropospheric
|
||||
"tropospheric_ducting": {
|
||||
"name": "Tropospheric Ducting",
|
||||
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
||||
"default_severity": "routine",
|
||||
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
||||
},
|
||||
|
||||
# Environmental - Fire
|
||||
"fire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
||||
},
|
||||
"wildfire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
||||
},
|
||||
"new_ignition": {
|
||||
"name": "New Fire Ignition",
|
||||
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
||||
"default_severity": "priority",
|
||||
"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
|
||||
"stream_flood_warning": {
|
||||
"name": "Stream Flood Warning",
|
||||
"description": "River gauge exceeds NWS flood stage threshold",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
||||
},
|
||||
"stream_high_water": {
|
||||
"name": "Stream High Water",
|
||||
"description": "River gauge approaching flood stage — monitoring recommended",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
||||
},
|
||||
|
||||
# Environmental - Roads
|
||||
"road_closure": {
|
||||
"name": "Road Closure",
|
||||
"description": "Full road closure on a monitored corridor",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
||||
},
|
||||
"traffic_congestion": {
|
||||
"name": "Traffic Congestion",
|
||||
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
||||
},
|
||||
|
||||
# Environmental - Avalanche
|
||||
"avalanche_warning": {
|
||||
"name": "Avalanche Danger High",
|
||||
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
||||
},
|
||||
"avalanche_considerable": {
|
||||
"name": "Avalanche Danger Considerable",
|
||||
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
||||
"default_severity": "routine",
|
||||
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_category(category_id: str) -> dict:
|
||||
"""Get category info by ID, with fallback for unknown categories."""
|
||||
if category_id in ALERT_CATEGORIES:
|
||||
return ALERT_CATEGORIES[category_id]
|
||||
return {
|
||||
"name": category_id.replace("_", " ").title(),
|
||||
"description": f"Alert type: {category_id}",
|
||||
"default_severity": "routine",
|
||||
"example_message": f"Alert: {category_id}",
|
||||
}
|
||||
|
||||
|
||||
def list_categories() -> list[dict]:
|
||||
"""List all categories with their IDs."""
|
||||
return [
|
||||
{"id": cat_id, **cat_info}
|
||||
for cat_id, cat_info in ALERT_CATEGORIES.items()
|
||||
]
|
||||
"""Alert category registry.
|
||||
|
||||
Defines all alertable conditions with human-readable names, descriptions,
|
||||
and example messages showing what users will receive.
|
||||
|
||||
Severity levels (military/intelligence precedence):
|
||||
routine - Informational, no time pressure
|
||||
priority - Needs attention soon
|
||||
immediate - Act now, drop everything
|
||||
"""
|
||||
|
||||
ALERT_CATEGORIES = {
|
||||
# Infrastructure alerts
|
||||
"infra_offline": {
|
||||
"name": "Infrastructure Node Offline",
|
||||
"description": "An infrastructure node (router/repeater) stopped responding",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚠ Infrastructure Offline: MHR — Mountain Harrison Rptr has not been heard for 2 hours",
|
||||
},
|
||||
"critical_node_down": {
|
||||
"name": "Critical Node Down",
|
||||
"description": "A node you marked as critical went offline",
|
||||
"default_severity": "immediate",
|
||||
"example_message": "🚨 Critical Node Down: HPR — Hayden Peak Rptr offline for 1 hour",
|
||||
},
|
||||
"infra_recovery": {
|
||||
"name": "Infrastructure Recovery",
|
||||
"description": "An offline infrastructure node came back online",
|
||||
"default_severity": "routine",
|
||||
"example_message": "✅ Recovery: MHR — Mountain Harrison Rptr back online after 2h outage",
|
||||
},
|
||||
"new_router": {
|
||||
"name": "New Router",
|
||||
"description": "A new router appeared on the mesh",
|
||||
"default_severity": "routine",
|
||||
"example_message": "📡 New Router: Snake River Relay appeared in Wood River Valley",
|
||||
},
|
||||
|
||||
# Power alerts
|
||||
"battery_warning": {
|
||||
"name": "Battery Warning",
|
||||
"description": "Infrastructure node battery below 30% (3.60V)",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🔋 Battery Warning: BLD-MTN at 28% (3.58V), solar not charging",
|
||||
},
|
||||
"battery_critical": {
|
||||
"name": "Battery Critical",
|
||||
"description": "Infrastructure node battery below 15% (3.50V)",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🔋 Battery Critical: BLD-MTN at 12% (3.48V) — shutdown in hours",
|
||||
},
|
||||
"battery_emergency": {
|
||||
"name": "Battery Emergency",
|
||||
"description": "Infrastructure node battery below 5% (3.40V) — shutdown imminent",
|
||||
"default_severity": "immediate",
|
||||
"example_message": "🚨 Battery Emergency: BLD-MTN at 4% (3.38V) — shutdown imminent",
|
||||
},
|
||||
"battery_trend": {
|
||||
"name": "Battery Declining",
|
||||
"description": "Battery showing declining trend over 7 days — possible solar or charging issue",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🔋 Battery Trend: HPR declining 85% → 62% over 7 days (-3.3%/day)",
|
||||
},
|
||||
"power_source_change": {
|
||||
"name": "Power Source Change",
|
||||
"description": "Node switched from USB to battery — possible power outage at site",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚡ Power Source: MHR switched from USB to battery — possible outage",
|
||||
},
|
||||
"solar_not_charging": {
|
||||
"name": "Solar Not Charging",
|
||||
"description": "Solar panel not charging during daylight hours — panel issue or obstruction",
|
||||
"default_severity": "priority",
|
||||
"example_message": "☀️ Solar Issue: BLD-MTN not charging during daylight (12:00 MDT)",
|
||||
},
|
||||
|
||||
# Utilization alerts
|
||||
"high_utilization": {
|
||||
"name": "Channel Airtime High",
|
||||
"description": "LoRa channel airtime exceeding threshold — mesh congestion",
|
||||
"default_severity": "routine",
|
||||
"example_message": "📊 Channel Airtime: 47% utilization (threshold: 40%). Reliability may degrade.",
|
||||
},
|
||||
"sustained_high_util": {
|
||||
"name": "Sustained High Utilization",
|
||||
"description": "Channel airtime elevated for extended period — ongoing congestion",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📊 Sustained Congestion: 45% channel utilization for 2+ hours. Consider reducing telemetry.",
|
||||
},
|
||||
"packet_flood": {
|
||||
"name": "Packet Flood",
|
||||
"description": "A single node sending excessive radio packets (NOT water flooding) — possible firmware bug or stuck transmitter",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📻 Packet Flood: Node 'BKBS' transmitting 42 packets/min (threshold: 10/min). Firmware bug?",
|
||||
},
|
||||
|
||||
# Coverage alerts
|
||||
"infra_single_gateway": {
|
||||
"name": "Single Gateway",
|
||||
"description": "Infrastructure node dropped to single gateway coverage — reduced redundancy",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📶 Reduced Coverage: HPR dropped to single gateway. Previously had 3 paths.",
|
||||
},
|
||||
"feeder_offline": {
|
||||
"name": "Feeder Offline",
|
||||
"description": "A feeder gateway stopped responding — coverage gap possible",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📡 Feeder Offline: AIDA-N2 gateway not responding. 5 nodes may lose uplink.",
|
||||
},
|
||||
"region_total_blackout": {
|
||||
"name": "Region Blackout",
|
||||
"description": "All infrastructure in a region is offline — complete coverage loss",
|
||||
"default_severity": "immediate",
|
||||
"example_message": "🚨 REGION BLACKOUT: All infrastructure in Magic Valley offline!",
|
||||
},
|
||||
|
||||
# Health score alerts
|
||||
"mesh_score_low": {
|
||||
"name": "Mesh Health Low",
|
||||
"description": "Overall mesh health score dropped below threshold — multiple issues likely",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📉 Mesh Health: Score 62/100 (threshold: 65). Infrastructure: 71, Connectivity: 58.",
|
||||
},
|
||||
"region_score_low": {
|
||||
"name": "Region Health Low",
|
||||
"description": "A region's health score below threshold — localized issues",
|
||||
"default_severity": "priority",
|
||||
"example_message": "📉 Region Health: Magic Valley at 55/100 (threshold: 60). 2 nodes offline.",
|
||||
},
|
||||
|
||||
# Environmental - Weather
|
||||
"weather_warning": {
|
||||
"name": "Severe Weather",
|
||||
"description": "NWS warning or advisory affecting your mesh area",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚠ Red Flag Warning — Twin Falls, Cassia counties. Gusty winds, low humidity. Until May 13 04:00Z",
|
||||
},
|
||||
|
||||
# Environmental - Space Weather
|
||||
"hf_blackout": {
|
||||
"name": "HF Radio Blackout",
|
||||
"description": "R3+ solar flare degrading HF propagation on sunlit side",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⚠ R3 Strong Radio Blackout — X1.2 flare. Wide-area HF blackout ~1 hour on sunlit side.",
|
||||
},
|
||||
"geomagnetic_storm": {
|
||||
"name": "Geomagnetic Storm",
|
||||
"description": "G2+ geomagnetic storm — HF degraded at higher latitudes, aurora possible",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🌐 G2 Moderate Geomagnetic Storm — Kp=6. HF fades at high latitudes, aurora to ~55°.",
|
||||
},
|
||||
|
||||
# Environmental - Tropospheric
|
||||
"tropospheric_ducting": {
|
||||
"name": "Tropospheric Ducting",
|
||||
"description": "Atmospheric conditions trapping VHF/UHF signals — extended range",
|
||||
"default_severity": "routine",
|
||||
"example_message": "📡 Tropospheric Ducting: Surface duct detected, dM/dz -45 M-units/km, ~120m thick. VHF/UHF extended range.",
|
||||
},
|
||||
|
||||
# Environmental - Fire
|
||||
"fire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR. Monitor closely.",
|
||||
},
|
||||
"wildfire_proximity": {
|
||||
"name": "Fire Near Mesh",
|
||||
"description": "Active wildfire within alert radius of mesh infrastructure",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🔥 Fire Near Mesh: Rock Creek Fire — 1,240 ac, 15% contained, 12 km SSW of MHR.",
|
||||
},
|
||||
"new_ignition": {
|
||||
"name": "New Fire Ignition",
|
||||
"description": "Satellite hotspot detected NOT near any known fire — potential new wildfire",
|
||||
"default_severity": "priority",
|
||||
"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
|
||||
"stream_flood_warning": {
|
||||
"name": "Stream Flood Warning",
|
||||
"description": "River gauge exceeds NWS flood stage threshold",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🌊 Stream Flood Warning: Snake River nr Twin Falls at 12.8 ft — Minor Flood Stage is 10.5 ft.",
|
||||
},
|
||||
"stream_high_water": {
|
||||
"name": "Stream High Water",
|
||||
"description": "River gauge approaching flood stage — monitoring recommended",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🌊 High Water: Snake River at 9.8 ft — Action Stage is 9.0 ft. Monitor conditions.",
|
||||
},
|
||||
|
||||
# Environmental - Roads
|
||||
"road_closure": {
|
||||
"name": "Road Closure",
|
||||
"description": "Full road closure on a monitored corridor",
|
||||
"default_severity": "priority",
|
||||
"example_message": "🚧 Road Closure: I-84 EB at MP 173 — full closure, construction. Detour via US-30.",
|
||||
},
|
||||
"traffic_congestion": {
|
||||
"name": "Traffic Congestion",
|
||||
"description": "Traffic speed dropped below congestion threshold on a monitored corridor",
|
||||
"default_severity": "routine",
|
||||
"example_message": "🚗 Traffic Congestion: I-84 Twin Falls — 35 mph (free-flow 70 mph), 50% speed ratio",
|
||||
},
|
||||
|
||||
# Environmental - Avalanche
|
||||
"avalanche_warning": {
|
||||
"name": "Avalanche Danger High",
|
||||
"description": "Avalanche danger level 4 (High) or 5 (Extreme) in your area",
|
||||
"default_severity": "priority",
|
||||
"example_message": "⛷ Avalanche Danger HIGH: Sawtooth Zone — avoid avalanche terrain. Natural avalanches likely.",
|
||||
},
|
||||
"avalanche_considerable": {
|
||||
"name": "Avalanche Danger Considerable",
|
||||
"description": "Avalanche danger level 3 (Considerable) — most fatalities occur at this level",
|
||||
"default_severity": "routine",
|
||||
"example_message": "⛷ Avalanche Danger CONSIDERABLE: Sawtooth Zone — dangerous conditions on steep slopes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_category(category_id: str) -> dict:
|
||||
"""Get category info by ID, with fallback for unknown categories."""
|
||||
if category_id in ALERT_CATEGORIES:
|
||||
return ALERT_CATEGORIES[category_id]
|
||||
return {
|
||||
"name": category_id.replace("_", " ").title(),
|
||||
"description": f"Alert type: {category_id}",
|
||||
"default_severity": "routine",
|
||||
"example_message": f"Alert: {category_id}",
|
||||
}
|
||||
|
||||
|
||||
def list_categories() -> list[dict]:
|
||||
"""List all categories with their IDs."""
|
||||
return [
|
||||
{"id": cat_id, **cat_info}
|
||||
for cat_id, cat_info in ALERT_CATEGORIES.items()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ class EmailChannel(NotificationChannel):
|
|||
return False
|
||||
|
||||
alert_type = alert.get("type", "alert")
|
||||
severity = alert.get("severity", "info").upper()
|
||||
severity = alert.get("severity", "routine").upper()
|
||||
message = alert.get("message", "")
|
||||
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." % (
|
||||
|
|
@ -518,7 +518,7 @@ class WebhookChannel(NotificationChannel):
|
|||
"""POST alert to webhook URL."""
|
||||
payload = {
|
||||
"type": alert.get("type"),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"severity": alert.get("severity", "routine"),
|
||||
"message": alert.get("message", ""),
|
||||
"timestamp": time.time(),
|
||||
"node_name": alert.get("node_name"),
|
||||
|
|
@ -527,7 +527,7 @@ class WebhookChannel(NotificationChannel):
|
|||
|
||||
# Discord/Slack format
|
||||
if "discord.com" in self._url or "slack.com" in self._url:
|
||||
severity = alert.get("severity", "info")
|
||||
severity = alert.get("severity", "routine")
|
||||
color = {
|
||||
"immediate": 0xFF0000,
|
||||
"priority": 0xFFAA00,
|
||||
|
|
@ -669,7 +669,7 @@ class WebhookChannel(NotificationChannel):
|
|||
else:
|
||||
payload = {
|
||||
"type": "test",
|
||||
"severity": "info",
|
||||
"severity": "routine",
|
||||
"message": "MeshAI channel connectivity test",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
|
@ -730,7 +730,7 @@ class WebhookChannel(NotificationChannel):
|
|||
async def deliver_test(self, message: str) -> tuple[bool, str]:
|
||||
"""Deliver a specific test message via webhook."""
|
||||
try:
|
||||
test_alert = {"type": "test", "severity": "info", "message": message}
|
||||
test_alert = {"type": "test", "severity": "routine", "message": message}
|
||||
success = await self.deliver(test_alert, {})
|
||||
if success:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class NotificationRouter:
|
|||
self._timezone = timezone
|
||||
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
|
||||
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
|
||||
self._llm = llm_backend
|
||||
self._connector = connector
|
||||
self._config = config
|
||||
|
||||
|
|
@ -149,7 +150,7 @@ class NotificationRouter:
|
|||
async def process_alert(self, alert: dict) -> bool:
|
||||
"""Route an alert through matching rules."""
|
||||
category = alert.get("type", "")
|
||||
severity = alert.get("severity", "info")
|
||||
severity = alert.get("severity", "routine")
|
||||
delivered = False
|
||||
|
||||
for rule in self._rules:
|
||||
|
|
@ -159,7 +160,7 @@ class NotificationRouter:
|
|||
if rule_categories and category not in rule_categories:
|
||||
continue
|
||||
|
||||
min_severity = rule.get("min_severity", "info")
|
||||
min_severity = rule.get("min_severity", "routine")
|
||||
if not self._severity_meets(severity, min_severity):
|
||||
continue
|
||||
|
||||
|
|
@ -391,7 +392,7 @@ class NotificationRouter:
|
|||
|
||||
rule_name = rule_dict.get("name", f"Rule {rule_index}")
|
||||
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", "")
|
||||
|
||||
# Legacy support
|
||||
|
|
@ -535,7 +536,7 @@ class NotificationRouter:
|
|||
for alert in alert_engine.get_pending_alerts():
|
||||
all_events.append({
|
||||
"type": alert.get("type", ""),
|
||||
"severity": alert.get("severity", "info"),
|
||||
"severity": alert.get("severity", "routine"),
|
||||
"message": alert.get("message", ""),
|
||||
"headline": alert.get("message", "")[:80],
|
||||
})
|
||||
|
|
@ -547,7 +548,7 @@ class NotificationRouter:
|
|||
for event in env_store.get_active():
|
||||
all_events.append({
|
||||
"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))),
|
||||
"headline": event.get("headline", event.get("message", "Event"))[:80],
|
||||
})
|
||||
|
|
@ -624,12 +625,20 @@ class NotificationRouter:
|
|||
channel = self._create_channel_for_rule(rule_dict)
|
||||
if channel:
|
||||
try:
|
||||
if action == "send_status" and live_data_lines:
|
||||
# Filter out the warning line for status message
|
||||
data_lines = [l for l in live_data_lines if not l.startswith("[!]")]
|
||||
status_msg = "[STATUS] " + " | ".join(data_lines[:4])
|
||||
if len(status_msg) > 200:
|
||||
status_msg = status_msg[:195] + "..."
|
||||
if action == "send_status":
|
||||
# Determine report type from rule categories
|
||||
report_type = "all"
|
||||
if rule_categories:
|
||||
if any(c in rule_categories for c in ["hf_blackout", "geomagnetic_storm", "tropospheric_ducting"]):
|
||||
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)
|
||||
delivered = success
|
||||
delivery_result = result if success else f"Failed: {result}"
|
||||
|
|
@ -637,9 +646,9 @@ class NotificationRouter:
|
|||
delivery_error = result
|
||||
|
||||
elif action == "send_live" and matching_alerts:
|
||||
live_msg = f"[LIVE TEST] {matching_alerts[0].get('message', '')}"
|
||||
if len(live_msg) > 200:
|
||||
live_msg = live_msg[:195] + "..."
|
||||
live_msg = matching_alerts[0].get('message', '')
|
||||
if len(live_msg) > 195:
|
||||
live_msg = live_msg[:192] + "..."
|
||||
success, result = await channel.deliver_test(live_msg)
|
||||
delivered = success
|
||||
delivery_result = result if success else f"Failed: {result}"
|
||||
|
|
@ -752,7 +761,7 @@ class NotificationRouter:
|
|||
"enabled": True,
|
||||
"trigger_type": "condition",
|
||||
"categories": categories if categories else [],
|
||||
"min_severity": "warning",
|
||||
"min_severity": "priority",
|
||||
"delivery_type": "mesh_dm",
|
||||
"node_ids": [node_id],
|
||||
"cooldown_minutes": 10,
|
||||
|
|
@ -776,6 +785,264 @@ class NotificationRouter:
|
|||
return categories if categories else ["all"]
|
||||
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):
|
||||
"""Clean up old entries from recent alerts cache."""
|
||||
now = time.time()
|
||||
|
|
|
|||
|
|
@ -1,64 +1,64 @@
|
|||
"""Message summarizer for mesh delivery."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..backends import LLMBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageSummarizer:
|
||||
"""Summarizes long messages for mesh delivery.
|
||||
|
||||
Only used when:
|
||||
- Delivering to mesh channels (broadcast or DM)
|
||||
- Message exceeds max_chars (default 200)
|
||||
- LLM backend is available
|
||||
|
||||
Email and webhook channels receive full messages.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_backend: Optional["LLMBackend"] = None):
|
||||
self._llm = llm_backend
|
||||
|
||||
async def summarize(self, message: str, max_chars: int = 195) -> str:
|
||||
"""Summarize a message to fit within max_chars.
|
||||
|
||||
Args:
|
||||
message: Original message text
|
||||
max_chars: Maximum characters for summary
|
||||
|
||||
Returns:
|
||||
Summarized message, or truncated original if LLM unavailable
|
||||
"""
|
||||
if len(message) <= max_chars:
|
||||
return message
|
||||
|
||||
if not self._llm:
|
||||
return message[:max_chars - 3] + "..."
|
||||
|
||||
prompt = (
|
||||
"Summarize this alert in under %d characters. "
|
||||
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
|
||||
% (max_chars, message)
|
||||
)
|
||||
|
||||
try:
|
||||
# Use the LLM to generate a summary
|
||||
response = await self._llm.generate(
|
||||
prompt,
|
||||
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
|
||||
max_tokens=100,
|
||||
)
|
||||
summary = response.strip()
|
||||
|
||||
# Ensure it fits
|
||||
if len(summary) <= max_chars:
|
||||
return summary
|
||||
return summary[:max_chars - 3] + "..."
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("LLM summarization failed: %s", e)
|
||||
return message[:max_chars - 3] + "..."
|
||||
"""Message summarizer for mesh delivery."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..backends import LLMBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageSummarizer:
|
||||
"""Summarizes long messages for mesh delivery.
|
||||
|
||||
Only used when:
|
||||
- Delivering to mesh channels (broadcast or DM)
|
||||
- Message exceeds max_chars (default 200)
|
||||
- LLM backend is available
|
||||
|
||||
Email and webhook channels receive full messages.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_backend: Optional["LLMBackend"] = None):
|
||||
self._llm = llm_backend
|
||||
|
||||
async def summarize(self, message: str, max_chars: int = 195) -> str:
|
||||
"""Summarize a message to fit within max_chars.
|
||||
|
||||
Args:
|
||||
message: Original message text
|
||||
max_chars: Maximum characters for summary
|
||||
|
||||
Returns:
|
||||
Summarized message, or truncated original if LLM unavailable
|
||||
"""
|
||||
if len(message) <= max_chars:
|
||||
return message
|
||||
|
||||
if not self._llm:
|
||||
return message[:max_chars - 3] + "..."
|
||||
|
||||
prompt = (
|
||||
"Summarize this alert in under %d characters. "
|
||||
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
|
||||
% (max_chars, message)
|
||||
)
|
||||
|
||||
try:
|
||||
# Use the LLM to generate a summary
|
||||
response = await self._llm.generate(
|
||||
prompt,
|
||||
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
|
||||
|
||||
)
|
||||
summary = response.strip()
|
||||
|
||||
# Ensure it fits
|
||||
if len(summary) <= max_chars:
|
||||
return summary
|
||||
return summary[:max_chars - 3] + "..."
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("LLM summarization failed: %s", e)
|
||||
return message[:max_chars - 3] + "..."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue