meshai/meshai/notifications/router.py
Matt Johnson (via Claude) b948ed775f feat(v0.6-phase2): rip out quiet hours entirely -- dashboard toggle, config schema, pipeline checks. Per Matt's repeated feedback (saved as feedback-quiet-hours-trash.md): silent is better than ugly, mesh users who need a fire alert at 3 AM need it at 3 AM. No replacement.
Backend removals:
  meshai/config.py
    - NotificationRuleConfig.override_quiet field
    - NotificationToggle.quiet_hours_override field
    - NotificationsConfig.quiet_hours_enabled / quiet_hours_start /
      quiet_hours_end fields
    - _default_toggles() no longer sets quiet_hours_override=True
    - rule migration helper no longer copies override_quiet
  meshai/notifications/router.py
    - self._quiet_enabled / _quiet_start / _quiet_end instance vars
    - _in_quiet_hours() method (deleted entirely)
    - The dispatch-time check that suppressed non-overriding rules
      during quiet hours
    - 'override_quiet': False dropped from subscription rule dicts
  meshai/notifications/pipeline/dispatcher.py
    - _toggle_to_rule() no longer passes override_quiet=... to the
      NotificationRuleConfig constructor

Test changes:
  tests/test_notification_toggles.py
    - RecChannel.deliver() no longer records override_quiet
    - test_quiet_hours_override_immediate_only deleted (only tested the
      removed feature)

Frontend removals (dashboard-frontend/src/pages/Notifications.tsx):
  - The 'Enable Quiet Hours' card with its time-range inputs deleted
  - 'Override Quiet Hours' per-rule toggle deleted
  - 'Quiet-hours override (immediate only)' per-toggle field deleted
  - quiet_hours_* fields removed from TS interfaces
  - quietHoursEnabled prop + state plumbing removed from the RuleEditor
  - All override_quiet: false defaults dropped from rule scaffolds
  - Unused Moon icon import dropped

Verification (post-strip):
  grep -rn 'quiet_hours\|override_quiet' meshai/*.py meshai/**/*.py
    -> 0 hits
  grep -rn 'quiet_hours\|override_quiet\|quietHours' dashboard-frontend/src
    -> 0 hits

Test count: 830 -> 829 (-1: test_quiet_hours_override_immediate_only
deleted; no other regressions).

No replacement. Mesh users who need a fire alert at 3 AM need it at 3 AM.
2026-06-05 20:39:36 +00:00

1037 lines
43 KiB
Python

"""Notification router - matches alerts to rules and delivers via channels."""
import asyncio
import json
import logging
import os
import time
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from .channels import create_channel_from_dict, NotificationChannel
from .events import NotificationPayload
from .summarizer import MessageSummarizer
if TYPE_CHECKING:
from ..connector import MeshConnector
logger = logging.getLogger(__name__)
# Severity levels in order
SEVERITY_ORDER = ["routine", "priority", "immediate"]
# State file for rule statistics
RULE_STATS_FILE = "/data/rule_stats.json"
class NotificationRouter:
"""Routes alerts through matching rules to notification channels."""
def __init__(
self,
config,
connector: Optional["MeshConnector"] = None,
llm_backend=None,
timezone: str = "America/Boise",
):
self._rules: list[dict] = []
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
# Rule statistics: {rule_name: {last_fired, last_test, fire_count}}
self._rule_stats = self._load_rule_stats()
# Load rules from config
rules_config = getattr(config, "rules", [])
for rule in rules_config:
if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
rule_dict = dict(rule) if isinstance(rule, dict) else {}
# Skip disabled rules
if not rule_dict.get("enabled", True):
continue
# Only load condition-triggered rules (scheduled rules handled by scheduler)
if rule_dict.get("trigger_type", "condition") == "condition":
self._rules.append(rule_dict)
logger.info("Notification router initialized: %d condition rules", len(self._rules))
def _load_rule_stats(self) -> dict:
"""Load rule statistics from persistent storage."""
try:
if os.path.exists(RULE_STATS_FILE):
with open(RULE_STATS_FILE, "r") as f:
return json.load(f)
except Exception as e:
logger.warning("Failed to load rule stats: %s", e)
return {}
def _save_rule_stats(self):
"""Save rule statistics to persistent storage."""
try:
os.makedirs(os.path.dirname(RULE_STATS_FILE), exist_ok=True)
with open(RULE_STATS_FILE, "w") as f:
json.dump(self._rule_stats, f, indent=2)
except Exception as e:
logger.warning("Failed to save rule stats: %s", e)
def _record_fire(self, rule_name: str):
"""Record that a rule fired."""
if rule_name not in self._rule_stats:
self._rule_stats[rule_name] = {"last_fired": None, "last_test": None, "fire_count": 0}
self._rule_stats[rule_name]["last_fired"] = time.time()
self._rule_stats[rule_name]["fire_count"] = self._rule_stats[rule_name].get("fire_count", 0) + 1
self._save_rule_stats()
def _record_test(self, rule_name: str):
"""Record that a rule was tested."""
if rule_name not in self._rule_stats:
self._rule_stats[rule_name] = {"last_fired": None, "last_test": None, "fire_count": 0}
self._rule_stats[rule_name]["last_test"] = time.time()
self._save_rule_stats()
def get_rule_stats(self, rule_name: str) -> dict:
"""Get statistics for a rule."""
return self._rule_stats.get(rule_name, {"last_fired": None, "last_test": None, "fire_count": 0})
def _create_channel_for_rule(self, rule: dict) -> Optional[NotificationChannel]:
"""Create a channel instance from a rule's inline delivery config."""
delivery_type = rule.get("delivery_type", "")
if not delivery_type:
return None
if delivery_type == "mesh_broadcast":
config = {
"type": "mesh_broadcast",
"channel_index": rule.get("broadcast_channel", 0),
}
elif delivery_type == "mesh_dm":
config = {
"type": "mesh_dm",
"node_ids": rule.get("node_ids", []),
}
elif delivery_type == "email":
config = {
"type": "email",
"smtp_host": rule.get("smtp_host", ""),
"smtp_port": rule.get("smtp_port", 587),
"smtp_user": rule.get("smtp_user", ""),
"smtp_password": rule.get("smtp_password", ""),
"smtp_tls": rule.get("smtp_tls", True),
"from_address": rule.get("from_address", ""),
"recipients": rule.get("recipients", []),
}
elif delivery_type == "webhook":
config = {
"type": "webhook",
"url": rule.get("webhook_url", ""),
"headers": rule.get("webhook_headers", {}),
}
else:
logger.warning("Unknown delivery type '%s' in rule '%s'", delivery_type, rule.get("name"))
return None
try:
return create_channel_from_dict(config, self._connector)
except Exception as e:
logger.warning("Failed to create channel for rule '%s': %s", rule.get("name"), e)
return None
async def process_alert(self, alert: dict) -> bool:
"""Route an alert through matching rules."""
category = alert.get("type", "")
severity = alert.get("severity", "routine")
delivered = False
for rule in self._rules:
rule_name = rule.get("name", "unnamed")
rule_categories = rule.get("categories", [])
if rule_categories and category not in rule_categories:
continue
min_severity = rule.get("min_severity", "routine")
if not self._severity_meets(severity, min_severity):
continue
cooldown = rule.get("cooldown_minutes", 10) * 60
event_id = alert.get("event_id", alert.get("message", "")[:50])
dedup_key = (rule_name, category, event_id)
now = time.time()
if dedup_key in self._recent:
if now - self._recent[dedup_key] < cooldown:
continue
self._recent[dedup_key] = now
logger.info("Rule '%s' matched alert: %s (%s)", rule_name, category, severity)
delivery_type = rule.get("delivery_type", "")
if not delivery_type:
continue
channel = self._create_channel_for_rule(rule)
if not channel:
continue
try:
delivery_alert = alert
message = alert.get("message", "")
if channel.channel_type in ("mesh_broadcast", "mesh_dm"):
if len(message) > 200:
if self._summarizer:
summary = await self._summarizer.summarize(message, max_chars=195)
delivery_alert = {**alert, "message": summary}
else:
delivery_alert = {**alert, "message": message[:195] + "..."}
# Convert dict to NotificationPayload for channel interface
payload = NotificationPayload(
message=delivery_alert.get("message", ""),
category=delivery_alert.get("type", "unknown"),
severity=delivery_alert.get("severity", "routine"),
timestamp=delivery_alert.get("timestamp", time.time()),
node_id=delivery_alert.get("node_id"),
node_name=delivery_alert.get("node_name"),
region=delivery_alert.get("region"),
event_type=delivery_alert.get("type"),
)
# Rule is a dict here; channels don't use it so we pass None
# for the rule parameter (channels ignore it anyway)
success = await channel.deliver(payload, None)
if success:
delivered = True
self._record_fire(rule_name)
except Exception as e:
logger.warning("Rule '%s' delivery failed: %s", rule_name, e)
return delivered
def _severity_meets(self, actual: str, required: str) -> bool:
"""Check if actual severity meets or exceeds required severity."""
try:
actual_idx = SEVERITY_ORDER.index(actual.lower())
required_idx = SEVERITY_ORDER.index(required.lower())
return actual_idx >= required_idx
except ValueError:
return True
def get_rules(self) -> list[dict]:
"""Get list of configured rules with stats."""
rules_with_stats = []
for rule in self._rules:
rule_copy = dict(rule)
stats = self.get_rule_stats(rule.get("name", ""))
rule_copy["_stats"] = stats
rules_with_stats.append(rule_copy)
return rules_with_stats
async def test_channel(self, channel_config: dict) -> dict:
"""Test a channel's connectivity.
Args:
channel_config: Channel configuration dict with type and settings
Returns:
{success, message, error, details}
"""
try:
channel = create_channel_from_dict(channel_config, self._connector)
return await channel.test_connection()
except ValueError as e:
return {
"success": False,
"message": "Invalid channel configuration",
"error": str(e),
"details": {}
}
except Exception as e:
return {
"success": False,
"message": "Channel test failed",
"error": str(e),
"details": {}
}
def get_source_health(self, rule_categories: list, env_store=None) -> dict:
"""Get health status of data sources for a rule's categories.
Returns:
{
category_id: {
"enabled": bool,
"active_events": int,
"source": str,
"status": "ok" | "disabled" | "no_data"
}
}
"""
# Map categories to their data sources
category_sources = {
"hf_blackout": "swpc",
"geomagnetic_storm": "swpc",
"tropospheric_ducting": "ducting",
"weather_warning": "nws",
# v0.5.7-fire: fire_proximity / wildfire_proximity removed.
"wildfire_incident": "nifc",
"wildfire_hotspot": "firms",
"new_ignition": "firms",
"stream_flood_warning": "usgs",
"stream_high_water": "usgs",
"road_closure": "roads511",
"traffic_congestion": "traffic",
"avalanche_warning": "avalanche",
"avalanche_considerable": "avalanche",
"infra_offline": "health",
"critical_node_down": "health",
"battery_warning": "health",
"battery_critical": "health",
"battery_emergency": "health",
"mesh_score_low": "health",
"high_utilization": "health",
"infra_recovery": "health",
"packet_flood": "health",
}
result = {}
for cat_id in rule_categories:
source = category_sources.get(cat_id, "unknown")
if source == "health":
# Mesh health is always available
result[cat_id] = {
"enabled": True,
"active_events": 0, # Would need health_engine to check
"source": "mesh_health",
"status": "ok"
}
elif env_store is None:
result[cat_id] = {
"enabled": False,
"active_events": 0,
"source": source,
"status": "disabled"
}
else:
# Check if source has an adapter
adapters = getattr(env_store, '_adapters', {})
if source in adapters:
events = env_store.get_active(source=source)
result[cat_id] = {
"enabled": True,
"active_events": len(events) if events else 0,
"source": source,
"status": "ok"
}
else:
result[cat_id] = {
"enabled": False,
"active_events": 0,
"source": source,
"status": "disabled"
}
return result
async def test_rule_with_conditions(
self,
rule_index: int,
alert_engine=None,
env_store=None,
health_engine=None,
send: bool = False,
action: str = "preview",
) -> dict:
"""Test a rule against current conditions with live data.
Args:
rule_index: Index of the rule to test
alert_engine: AlertEngine instance for pending alerts
env_store: EnvStore instance for environmental events
health_engine: MeshHealthEngine for mesh status
send: Legacy param - use action instead
action: "preview", "send_test", "send_status", "send_live"
"""
from .categories import get_category
rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config):
return {
"conditions_matched": 0,
"preview_messages": [],
"is_example": False,
"delivered": False,
"delivery_method": "",
"delivery_result": "Rule index out of range",
}
rule = rules_config[rule_index]
if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
rule_dict = dict(rule)
rule_name = rule_dict.get("name", f"Rule {rule_index}")
rule_categories = rule_dict.get("categories", [])
min_severity = rule_dict.get("min_severity", "routine")
delivery_type = rule_dict.get("delivery_type", "")
# Legacy support
if send and action == "preview":
action = "send_test"
# ============================================================
# SECTION 1: Collect LIVE DATA for rule's categories
# ============================================================
live_data_lines = []
feeds_not_enabled = []
category_sources = {
"hf_blackout": "swpc", "geomagnetic_storm": "swpc",
"tropospheric_ducting": "ducting",
"weather_warning": "nws",
"wildfire_incident": "nifc", "wildfire_hotspot": "firms", "new_ignition": "firms", # v0.5.7-fire
"stream_flood_warning": "usgs", "stream_high_water": "usgs",
"road_closure": "roads511", "traffic_congestion": "traffic",
"avalanche_warning": "avalanche", "avalanche_considerable": "avalanche",
"infra_offline": "health", "critical_node_down": "health",
"battery_warning": "health", "battery_critical": "health",
"mesh_score_low": "health", "high_utilization": "health",
}
sources_needed = set()
for cat in rule_categories if rule_categories else []:
if cat in category_sources:
sources_needed.add(category_sources[cat])
# Check which sources are available
if env_store:
adapters = getattr(env_store, '_adapters', {})
if "swpc" in sources_needed:
if "swpc" in adapters and hasattr(env_store, 'get_swpc_status'):
swpc = env_store.get_swpc_status()
if swpc:
kp = swpc.get("kp_current", "?")
sfi = swpc.get("sfi", "?")
r = swpc.get("r_scale", 0)
s = swpc.get("s_scale", 0)
g = swpc.get("g_scale", 0)
live_data_lines.append(f"RF: SFI {sfi}, Kp {kp}, R{r}/S{s}/G{g}")
else:
feeds_not_enabled.append("SWPC")
if "ducting" in sources_needed:
if "ducting" in adapters and hasattr(env_store, 'get_ducting_status'):
ducting = env_store.get_ducting_status()
if ducting:
condition = ducting.get("condition", "unknown")
gradient = ducting.get("min_gradient", "?")
live_data_lines.append(f"Tropo: {condition}, dM/dz {gradient}")
else:
feeds_not_enabled.append("Ducting")
if "nws" in sources_needed:
if "nws" in adapters:
nws = env_store.get_active(source="nws")
if nws:
live_data_lines.append(f"NWS: {len(nws)} active alert(s)")
for a in nws[:2]:
headline = a.get('headline', a.get('message', 'Alert'))[:60]
live_data_lines.append(f" - {headline}")
else:
live_data_lines.append("NWS: No active alerts")
else:
feeds_not_enabled.append("NWS")
if "nifc" in sources_needed:
if "nifc" in adapters:
fires = env_store.get_active(source="nifc")
if fires:
live_data_lines.append(f"Fires: {len(fires)} active")
else:
live_data_lines.append("Fires: None active")
else:
feeds_not_enabled.append("NIFC")
if "firms" in sources_needed:
if "firms" in adapters:
hotspots = env_store.get_active(source="firms")
if hotspots:
live_data_lines.append(f"Hotspots: {len(hotspots)} detected")
else:
live_data_lines.append("Hotspots: None detected")
else:
feeds_not_enabled.append("FIRMS")
if "usgs" in sources_needed:
if "usgs" in adapters:
streams = env_store.get_active(source="usgs")
if streams:
live_data_lines.append(f"Streams: {len(streams)} gauge(s) reporting")
else:
live_data_lines.append("Streams: No alerts")
else:
feeds_not_enabled.append("USGS")
if "traffic" in sources_needed:
if "traffic" in adapters:
traffic = env_store.get_active(source="traffic")
if traffic:
live_data_lines.append(f"Traffic: {len(traffic)} corridor(s)")
else:
live_data_lines.append("Traffic: Normal")
else:
feeds_not_enabled.append("Traffic")
if "roads511" in sources_needed:
if "roads511" in adapters:
roads = env_store.get_active(source="roads511")
if roads:
live_data_lines.append(f"Roads: {len(roads)} event(s)")
else:
live_data_lines.append("Roads: No closures")
else:
feeds_not_enabled.append("511 Roads")
elif sources_needed - {"health"}:
feeds_not_enabled.append("Environmental feeds")
if health_engine and "health" in sources_needed:
mesh_health = getattr(health_engine, 'mesh_health', None)
if mesh_health:
score = mesh_health.score
live_data_lines.append(f"Mesh: {score.composite:.0f}/100, {score.infra_online}/{score.infra_total} infra")
# Add warning if feeds not enabled
if feeds_not_enabled:
live_data_lines.append(f"[!] Not enabled: {', '.join(feeds_not_enabled)}")
# ============================================================
# SECTION 2: Check for MATCHING and NEAR-MISS events
# ============================================================
matching_alerts = []
below_threshold = []
all_events = []
if alert_engine and hasattr(alert_engine, "get_pending_alerts"):
try:
for alert in alert_engine.get_pending_alerts():
all_events.append({
"type": alert.get("type", ""),
"severity": alert.get("severity", "routine"),
"message": alert.get("message", ""),
"headline": alert.get("message", "")[:80],
})
except Exception:
pass
if env_store and hasattr(env_store, "get_active"):
try:
for event in env_store.get_active():
all_events.append({
"type": event.get("type", event.get("category", "")),
"severity": event.get("severity", "routine"),
"message": event.get("message", event.get("headline", str(event))),
"headline": event.get("headline", event.get("message", "Event"))[:80],
})
except Exception:
pass
for event in all_events:
event_type = event["type"]
severity = event["severity"]
category_match = not rule_categories
if not category_match:
for cat in rule_categories:
if event_type.startswith(cat.rstrip("_")) or cat in event_type or event_type == cat:
category_match = True
break
if category_match:
if self._severity_meets(severity, min_severity):
matching_alerts.append(event)
else:
below_threshold.append(event)
# ============================================================
# SECTION 3: Build response
# ============================================================
preview_messages = []
is_example = False
below_threshold_summary = ""
below_threshold_events = []
suggestion = ""
if matching_alerts:
for alert in matching_alerts[:5]:
msg = alert.get("message", "")
if len(msg) > 200 and delivery_type in ("mesh_broadcast", "mesh_dm"):
msg = msg[:195] + "..."
preview_messages.append(msg)
else:
is_example = True
if below_threshold:
severity_counts = {}
for evt in below_threshold:
sev = evt["severity"]
severity_counts[sev] = severity_counts.get(sev, 0) + 1
parts = [f"{count} at '{sev}'" for sev, count in severity_counts.items()]
below_threshold_summary = f"{len(below_threshold)} event(s) filtered by severity: {', '.join(parts)}. Rule requires '{min_severity}' or higher."
suggestion = f"Lower severity threshold to '{list(severity_counts.keys())[0]}' to match these events"
below_threshold_events = [{"headline": e["headline"], "severity": e["severity"]} for e in below_threshold[:5]]
if rule_categories:
for cat_id in rule_categories[:3]:
cat_info = get_category(cat_id)
preview_messages.append(f"[EXAMPLE] {cat_info.get('example_message', f'Alert: {cat_id}')}")
else:
cat_info = get_category("infra_offline")
preview_messages.append(f"[EXAMPLE] {cat_info.get('example_message', 'Alert notification')}")
# Get source health
source_health = self.get_source_health(rule_categories, env_store)
# ============================================================
# SECTION 4: Handle delivery actions
# ============================================================
delivered = False
delivery_result = "Preview only"
delivery_error = ""
if action != "preview":
if not delivery_type:
delivery_result = "No delivery method configured"
delivery_error = "Configure a delivery method to send test messages"
else:
channel = self._create_channel_for_rule(rule_dict)
if channel:
try:
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", "wildfire_incident", "wildfire_hotspot", "new_ignition"]): # v0.5.7-fire
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}"
if not success:
delivery_error = result
elif action == "send_live" and matching_alerts:
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}"
if not success:
delivery_error = result
elif action == "send_test":
if preview_messages:
test_msg = preview_messages[0]
if test_msg.startswith("[EXAMPLE]"):
test_msg = test_msg.replace("[EXAMPLE]", "[TEST]")
elif not test_msg.startswith("["):
test_msg = f"[TEST] {test_msg}"
else:
test_msg = "[TEST] MeshAI notification test"
success, result = await channel.deliver_test(test_msg)
delivered = success
delivery_result = result if success else f"Failed: {result}"
if not success:
delivery_error = result
# Record test
if action != "preview":
self._record_test(rule_name)
except Exception as e:
delivery_result = f"Delivery error"
delivery_error = str(e)
# Get rule stats
stats = self.get_rule_stats(rule_name)
return {
"live_data_summary": live_data_lines,
"conditions_matched": len(matching_alerts),
"preview_messages": preview_messages,
"is_example": is_example,
"conditions_below_threshold": len(below_threshold),
"below_threshold_summary": below_threshold_summary,
"below_threshold_events": below_threshold_events,
"suggestion": suggestion,
"delivered": delivered,
"delivery_method": delivery_type,
"delivery_result": delivery_result,
"delivery_error": delivery_error,
"can_send_live": len(matching_alerts) > 0,
"source_health": source_health,
"rule_stats": stats,
}
async def test_rule(self, rule_index: int) -> tuple[bool, str]:
"""Send a test alert through a specific rule (legacy method)."""
result = await self.test_rule_with_conditions(rule_index, action="send_test")
return result.get("delivered", False), result.get("delivery_result", "Unknown")
async def preview_rule(self, rule_index: int) -> dict:
"""Preview what a rule would match right now."""
rules_config = getattr(self._config, "rules", [])
if rule_index < 0 or rule_index >= len(rules_config):
return {"matches": False, "conditions": [], "preview": "Invalid rule index"}
rule = rules_config[rule_index]
if hasattr(rule, "__dict__"):
rule_dict = {k: v for k, v in rule.__dict__.items() if not k.startswith("_")}
else:
rule_dict = dict(rule)
if rule_dict.get("trigger_type", "condition") == "condition":
from .categories import get_category
categories = rule_dict.get("categories", [])
if not categories:
example = get_category("infra_offline")
return {
"matches": True,
"conditions": ["All alert categories"],
"preview": example.get("example_message", "Alert notification"),
}
else:
cat_info = get_category(categories[0])
return {
"matches": True,
"conditions": [get_category(c)["name"] for c in categories],
"preview": cat_info.get("example_message", f"Alert: {categories[0]}"),
}
elif rule_dict.get("trigger_type") == "schedule":
message_type = rule_dict.get("message_type", "mesh_health_summary")
return {
"matches": True,
"conditions": [f"Scheduled: {rule_dict.get('schedule_frequency', 'daily')}"],
"preview": f"[{message_type}] Report content would appear here",
}
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
def add_mesh_subscription(self, node_id: str, categories: list[str], rule_name: Optional[str] = None) -> str:
"""Add a mesh DM subscription for a node."""
if not rule_name:
rule_name = "sub_%s" % node_id
for rule in self._rules:
if rule.get("name") == rule_name:
rule["categories"] = categories if categories else []
rule["node_ids"] = [node_id]
return rule_name
self._rules.append({
"name": rule_name,
"enabled": True,
"trigger_type": "condition",
"categories": categories if categories else [],
"min_severity": "priority",
"delivery_type": "mesh_dm",
"node_ids": [node_id],
"cooldown_minutes": 10,
})
return rule_name
def remove_mesh_subscription(self, node_id: str) -> bool:
"""Remove a mesh subscription for a node."""
rule_name = "sub_%s" % node_id
self._rules = [r for r in self._rules if r.get("name") != rule_name]
return True
def get_node_subscriptions(self, node_id: str) -> list[str]:
"""Get categories a node is subscribed to."""
rule_name = "sub_%s" % node_id
for rule in self._rules:
if rule.get("name") == rule_name:
categories = rule.get("categories", [])
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()
self._recent = {k: v for k, v in self._recent.items() if now - v < max_age}