mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +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", []),
|
||||
|
|
|
|||
|
|
@ -21,13 +21,31 @@ async def get_env_status(request: Request):
|
|||
|
||||
@router.get("/env/active")
|
||||
async def get_active_env(request: Request):
|
||||
"""Get active environmental events."""
|
||||
"""Get active environmental events with local zone marking."""
|
||||
env_store = getattr(request.app.state, "env_store", None)
|
||||
|
||||
if not env_store:
|
||||
return []
|
||||
|
||||
return env_store.get_active()
|
||||
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")
|
||||
|
|
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class MessageSummarizer:
|
|||
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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue