Compare commits

..

No commits in common. "344ca0677ddf22dfebf9d24ddba0e3e94607bfe1" and "32f6a238f8133c718b2fa5faf4a84d5f18d5d85d" have entirely different histories.

18 changed files with 1420 additions and 1777 deletions

View file

@ -119,18 +119,18 @@ mesh_sources: []
# enabled: true # enabled: true
# region_radius_miles: 40.0 # Radius for region clustering # region_radius_miles: 40.0 # Radius for region clustering
# locality_radius_miles: 8.0 # Radius for locality clustering # locality_radius_miles: 8.0 # Radius for locality clustering
# offline_threshold_hours: 2 # Hours before node considered offline # offline_threshold_hours: 24 # Hours before node considered offline
# packet_threshold: 500 # Non-text packets per 24h to flag # packet_threshold: 500 # Non-text packets per 24h to flag
# battery_warning_percent: 30 # Battery level for warnings # battery_warning_percent: 20 # Battery level for warnings
# infra_overrides: [] # Node IDs to exclude from infrastructure # infra_overrides: [] # Node IDs to exclude from infrastructure
# region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"} # region_labels: {} # Override auto-names: {"Twin Falls": "Magic Valley"}
mesh_intelligence: mesh_intelligence:
enabled: false enabled: false
region_radius_miles: 40.0 region_radius_miles: 40.0
locality_radius_miles: 8.0 locality_radius_miles: 8.0
offline_threshold_hours: 2 offline_threshold_hours: 24
packet_threshold: 500 packet_threshold: 500
battery_warning_percent: 30 battery_warning_percent: 20
infra_overrides: [] infra_overrides: []
region_labels: {} region_labels: {}
@ -217,13 +217,11 @@ environmental:
proximity_km: 10.0 # km to match known fire perimeters proximity_km: 10.0 # km to match known fire perimeters
# === NOTIFICATION DELIVERY (TRANSITIONAL) === # === NOTIFICATION DELIVERY ===
# NOTE: This notifications schema will be replaced in v0.3 by the 8-toggle model.
# These rule examples are transitional until Phase 1.2 lands. Do not extend.
# Severity levels: routine (informational), priority (needs attention), immediate (act now)
#
# Route alerts to channels (mesh, email, webhook) based on rules. # Route alerts to channels (mesh, email, webhook) based on rules.
# Categories match alert types from alert_engine.py. # Categories match alert types from alert_engine.py.
# Severity levels: info, advisory, watch, warning, critical, emergency
#
notifications: notifications:
enabled: false enabled: false
quiet_hours_enabled: true # Master toggle for quiet hours feature quiet_hours_enabled: true # Master toggle for quiet hours feature
@ -238,7 +236,7 @@ notifications:
enabled: true enabled: true
trigger_type: condition trigger_type: condition
categories: [] # Empty = all categories categories: [] # Empty = all categories
min_severity: "immediate" min_severity: "emergency"
delivery_type: mesh_broadcast delivery_type: mesh_broadcast
broadcast_channel: 0 broadcast_channel: 0
cooldown_minutes: 5 cooldown_minutes: 5
@ -249,7 +247,7 @@ notifications:
enabled: true enabled: true
trigger_type: condition trigger_type: condition
categories: ["infra_offline", "critical_node_down"] categories: ["infra_offline", "critical_node_down"]
min_severity: "priority" min_severity: "warning"
delivery_type: mesh_broadcast delivery_type: mesh_broadcast
broadcast_channel: 0 broadcast_channel: 0
cooldown_minutes: 30 cooldown_minutes: 30
@ -260,7 +258,7 @@ notifications:
enabled: true enabled: true
trigger_type: condition trigger_type: condition
categories: ["wildfire_proximity", "new_ignition"] categories: ["wildfire_proximity", "new_ignition"]
min_severity: "routine" min_severity: "advisory"
delivery_type: mesh_broadcast delivery_type: mesh_broadcast
broadcast_channel: 0 broadcast_channel: 0
cooldown_minutes: 60 cooldown_minutes: 60
@ -271,7 +269,7 @@ notifications:
enabled: true enabled: true
trigger_type: condition trigger_type: condition
categories: ["weather_warning"] categories: ["weather_warning"]
min_severity: "priority" min_severity: "warning"
delivery_type: mesh_broadcast delivery_type: mesh_broadcast
broadcast_channel: 0 broadcast_channel: 0
cooldown_minutes: 30 cooldown_minutes: 30
@ -282,7 +280,7 @@ notifications:
# enabled: true # enabled: true
# trigger_type: condition # trigger_type: condition
# categories: ["wildfire_proximity", "new_ignition"] # categories: ["wildfire_proximity", "new_ignition"]
# min_severity: "routine" # min_severity: "advisory"
# delivery_type: email # delivery_type: email
# smtp_host: "smtp.gmail.com" # smtp_host: "smtp.gmail.com"
# smtp_port: 587 # smtp_port: 587
@ -298,7 +296,7 @@ notifications:
# enabled: true # enabled: true
# trigger_type: condition # trigger_type: condition
# categories: [] # categories: []
# min_severity: "priority" # min_severity: "warning"
# delivery_type: webhook # delivery_type: webhook
# webhook_url: "https://discord.com/api/webhooks/..." # webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10 # cooldown_minutes: 10
@ -318,7 +316,7 @@ notifications:
# enabled: true # enabled: true
# trigger_type: condition # trigger_type: condition
# categories: ["battery_warning"] # categories: ["battery_warning"]
# min_severity: "priority" # min_severity: "warning"
# delivery_type: "" # Empty = no delivery, just tracks matches # delivery_type: "" # Empty = no delivery, just tracks matches
# === WEB DASHBOARD === # === WEB DASHBOARD ===

View file

@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
emergency: 'bg-red-700/20 text-red-200 border-red-700/30', emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
} }
function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) { function EventFeedItem({ event }: { event: EnvEvent }) {
const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source } const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
const Icon = sourceConfig.icon const Icon = sourceConfig.icon
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
@ -483,43 +483,18 @@ function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
} }
// Build display title: prefer event_type + area_desc, fall back to headline
const eventType = (event as Record<string, unknown>).event_type as string | undefined
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
const description = (event as Record<string, unknown>).description as string | undefined
let title = event.headline
if (eventType && areaDesc) {
// Shorten area description (remove "County" repetition)
const shortArea = areaDesc.replace(/ County/g, '').split(';')[0]
title = `${eventType}${shortArea}`
} else if (eventType) {
title = eventType
}
// Get first sentence of description as subtitle
const subtitle = description ? description.split('. ')[0] : null
return ( return (
<div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-blue-500 pl-2 -ml-2' : ''}`}> <div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} /> <Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}> <span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
{event.severity || 'info'} {event.severity || 'info'}
</span> </span>
{isLocal && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
LOCAL
</span>
)}
<span className="text-xs text-slate-500">{sourceConfig.label}</span> <span className="text-xs text-slate-500">{sourceConfig.label}</span>
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span> <span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
</div> </div>
<div className={`text-sm truncate ${isLocal ? 'text-slate-100' : 'text-slate-300'}`}>{title}</div> <div className="text-sm text-slate-200 truncate">{event.headline}</div>
{subtitle && (
<div className="text-xs text-slate-500 truncate mt-0.5">{subtitle}</div>
)}
</div> </div>
</div> </div>
) )
@ -527,31 +502,8 @@ function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean
// Live Event Feed Card // Live Event Feed Card
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) { function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
// Severity order for sorting
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
const sortedEvents = useMemo(() => { const sortedEvents = useMemo(() => {
// Dedup by event_id return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
const seen = new Set<string>()
const deduped = events.filter(e => {
if (!e.event_id) return true
if (seen.has(e.event_id)) return false
seen.add(e.event_id)
return true
})
// Sort: local first, then by severity, then by time
return deduped.sort((a, b) => {
const aLocal = (a as Record<string, unknown>).is_local ? 1 : 0
const bLocal = (b as Record<string, unknown>).is_local ? 1 : 0
if (aLocal !== bLocal) return bLocal - aLocal // local first
const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2
const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2
if (aSev !== bSev) return aSev - bSev // higher severity first
return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first
})
}, [events]) }, [events])
// Calculate feed health summary // Calculate feed health summary
@ -576,11 +528,7 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
{sortedEvents.length > 0 ? ( {sortedEvents.length > 0 ? (
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1"> <div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
{sortedEvents.map((event, i) => ( {sortedEvents.map((event, i) => (
<EventFeedItem <EventFeedItem key={event.event_id || i} event={event} />
key={event.event_id || i}
event={event}
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
/>
))} ))}
</div> </div>
) : ( ) : (

View file

@ -284,7 +284,6 @@ class MeshIntelligenceConfig:
locality_radius_miles: float = 8.0 # Radius for locality clustering within regions locality_radius_miles: float = 8.0 # Radius for locality clustering within regions
offline_threshold_hours: int = 2 # Hours before node considered offline offline_threshold_hours: int = 2 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag packet_threshold: int = 500 # Non-text packets per 24h to flag
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
battery_warning_percent: int = 30 # Battery level for warnings battery_warning_percent: int = 30 # Battery level for warnings
# Alert settings # Alert settings
@ -578,7 +577,7 @@ def _migrate_legacy_channels(notifications, data: dict):
enabled=ch.get("enabled", True), enabled=ch.get("enabled", True),
trigger_type="condition", trigger_type="condition",
categories=old_rule.get("categories", []), categories=old_rule.get("categories", []),
min_severity=old_rule.get("min_severity", "priority"), min_severity=old_rule.get("min_severity", "warning"),
delivery_type=ch.get("type", "mesh_broadcast"), delivery_type=ch.get("type", "mesh_broadcast"),
broadcast_channel=ch.get("channel_index", 0), broadcast_channel=ch.get("channel_index", 0),
node_ids=ch.get("node_ids", []), node_ids=ch.get("node_ids", []),

View file

@ -1,210 +1,192 @@
"""Environmental data API routes.""" """Environmental data API routes."""
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
router = APIRouter(tags=["environment"]) router = APIRouter(tags=["environment"])
@router.get("/env/status") @router.get("/env/status")
async def get_env_status(request: Request): async def get_env_status(request: Request):
"""Get environmental feeds status.""" """Get environmental feeds status."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False, "feeds": []} return {"enabled": False, "feeds": []}
return { return {
"enabled": True, "enabled": True,
"feeds": env_store.get_source_health(), "feeds": env_store.get_source_health(),
} }
@router.get("/env/active") @router.get("/env/active")
async def get_active_env(request: Request): async def get_active_env(request: Request):
"""Get active environmental events with local zone marking.""" """Get active environmental events."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return [] return []
events = env_store.get_active() return env_store.get_active()
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
# Dedup by event_id and add is_local field @router.get("/env/swpc")
seen_ids = set() async def get_swpc_data(request: Request):
result = [] """Get SWPC space weather data."""
for event in events: env_store = getattr(request.app.state, "env_store", None)
event_id = event.get("event_id")
if event_id and event_id in seen_ids: if not env_store:
continue return {"enabled": False}
if event_id:
seen_ids.add(event_id) status = env_store.get_swpc_status()
if not status:
# Mark as local if event zones overlap with configured mesh zones return {"enabled": False}
event_zones = set(event.get("areas", []))
event["is_local"] = bool(event_zones & mesh_zones) return {
result.append(event) "enabled": True,
**status,
return result }
@router.get("/env/swpc") @router.get("/env/propagation")
async def get_swpc_data(request: Request): async def get_rf_propagation(request: Request):
"""Get SWPC space weather data.""" """Get combined HF + UHF propagation data for dashboard."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return {"hf": {}, "uhf_ducting": {}}
status = env_store.get_swpc_status() return env_store.get_rf_propagation()
if not status:
return {"enabled": False}
@router.get("/env/ducting")
return { async def get_ducting_data(request: Request):
"enabled": True, """Get tropospheric ducting assessment."""
**status, env_store = getattr(request.app.state, "env_store", None)
}
if not env_store:
return {"enabled": False}
@router.get("/env/propagation")
async def get_rf_propagation(request: Request): status = env_store.get_ducting_status()
"""Get combined HF + UHF propagation data for dashboard.""" if not status:
env_store = getattr(request.app.state, "env_store", None) return {"enabled": False}
if not env_store: return {
return {"hf": {}, "uhf_ducting": {}} "enabled": True,
**status,
return env_store.get_rf_propagation() }
@router.get("/env/ducting") @router.get("/env/fires")
async def get_ducting_data(request: Request): async def get_fires_data(request: Request):
"""Get tropospheric ducting assessment.""" """Get active wildfire perimeters."""
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
if not env_store: if not env_store:
return {"enabled": False} return []
status = env_store.get_ducting_status() return env_store.get_active(source="nifc")
if not status:
return {"enabled": False}
@router.get("/env/avalanche")
return { async def get_avalanche_data(request: Request):
"enabled": True, """Get avalanche advisories."""
**status, env_store = getattr(request.app.state, "env_store", None)
}
if not env_store:
return {"off_season": True, "advisories": []}
@router.get("/env/fires")
async def get_fires_data(request: Request): adapters = getattr(env_store, "_adapters", {})
"""Get active wildfire perimeters.""" avy_adapter = adapters.get("avalanche")
env_store = getattr(request.app.state, "env_store", None)
if avy_adapter and avy_adapter.is_off_season():
if not env_store: return {"off_season": True, "advisories": []}
return []
return {
return env_store.get_active(source="nifc") "off_season": False,
"advisories": env_store.get_active(source="avalanche"),
}
@router.get("/env/avalanche")
async def get_avalanche_data(request: Request):
"""Get avalanche advisories.""" @router.get("/env/streams")
env_store = getattr(request.app.state, "env_store", None) async def get_streams_data(request: Request):
"""Get USGS stream gauge readings."""
if not env_store: env_store = getattr(request.app.state, "env_store", None)
return {"off_season": True, "advisories": []}
if not env_store:
adapters = getattr(env_store, "_adapters", {}) return []
avy_adapter = adapters.get("avalanche")
return env_store.get_active(source="usgs")
if avy_adapter and avy_adapter.is_off_season():
return {"off_season": True, "advisories": []}
@router.get("/env/usgs/lookup/{site_id}")
return { async def lookup_usgs_site(request: Request, site_id: str):
"off_season": False, """Lookup USGS site metadata and NWS flood stages.
"advisories": env_store.get_active(source="avalanche"),
} Returns site name, location, and flood stage thresholds from NWS NWPS.
Used by the config UI to auto-populate fields when adding a new gauge.
"""
@router.get("/env/streams") env_store = getattr(request.app.state, "env_store", None)
async def get_streams_data(request: Request):
"""Get USGS stream gauge readings.""" if not env_store:
env_store = getattr(request.app.state, "env_store", None) return {"error": "Environmental feeds not enabled"}
if not env_store: adapters = getattr(env_store, "_adapters", {})
return [] usgs_adapter = adapters.get("usgs")
return env_store.get_active(source="usgs") if not usgs_adapter:
# Create a temporary adapter for lookup
from meshai.env.usgs import USGSStreamsAdapter
@router.get("/env/usgs/lookup/{site_id}") from meshai.config import USGSConfig
async def lookup_usgs_site(request: Request, site_id: str): usgs_adapter = USGSStreamsAdapter(USGSConfig())
"""Lookup USGS site metadata and NWS flood stages.
try:
Returns site name, location, and flood stage thresholds from NWS NWPS. result = usgs_adapter.lookup_site(site_id)
Used by the config UI to auto-populate fields when adding a new gauge. return result
""" except Exception as e:
env_store = getattr(request.app.state, "env_store", None) return {"error": str(e), "site_id": site_id}
if not env_store:
return {"error": "Environmental feeds not enabled"} @router.get("/env/traffic")
async def get_traffic_data(request: Request):
adapters = getattr(env_store, "_adapters", {}) """Get TomTom traffic flow data."""
usgs_adapter = adapters.get("usgs") env_store = getattr(request.app.state, "env_store", None)
if not usgs_adapter: if not env_store:
# Create a temporary adapter for lookup return []
from meshai.env.usgs import USGSStreamsAdapter
from meshai.config import USGSConfig return env_store.get_active(source="traffic")
usgs_adapter = USGSStreamsAdapter(USGSConfig())
try: @router.get("/env/roads")
result = usgs_adapter.lookup_site(site_id) async def get_roads_data(request: Request):
return result """Get 511 road conditions."""
except Exception as e: env_store = getattr(request.app.state, "env_store", None)
return {"error": str(e), "site_id": site_id}
if not env_store:
return []
@router.get("/env/traffic")
async def get_traffic_data(request: Request): return env_store.get_active(source="511")
"""Get TomTom traffic flow data."""
env_store = getattr(request.app.state, "env_store", None)
@router.get("/env/hotspots")
if not env_store: async def get_hotspots_data(request: Request):
return [] """Get NASA FIRMS satellite fire hotspots."""
env_store = getattr(request.app.state, "env_store", None)
return env_store.get_active(source="traffic")
if not env_store:
return {"hotspots": [], "new_ignitions": 0}
@router.get("/env/roads")
async def get_roads_data(request: Request): firms_adapter = getattr(env_store, "_firms", None)
"""Get 511 road conditions."""
env_store = getattr(request.app.state, "env_store", None) if not firms_adapter:
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
if not env_store:
return [] hotspots = env_store.get_active(source="firms")
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
return env_store.get_active(source="511")
return {
"enabled": True,
@router.get("/env/hotspots") "hotspots": hotspots,
async def get_hotspots_data(request: Request): "new_ignitions": len(new_ignitions),
"""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),
}

View file

@ -1,305 +1,305 @@
"""Notification API routes with comprehensive testing.""" """Notification API routes with comprehensive testing."""
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
class TestRequest(BaseModel): class TestRequest(BaseModel):
"""Request body for test endpoint.""" """Request body for test endpoint."""
send: bool = False # Legacy: True = send_test send: bool = False # Legacy: True = send_test
action: str = "preview" # "preview", "send_test", "send_status", "send_live" action: str = "preview" # "preview", "send_test", "send_status", "send_live"
class ChannelTestRequest(BaseModel): class ChannelTestRequest(BaseModel):
"""Request body for channel connectivity test.""" """Request body for channel connectivity test."""
type: str # mesh_broadcast, mesh_dm, email, webhook type: str # mesh_broadcast, mesh_dm, email, webhook
# Mesh broadcast # Mesh broadcast
channel_index: Optional[int] = 0 channel_index: Optional[int] = 0
# Mesh DM # Mesh DM
node_ids: Optional[List[str]] = [] node_ids: Optional[List[str]] = []
# Email # Email
smtp_host: Optional[str] = "" smtp_host: Optional[str] = ""
smtp_port: Optional[int] = 587 smtp_port: Optional[int] = 587
smtp_user: Optional[str] = "" smtp_user: Optional[str] = ""
smtp_password: Optional[str] = "" smtp_password: Optional[str] = ""
smtp_tls: Optional[bool] = True smtp_tls: Optional[bool] = True
from_address: Optional[str] = "" from_address: Optional[str] = ""
recipients: Optional[List[str]] = [] recipients: Optional[List[str]] = []
# Webhook # Webhook
url: Optional[str] = "" url: Optional[str] = ""
headers: Optional[Dict[str, str]] = {} headers: Optional[Dict[str, str]] = {}
class RuleSourcesRequest(BaseModel): class RuleSourcesRequest(BaseModel):
"""Request body for rule sources health check.""" """Request body for rule sources health check."""
categories: List[str] = [] categories: List[str] = []
@router.get("/categories") @router.get("/categories")
async def get_categories(): async def get_categories():
"""Get all alert categories with descriptions.""" """Get all alert categories with descriptions."""
try: try:
from ...notifications.categories import list_categories from ...notifications.categories import list_categories
return list_categories() return list_categories()
except ImportError: except ImportError:
return [] return []
@router.get("/rules") @router.get("/rules")
async def get_rules(request: Request): async def get_rules(request: Request):
"""Get configured notification rules with stats.""" """Get configured notification rules with stats."""
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
return [] return []
rules = notification_router.get_rules() rules = notification_router.get_rules()
# Enhance rules with stats # Enhance rules with stats
result = [] result = []
for i, rule in enumerate(rules): for i, rule in enumerate(rules):
rule_copy = dict(rule) rule_copy = dict(rule)
stats = rule_copy.pop("_stats", {}) stats = rule_copy.pop("_stats", {})
rule_copy["stats"] = stats rule_copy["stats"] = stats
rule_copy["index"] = i rule_copy["index"] = i
result.append(rule_copy) result.append(rule_copy)
return result return result
@router.get("/rules/{rule_index}/stats") @router.get("/rules/{rule_index}/stats")
async def get_rule_stats(request: Request, rule_index: int): async def get_rule_stats(request: Request, rule_index: int):
"""Get statistics for a specific rule.""" """Get statistics for a specific rule."""
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
rules_config = getattr(request.app.state, "config", None) rules_config = getattr(request.app.state, "config", None)
if rules_config: if rules_config:
rules_config = getattr(rules_config, "rules", []) rules_config = getattr(rules_config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config): if rule_index < 0 or rule_index >= len(rules_config):
raise HTTPException(status_code=404, detail="Rule not found") raise HTTPException(status_code=404, detail="Rule not found")
rule = rules_config[rule_index] rule = rules_config[rule_index]
if hasattr(rule, "__dict__"): if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")} rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else: else:
rule_dict = dict(rule) rule_dict = dict(rule)
rule_name = rule_dict.get("name", f"Rule {rule_index}") rule_name = rule_dict.get("name", f"Rule {rule_index}")
return notification_router.get_rule_stats(rule_name) return notification_router.get_rule_stats(rule_name)
return {"last_fired": None, "last_test": None, "fire_count": 0} return {"last_fired": None, "last_test": None, "fire_count": 0}
@router.post("/channels/test") @router.post("/channels/test")
async def test_channel(request: Request, body: ChannelTestRequest): async def test_channel(request: Request, body: ChannelTestRequest):
"""Test channel connectivity without sending actual alert content. """Test channel connectivity without sending actual alert content.
Returns: Returns:
{ {
"success": bool, "success": bool,
"message": str, # Human-readable result "message": str, # Human-readable result
"error": str, # Detailed error if failed "error": str, # Detailed error if failed
"details": {} # Channel-specific details "details": {} # Channel-specific details
} }
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
# Build channel config from request # Build channel config from request
channel_config = {"type": body.type} channel_config = {"type": body.type}
if body.type == "mesh_broadcast": if body.type == "mesh_broadcast":
channel_config["channel_index"] = body.channel_index or 0 channel_config["channel_index"] = body.channel_index or 0
elif body.type == "mesh_dm": elif body.type == "mesh_dm":
channel_config["node_ids"] = body.node_ids or [] channel_config["node_ids"] = body.node_ids or []
elif body.type == "email": elif body.type == "email":
channel_config.update({ channel_config.update({
"smtp_host": body.smtp_host or "", "smtp_host": body.smtp_host or "",
"smtp_port": body.smtp_port or 587, "smtp_port": body.smtp_port or 587,
"smtp_user": body.smtp_user or "", "smtp_user": body.smtp_user or "",
"smtp_password": body.smtp_password or "", "smtp_password": body.smtp_password or "",
"smtp_tls": body.smtp_tls if body.smtp_tls is not None else True, "smtp_tls": body.smtp_tls if body.smtp_tls is not None else True,
"from_address": body.from_address or "", "from_address": body.from_address or "",
"recipients": body.recipients or [], "recipients": body.recipients or [],
}) })
elif body.type == "webhook": elif body.type == "webhook":
channel_config.update({ channel_config.update({
"url": body.url or "", "url": body.url or "",
"headers": body.headers or {}, "headers": body.headers or {},
}) })
else: else:
return { return {
"success": False, "success": False,
"message": "Unknown channel type", "message": "Unknown channel type",
"error": f"Channel type '{body.type}' is not supported", "error": f"Channel type '{body.type}' is not supported",
"details": {} "details": {}
} }
result = await notification_router.test_channel(channel_config) result = await notification_router.test_channel(channel_config)
return result return result
@router.post("/rules/{rule_index}/test") @router.post("/rules/{rule_index}/test")
async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None): async def test_rule(request: Request, rule_index: int, body: Optional[TestRequest] = None):
"""Test a notification rule against current conditions. """Test a notification rule against current conditions.
Returns comprehensive test result including: Returns comprehensive test result including:
- Live data from relevant environmental feeds - Live data from relevant environmental feeds
- Matching alerts (conditions that would fire) - Matching alerts (conditions that would fire)
- Near-misses (filtered by severity threshold) - Near-misses (filtered by severity threshold)
- Preview messages and delivery status - Preview messages and delivery status
- Source health (which feeds are enabled) - Source health (which feeds are enabled)
- Rule statistics (last fired, fire count) - Rule statistics (last fired, fire count)
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
action = body.action if body else "preview" action = body.action if body else "preview"
send = body.send if body else False send = body.send if body else False
# Legacy support # Legacy support
if send and action == "preview": if send and action == "preview":
action = "send_test" action = "send_test"
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action=action, action=action,
) )
return result return result
@router.post("/rules/{rule_index}/preview") @router.post("/rules/{rule_index}/preview")
async def preview_rule(request: Request, rule_index: int): async def preview_rule(request: Request, rule_index: int):
"""Preview what a rule would match right now (without sending).""" """Preview what a rule would match right now (without sending)."""
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="preview", action="preview",
) )
return result return result
@router.post("/rules/sources") @router.post("/rules/sources")
async def get_rule_sources(request: Request, body: RuleSourcesRequest): async def get_rule_sources(request: Request, body: RuleSourcesRequest):
"""Get data source health for a set of categories. """Get data source health for a set of categories.
Returns per-category source status: Returns per-category source status:
{ {
"category_id": { "category_id": {
"enabled": true/false, "enabled": true/false,
"active_events": number, "active_events": number,
"source": "nws"/"swpc"/etc, "source": "nws"/"swpc"/etc,
"status": "ok"/"disabled"/"no_data" "status": "ok"/"disabled"/"no_data"
} }
} }
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
return notification_router.get_source_health(body.categories, env_store) return notification_router.get_source_health(body.categories, env_store)
@router.post("/rules/{rule_index}/send-status") @router.post("/rules/{rule_index}/send-status")
async def send_rule_status(request: Request, rule_index: int): async def send_rule_status(request: Request, rule_index: int):
"""Send current conditions summary through a rule's channel. """Send current conditions summary through a rule's channel.
Formats current live data as a readable message and delivers Formats current live data as a readable message and delivers
through the rule's configured channel with [STATUS] prefix. through the rule's configured channel with [STATUS] prefix.
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="send_status", action="send_status",
) )
return result return result
@router.post("/rules/{rule_index}/send-test") @router.post("/rules/{rule_index}/send-test")
async def send_rule_test(request: Request, rule_index: int): async def send_rule_test(request: Request, rule_index: int):
"""Send example alert message through a rule's channel. """Send example alert message through a rule's channel.
Sends the example_message from the rule's first category Sends the example_message from the rule's first category
through the configured channel with [TEST] prefix. through the configured channel with [TEST] prefix.
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="send_test", action="send_test",
) )
return result return result
@router.post("/rules/{rule_index}/send-live") @router.post("/rules/{rule_index}/send-live")
async def send_rule_live(request: Request, rule_index: int): async def send_rule_live(request: Request, rule_index: int):
"""Send actual live alert through a rule's channel. """Send actual live alert through a rule's channel.
Only available when there are matching conditions. Only available when there are matching conditions.
Sends one of the actual matching alerts with [LIVE TEST] prefix. Sends one of the actual matching alerts with [LIVE TEST] prefix.
""" """
notification_router = getattr(request.app.state, "notification_router", None) notification_router = getattr(request.app.state, "notification_router", None)
if not notification_router: if not notification_router:
raise HTTPException(status_code=404, detail="Notification router not configured") raise HTTPException(status_code=404, detail="Notification router not configured")
alert_engine = getattr(request.app.state, "alert_engine", None) alert_engine = getattr(request.app.state, "alert_engine", None)
env_store = getattr(request.app.state, "env_store", None) env_store = getattr(request.app.state, "env_store", None)
health_engine = getattr(request.app.state, "health_engine", None) health_engine = getattr(request.app.state, "health_engine", None)
result = await notification_router.test_rule_with_conditions( result = await notification_router.test_rule_with_conditions(
rule_index, rule_index,
alert_engine=alert_engine, alert_engine=alert_engine,
env_store=env_store, env_store=env_store,
health_engine=health_engine, health_engine=health_engine,
action="send_live", action="send_live",
) )
return result return result

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-Bildyb1E.js"></script> <script type="module" crossorigin src="/assets/index-BXyt_EfK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QhNRb-ap.css"> <link rel="stylesheet" crossorigin href="/assets/index-CtFYHJy4.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -265,7 +265,6 @@ class MeshAI:
self.data_store = MeshDataStore( self.data_store = MeshDataStore(
source_configs=enabled_sources, source_configs=enabled_sources,
db_path="/data/mesh_history.db", db_path="/data/mesh_history.db",
offline_threshold_hours=self.config.mesh_intelligence.offline_threshold_hours,
) )
# Initial fetch and backfill # Initial fetch and backfill
self.data_store.force_refresh() self.data_store.force_refresh()
@ -339,18 +338,18 @@ class MeshAI:
) )
logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})") logger.info(f"Alert engine initialized (critical: {mi.critical_nodes}, channel: {mi.alert_channel})")
# Notification router # Notification router
if self.config.notifications.enabled: if self.config.notifications.enabled:
from .notifications.router import NotificationRouter from .notifications.router import NotificationRouter
self.notification_router = NotificationRouter( self.notification_router = NotificationRouter(
config=self.config.notifications, config=self.config.notifications,
connector=self.connector, connector=self.connector,
llm_backend=self.llm, llm_backend=self.llm,
timezone=self.config.timezone, timezone=self.config.timezone,
) )
logger.info("Notification router initialized") logger.info("Notification router initialized")
# Environmental feeds # Environmental feeds
env_cfg = self.config.environmental env_cfg = self.config.environmental
if env_cfg.enabled: if env_cfg.enabled:
@ -555,48 +554,48 @@ class MeshAI:
if pid_file.exists(): if pid_file.exists():
pid_file.unlink() pid_file.unlink()
async def _dispatch_alerts(self, alerts: list[dict]) -> None: async def _dispatch_alerts(self, alerts: list[dict]) -> None:
"""Dispatch alerts to subscribers and alert channel.""" """Dispatch alerts to subscribers and alert channel."""
mi = self.config.mesh_intelligence mi = self.config.mesh_intelligence
alert_channel = getattr(mi, 'alert_channel', -1) alert_channel = getattr(mi, 'alert_channel', -1)
for alert in alerts: for alert in alerts:
message = alert["message"] message = alert["message"]
logger.info(f"ALERT: {message}") logger.info(f"ALERT: {message}")
# Route through notification router if enabled # Route through notification router if enabled
if self.notification_router: if self.notification_router:
try: try:
await self.notification_router.process_alert(alert) await self.notification_router.process_alert(alert)
except Exception as e: except Exception as e:
logger.error(f"Notification router error: {e}") logger.error(f"Notification router error: {e}")
# Fallback: Send to alert channel if no notification router # Fallback: Send to alert channel if no notification router
elif alert_channel >= 0 and self.connector: elif alert_channel >= 0 and self.connector:
try: try:
self.connector.send_message( self.connector.send_message(
text=message, text=message,
destination=None, destination=None,
channel=alert_channel, channel=alert_channel,
) )
logger.info(f"Alert sent to channel {alert_channel}") logger.info(f"Alert sent to channel {alert_channel}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send channel alert: {e}") logger.error(f"Failed to send channel alert: {e}")
# Fallback: Send DMs to matching subscribers # Fallback: Send DMs to matching subscribers
if self.alert_engine and self.subscription_manager: if self.alert_engine and self.subscription_manager:
subscribers = self.alert_engine.get_subscribers_for_alert(alert) subscribers = self.alert_engine.get_subscribers_for_alert(alert)
for sub in subscribers: for sub in subscribers:
user_id = sub["user_id"] user_id = sub["user_id"]
try: try:
await self._send_sub_dm(user_id, message) await self._send_sub_dm(user_id, message)
logger.info(f"Alert DM sent to {user_id}: {alert['type']}") logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
except Exception as e: except Exception as e:
logger.error(f"Failed to send alert DM to {user_id}: {e}") logger.error(f"Failed to send alert DM to {user_id}: {e}")
if self.alert_engine: if self.alert_engine:
self.alert_engine.clear_pending() self.alert_engine.clear_pending()
async def _check_scheduled_subs(self) -> None: async def _check_scheduled_subs(self) -> None:
"""Check for and deliver due scheduled reports.""" """Check for and deliver due scheduled reports."""
from datetime import datetime from datetime import datetime

View file

@ -230,19 +230,16 @@ class MeshDataStore:
self, self,
source_configs: list[MeshSourceConfig], source_configs: list[MeshSourceConfig],
db_path: str = "/data/mesh_history.db", db_path: str = "/data/mesh_history.db",
offline_threshold_hours: int = 2,
): ):
"""Initialize the data store. """Initialize the data store.
Args: Args:
source_configs: List of source configurations source_configs: List of source configurations
db_path: Path to SQLite database for historical data db_path: Path to SQLite database for historical data
offline_threshold_hours: Hours before a node is considered offline
""" """
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {} self._sources: dict[str, MeshviewSource | MeshMonitorDataSource | MQTTSource] = {}
self._db_path = db_path self._db_path = db_path
self._db: Optional[sqlite3.Connection] = None self._db: Optional[sqlite3.Connection] = None
self._offline_threshold_hours = offline_threshold_hours
# Live state # Live state
self._nodes: dict[int, UnifiedNode] = {} self._nodes: dict[int, UnifiedNode] = {}
@ -748,13 +745,11 @@ class MeshDataStore:
node.last_heard = ts or 0.0 node.last_heard = ts or 0.0
# Compute is_online based on configured threshold # NOTE: is_online is set by MeshHealthEngine.compute() using the
# This ensures correct status immediately, before health engine runs # configured offline_threshold_hours. Don't set it here with a
if node.last_heard: # hardcoded value - let the health engine determine online status.
offline_threshold = time.time() - (self._offline_threshold_hours * 3600) # The health engine runs on every refresh cycle and will set is_online
node.is_online = node.last_heard > offline_threshold # based on: (now - last_heard) < (offline_threshold_hours * 3600)
else:
node.is_online = False
# Hops, SNR, RSSI (MM) # Hops, SNR, RSSI (MM)
node.hops_away = raw.get("hopsAway") node.hops_away = raw.get("hopsAway")
@ -2116,7 +2111,7 @@ class MeshDataStore:
infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"} infra_roles = {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE", "REPEATER"}
return [n for n in self._nodes.values() if n.role in infra_roles] return [n for n in self._nodes.values() if n.role in infra_roles]
def get_low_battery_nodes(self, threshold: float = 30.0) -> list[UnifiedNode]: def get_low_battery_nodes(self, threshold: float = 20.0) -> list[UnifiedNode]:
"""Get nodes with low battery.""" """Get nodes with low battery."""
return [ return [
n n

View file

@ -28,7 +28,6 @@ INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
DEFAULT_LOCALITY_RADIUS_MILES = 8.0 DEFAULT_LOCALITY_RADIUS_MILES = 8.0
DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline DEFAULT_OFFLINE_THRESHOLD_HOURS = 2 # Hours before node considered offline
DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg) DEFAULT_PACKET_THRESHOLD = 7200 # Non-text packets per 24h (5/min avg)
# TODO: behavior pillar uses wrong scale - see meshai-v03-notification-handoff.md bug #2
# NOTE: This is aligned with notification config's packet_flood threshold. # NOTE: This is aligned with notification config's packet_flood threshold.
# 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day. # 5 packets/min avg × 60 min × 24 hr = 7,200 packets/day.
# A node averaging 5+ non-text packets/min is misbehaving. # A node averaging 5+ non-text packets/min is misbehaving.

View file

@ -630,7 +630,7 @@ class MeshReporter:
usb += 1 usb += 1
elif node.battery_percent >= 50: elif node.battery_percent >= 50:
ok += 1 ok += 1
elif node.battery_percent >= 30: elif node.battery_percent >= 20:
low += 1 low += 1
else: else:
critical += 1 critical += 1

View file

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

View file

@ -292,7 +292,7 @@ class EmailChannel(NotificationChannel):
return False return False
alert_type = alert.get("type", "alert") alert_type = alert.get("type", "alert")
severity = alert.get("severity", "routine").upper() severity = alert.get("severity", "info").upper()
message = alert.get("message", "") message = alert.get("message", "")
subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title()) subject = "[MeshAI %s] %s" % (severity, alert_type.replace("_", " ").title())
body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % ( body = "MeshAI Alert\n\nType: %s\nSeverity: %s\nTime: %s\n\n%s\n\n---\nAutomated message from MeshAI." % (
@ -518,7 +518,7 @@ class WebhookChannel(NotificationChannel):
"""POST alert to webhook URL.""" """POST alert to webhook URL."""
payload = { payload = {
"type": alert.get("type"), "type": alert.get("type"),
"severity": alert.get("severity", "routine"), "severity": alert.get("severity", "info"),
"message": alert.get("message", ""), "message": alert.get("message", ""),
"timestamp": time.time(), "timestamp": time.time(),
"node_name": alert.get("node_name"), "node_name": alert.get("node_name"),
@ -527,7 +527,7 @@ class WebhookChannel(NotificationChannel):
# Discord/Slack format # Discord/Slack format
if "discord.com" in self._url or "slack.com" in self._url: if "discord.com" in self._url or "slack.com" in self._url:
severity = alert.get("severity", "routine") severity = alert.get("severity", "info")
color = { color = {
"immediate": 0xFF0000, "immediate": 0xFF0000,
"priority": 0xFFAA00, "priority": 0xFFAA00,
@ -669,7 +669,7 @@ class WebhookChannel(NotificationChannel):
else: else:
payload = { payload = {
"type": "test", "type": "test",
"severity": "routine", "severity": "info",
"message": "MeshAI channel connectivity test", "message": "MeshAI channel connectivity test",
"timestamp": time.time(), "timestamp": time.time(),
} }
@ -730,7 +730,7 @@ class WebhookChannel(NotificationChannel):
async def deliver_test(self, message: str) -> tuple[bool, str]: async def deliver_test(self, message: str) -> tuple[bool, str]:
"""Deliver a specific test message via webhook.""" """Deliver a specific test message via webhook."""
try: try:
test_alert = {"type": "test", "severity": "routine", "message": message} test_alert = {"type": "test", "severity": "info", "message": message}
success = await self.deliver(test_alert, {}) success = await self.deliver(test_alert, {})
if success: if success:
try: try:

View file

@ -40,7 +40,6 @@ class NotificationRouter:
self._timezone = timezone self._timezone = timezone
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
self._llm = llm_backend
self._connector = connector self._connector = connector
self._config = config self._config = config
@ -150,7 +149,7 @@ class NotificationRouter:
async def process_alert(self, alert: dict) -> bool: async def process_alert(self, alert: dict) -> bool:
"""Route an alert through matching rules.""" """Route an alert through matching rules."""
category = alert.get("type", "") category = alert.get("type", "")
severity = alert.get("severity", "routine") severity = alert.get("severity", "info")
delivered = False delivered = False
for rule in self._rules: for rule in self._rules:
@ -160,7 +159,7 @@ class NotificationRouter:
if rule_categories and category not in rule_categories: if rule_categories and category not in rule_categories:
continue continue
min_severity = rule.get("min_severity", "routine") min_severity = rule.get("min_severity", "info")
if not self._severity_meets(severity, min_severity): if not self._severity_meets(severity, min_severity):
continue continue
@ -392,7 +391,7 @@ class NotificationRouter:
rule_name = rule_dict.get("name", f"Rule {rule_index}") rule_name = rule_dict.get("name", f"Rule {rule_index}")
rule_categories = rule_dict.get("categories", []) rule_categories = rule_dict.get("categories", [])
min_severity = rule_dict.get("min_severity", "routine") min_severity = rule_dict.get("min_severity", "info")
delivery_type = rule_dict.get("delivery_type", "") delivery_type = rule_dict.get("delivery_type", "")
# Legacy support # Legacy support
@ -536,7 +535,7 @@ class NotificationRouter:
for alert in alert_engine.get_pending_alerts(): for alert in alert_engine.get_pending_alerts():
all_events.append({ all_events.append({
"type": alert.get("type", ""), "type": alert.get("type", ""),
"severity": alert.get("severity", "routine"), "severity": alert.get("severity", "info"),
"message": alert.get("message", ""), "message": alert.get("message", ""),
"headline": alert.get("message", "")[:80], "headline": alert.get("message", "")[:80],
}) })
@ -548,7 +547,7 @@ class NotificationRouter:
for event in env_store.get_active(): for event in env_store.get_active():
all_events.append({ all_events.append({
"type": event.get("type", event.get("category", "")), "type": event.get("type", event.get("category", "")),
"severity": event.get("severity", "routine"), "severity": event.get("severity", "info"),
"message": event.get("message", event.get("headline", str(event))), "message": event.get("message", event.get("headline", str(event))),
"headline": event.get("headline", event.get("message", "Event"))[:80], "headline": event.get("headline", event.get("message", "Event"))[:80],
}) })
@ -625,20 +624,12 @@ class NotificationRouter:
channel = self._create_channel_for_rule(rule_dict) channel = self._create_channel_for_rule(rule_dict)
if channel: if channel:
try: try:
if action == "send_status": if action == "send_status" and live_data_lines:
# Determine report type from rule categories # Filter out the warning line for status message
report_type = "all" data_lines = [l for l in live_data_lines if not l.startswith("[!]")]
if rule_categories: status_msg = "[STATUS] " + " | ".join(data_lines[:4])
if any(c in rule_categories for c in ["hf_blackout", "geomagnetic_storm", "tropospheric_ducting"]): if len(status_msg) > 200:
report_type = "rf_propagation" status_msg = status_msg[:195] + "..."
elif any(c in rule_categories for c in ["infra_offline", "critical_node_down", "mesh_score_low", "battery_warning"]):
report_type = "mesh_health"
elif any(c in rule_categories for c in ["weather_warning", "fire_proximity", "new_ignition"]):
report_type = "weather_fire"
status_msg = await self.generate_report(report_type, env_store, health_engine)
if len(status_msg) > 195:
status_msg = status_msg[:192] + "..."
success, result = await channel.deliver_test(status_msg) success, result = await channel.deliver_test(status_msg)
delivered = success delivered = success
delivery_result = result if success else f"Failed: {result}" delivery_result = result if success else f"Failed: {result}"
@ -646,9 +637,9 @@ class NotificationRouter:
delivery_error = result delivery_error = result
elif action == "send_live" and matching_alerts: elif action == "send_live" and matching_alerts:
live_msg = matching_alerts[0].get('message', '') live_msg = f"[LIVE TEST] {matching_alerts[0].get('message', '')}"
if len(live_msg) > 195: if len(live_msg) > 200:
live_msg = live_msg[:192] + "..." live_msg = live_msg[:195] + "..."
success, result = await channel.deliver_test(live_msg) success, result = await channel.deliver_test(live_msg)
delivered = success delivered = success
delivery_result = result if success else f"Failed: {result}" delivery_result = result if success else f"Failed: {result}"
@ -761,7 +752,7 @@ class NotificationRouter:
"enabled": True, "enabled": True,
"trigger_type": "condition", "trigger_type": "condition",
"categories": categories if categories else [], "categories": categories if categories else [],
"min_severity": "priority", "min_severity": "warning",
"delivery_type": "mesh_dm", "delivery_type": "mesh_dm",
"node_ids": [node_id], "node_ids": [node_id],
"cooldown_minutes": 10, "cooldown_minutes": 10,
@ -785,264 +776,6 @@ class NotificationRouter:
return categories if categories else ["all"] return categories if categories else ["all"]
return [] return []
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
"""Generate an LLM-summarized report from current data."""
context_parts = []
# For RF propagation, use deterministic formatter
swpc_data = None
ducting_data = None
if report_type in ("rf_propagation", "all"):
if env_store:
adapters = getattr(env_store, '_adapters', {})
if "swpc" in adapters and hasattr(env_store, 'get_swpc_status'):
swpc_data = env_store.get_swpc_status()
if "ducting" in adapters and hasattr(env_store, 'get_ducting_status'):
ducting_data = env_store.get_ducting_status()
# If this is an RF-only report, return deterministic format immediately
if report_type == "rf_propagation" and swpc_data:
sfi = swpc_data.get('sfi', 100)
kp = swpc_data.get('kp_current', 3)
try:
band_conditions = self._compute_band_conditions(float(sfi), float(kp))
return self._format_propagation_report(swpc_data, ducting_data, band_conditions)
except Exception as e:
logger.warning("Band condition calc failed: %s", e)
# Fall through to context-based approach
# For "all" report type, add RF data to context
if swpc_data:
context_parts.append(
f"Solar/Geomagnetic: SFI {swpc_data.get('sfi')}, "
f"Kp {swpc_data.get('kp_current')}, "
f"R{swpc_data.get('r_scale', 0)}/S{swpc_data.get('s_scale', 0)}/G{swpc_data.get('g_scale', 0)}"
)
if ducting_data:
context_parts.append(
f"Tropospheric: {ducting_data.get('condition', 'unknown')}, "
f"dM/dz {ducting_data.get('min_gradient', 'N/A')} M-units/km"
)
if report_type in ("mesh_health", "all"):
if health_engine:
health = getattr(health_engine, 'mesh_health', None)
if health and hasattr(health, 'score'):
score = health.score
context_parts.append(
f"Mesh: score {score.composite:.0f}/100, "
f"tier {score.tier}, "
f"{score.infra_online}/{score.infra_total} infra online, "
f"utilization {score.util_percent:.1f}%"
)
if report_type in ("weather_fire", "all"):
if env_store and hasattr(env_store, 'get_active'):
nws = env_store.get_active(source="nws")
fires = env_store.get_active(source="nifc")
if nws:
headlines = [e.get("headline", "")[:80] for e in nws[:3]]
context_parts.append(f"Weather: {len(nws)} active alerts: {'; '.join(headlines)}")
else:
context_parts.append("Weather: No active alerts")
if fires:
context_parts.append(f"Fires: {len(fires)} active")
else:
context_parts.append("Fires: None active")
if report_type in ("environmental", "all"):
if env_store and hasattr(env_store, 'get_active'):
for source in ["usgs", "traffic", "roads511", "avalanche"]:
events = env_store.get_active(source=source)
if events:
context_parts.append(f"{source.upper()}: {len(events)} events")
if not context_parts:
# Return a graceful message for the specific report type
no_data_messages = {
"rf_propagation": "RF propagation data not available",
"mesh_health": "Mesh health data not available",
"weather_fire": "Weather/fire monitoring not configured",
"environmental": "Environmental monitoring not configured",
"all": "No monitoring data available",
}
return no_data_messages.get(report_type, "No data available")
raw_data = "\n".join(context_parts)
# Generate LLM summary
if self._llm:
prompt = self._build_report_prompt(report_type, raw_data)
try:
messages = [{"role": "user", "content": prompt}]
summary = await self._llm.generate(
messages=messages,
system_prompt="You are a concise infrastructure status reporter. Format data clearly and briefly. Output only the formatted report, no preamble or explanation.",
)
return summary.strip()
except Exception as e:
logger.warning("LLM report generation failed: %s", e)
return raw_data
else:
return raw_data
def _compute_band_conditions(self, sfi: float, kp: float) -> dict:
"""Deterministic band conditions from SFI and Kp."""
from datetime import datetime, timezone
hour_utc = datetime.now(timezone.utc).hour
is_day = 12 <= hour_utc or hour_utc <= 3 # rough UTC daytime for US
bands = {}
# 10m (28 MHz) - needs high SFI, daytime only
if sfi > 140 and kp <= 2:
bands["10m"] = "Good"
elif sfi > 100 and kp <= 4 and is_day:
bands["10m"] = "Fair"
else:
bands["10m"] = "Poor"
# 12m (24 MHz)
if sfi > 120 and kp <= 3:
bands["12m"] = "Good"
elif sfi > 90 and kp <= 4 and is_day:
bands["12m"] = "Fair"
else:
bands["12m"] = "Poor"
# 15m (21 MHz)
if sfi > 100 and kp <= 3:
bands["15m"] = "Good"
elif sfi > 80 and kp <= 5:
bands["15m"] = "Fair"
else:
bands["15m"] = "Poor"
# 17m (18 MHz)
if sfi > 90 and kp <= 3:
bands["17m"] = "Good"
elif sfi > 70 and kp <= 5:
bands["17m"] = "Fair"
else:
bands["17m"] = "Poor"
# 20m (14 MHz) - almost always usable
if sfi > 80 and kp <= 3:
bands["20m"] = "Good"
elif kp <= 6:
bands["20m"] = "Fair"
else:
bands["20m"] = "Poor"
# 30m (10 MHz) - very reliable
if kp <= 4:
bands["30m"] = "Good"
elif kp <= 6:
bands["30m"] = "Fair"
else:
bands["30m"] = "Poor"
# 40m (7 MHz) - reliable, better at night
if kp <= 4:
bands["40m"] = "Good"
elif kp <= 6:
bands["40m"] = "Fair"
else:
bands["40m"] = "Poor"
# 80m (3.5 MHz) - night band
if kp <= 3:
bands["80m"] = "Good"
elif kp <= 5:
bands["80m"] = "Fair"
else:
bands["80m"] = "Poor"
return bands
def _format_propagation_report(self, swpc: dict, ducting: dict, band_conditions: dict) -> str:
"""Format propagation report deterministically."""
sfi = swpc.get('sfi', 'N/A')
kp = swpc.get('kp_current', 'N/A')
lines = [f"Band Conditions (SFI {sfi}, Kp {kp}):"]
for band_list, label in [
(["80m", "40m"], "80-40m"),
(["30m", "20m"], "30-20m"),
(["17m", "15m"], "17-15m"),
(["12m", "10m"], "12-10m"),
]:
# Take the better of the two bands in range
ratings = [band_conditions.get(b, "Poor") for b in band_list]
if "Good" in ratings:
rating = "Good"
elif "Fair" in ratings:
rating = "Fair"
else:
rating = "Poor"
lines.append(f"{label}: {rating}")
if ducting and ducting.get("condition") and ducting.get("condition") != "normal":
cond = ducting["condition"].replace("_", " ").title()
lines.append(f"Tropo: {cond}")
else:
lines.append("Tropo: Normal")
return "\n".join(lines)
def _build_report_prompt(self, report_type: str, raw_data: str) -> str:
"""Build the LLM prompt for report generation."""
prompts = {
"rf_propagation": (
"Format this propagation data as a band-by-band HF report. "
"Output format:\n"
"Band Conditions (SFI X, Kp Y):\n"
"80-40m: [Good/Fair/Poor]\n"
"30-20m: [Good/Fair/Poor]\n"
"17-15m: [Good/Fair/Poor]\n"
"12-10m: [Good/Fair/Poor]\n"
"Tropo: [Normal/Enhanced/Ducting]\n\n"
"Determine ratings from SFI and Kp values. "
"Output ONLY the formatted report.\n\n"
f"Data:\n{raw_data}"
),
"mesh_health": (
"Format this mesh network health data as a brief status. "
"Include: score out of 100, tier name, infrastructure count, "
"and any problems. If healthy, say 'Mesh healthy' with key stats. "
"Example: 'Mesh healthy: 90/100, 16/16 infra online, 20% util'\n"
"Keep it concise. Output ONLY the status line.\n\n"
f"Data:\n{raw_data}"
),
"weather_fire": (
"Format these weather/fire alerts as a brief summary. "
"List count of alerts and most severe conditions. "
"Example: '3 weather alerts: Winter Storm Warning (ID), "
"Wind Advisory (OR). No active fires.'\n"
"Keep it concise. Output ONLY the summary.\n\n"
f"Data:\n{raw_data}"
),
"environmental": (
"Summarize all environmental conditions briefly. "
"Cover weather alerts, fires, streams, roads. "
"Example: 'Weather clear, no fires, 2 streams elevated, "
"roads open.'\n"
"Keep it concise. Output ONLY the summary.\n\n"
f"Data:\n{raw_data}"
),
"all": (
"Summarize all conditions for a mesh network operator. "
"Cover: mesh health score, any infrastructure issues, "
"weather alerts, fire status, propagation conditions. "
"Be brief but complete.\n\n"
f"Data:\n{raw_data}"
),
}
return prompts.get(report_type, prompts["all"])
def cleanup_recent(self, max_age: int = 3600): def cleanup_recent(self, max_age: int = 3600):
"""Clean up old entries from recent alerts cache.""" """Clean up old entries from recent alerts cache."""
now = time.time() now = time.time()

View file

@ -1,64 +1,64 @@
"""Message summarizer for mesh delivery.""" """Message summarizer for mesh delivery."""
import logging import logging
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from ..backends import LLMBackend from ..backends import LLMBackend
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MessageSummarizer: class MessageSummarizer:
"""Summarizes long messages for mesh delivery. """Summarizes long messages for mesh delivery.
Only used when: Only used when:
- Delivering to mesh channels (broadcast or DM) - Delivering to mesh channels (broadcast or DM)
- Message exceeds max_chars (default 200) - Message exceeds max_chars (default 200)
- LLM backend is available - LLM backend is available
Email and webhook channels receive full messages. Email and webhook channels receive full messages.
""" """
def __init__(self, llm_backend: Optional["LLMBackend"] = None): def __init__(self, llm_backend: Optional["LLMBackend"] = None):
self._llm = llm_backend self._llm = llm_backend
async def summarize(self, message: str, max_chars: int = 195) -> str: async def summarize(self, message: str, max_chars: int = 195) -> str:
"""Summarize a message to fit within max_chars. """Summarize a message to fit within max_chars.
Args: Args:
message: Original message text message: Original message text
max_chars: Maximum characters for summary max_chars: Maximum characters for summary
Returns: Returns:
Summarized message, or truncated original if LLM unavailable Summarized message, or truncated original if LLM unavailable
""" """
if len(message) <= max_chars: if len(message) <= max_chars:
return message return message
if not self._llm: if not self._llm:
return message[:max_chars - 3] + "..." return message[:max_chars - 3] + "..."
prompt = ( prompt = (
"Summarize this alert in under %d characters. " "Summarize this alert in under %d characters. "
"Keep severity, location, and key facts. No preamble, just the summary:\n\n%s" "Keep severity, location, and key facts. No preamble, just the summary:\n\n%s"
% (max_chars, message) % (max_chars, message)
) )
try: try:
# Use the LLM to generate a summary # Use the LLM to generate a summary
response = await self._llm.generate( response = await self._llm.generate(
prompt, prompt,
system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.", system_prompt="You are a concise alert summarizer. Output only the summary, no explanation.",
max_tokens=100,
) )
summary = response.strip() summary = response.strip()
# Ensure it fits # Ensure it fits
if len(summary) <= max_chars: if len(summary) <= max_chars:
return summary return summary
return summary[:max_chars - 3] + "..." return summary[:max_chars - 3] + "..."
except Exception as e: except Exception as e:
logger.debug("LLM summarization failed: %s", e) logger.debug("LLM summarization failed: %s", e)
return message[:max_chars - 3] + "..." return message[:max_chars - 3] + "..."