mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
* feat(dashboard): Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing Regroup nav into GENERAL/MESHTASTIC/MESHCORE/DOCUMENTATION (<=5 pages each, MT & MC mirror). Consolidate via tabs (Places, Nodes & Health, Contacts & Companion) reusing existing components. Move Band Conditions, cold-start, and fire digest to per-mesh Scheduled Broadcasts pages; move Danger Zones to its own page. Routing keeps its sending rules unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): Phase B — clean identical Routing grids; relocate per-family gating to Data Feeds Meshtastic Routing becomes an always-visible pure-delivery grid matching MeshCore (no master-toggle expand/collapse). Per-family gating (enable/ severity/freshness/cooldown) moves to a Family Settings section on Data Feeds. Sending rules + Notification Rules unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): Phase C — MeshCore bot-behavior parity (observe channels / ignore contacts / DMs) Add meshcore context (observe channels by name, ignore contacts, DM policy) and wire the MeshCore inbound path to honor it, mirroring Meshtastic's observe/ignore filtering. Symmetric "Bot behavior" sections on both Connection pages. Meshtastic path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): Phase D — dedupe Environment/Adapter Config into one Data Feeds surface Curated family panels are the single home for the shared adapter keys; Adapter Config becomes an Advanced/raw escape hatch (owned keys no longer double-editable). Surface include_in_llm_context per adapter. Fix the adapter-config array-vs-object parsing (fire digest values now load). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): Phase E — Activity Log (per-mesh broadcast feed); remove subscription backend Replace Alerts with an Activity Log fed by per-mesh broadcast logging (transport+channel+success on mesh_broadcasts_out, additive migration). Remove the entire subscription backend (commands, DM dispatch, storage, API) and its UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
710 lines
31 KiB
Python
710 lines
31 KiB
Python
"""Alert engine - detects mesh state changes and dispatches alerts."""
|
|
|
|
import logging
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Optional, TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from .config import AlertRulesConfig, MeshIntelligenceConfig
|
|
from .mesh_health import MeshHealthEngine
|
|
from .mesh_reporter import MeshReporter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Scaling cooldown schedule (seconds after first alert)
|
|
# Alert 1: immediate, Alert 2: +12h, Alert 3: +24h more, Alert 4: +48h more, then stop
|
|
ESCALATION_SCHEDULE = [0, 12 * 3600, 24 * 3600, 48 * 3600]
|
|
|
|
|
|
class AlertState:
|
|
"""Tracks escalation state for a single condition."""
|
|
|
|
def __init__(self):
|
|
self.first_fired: float = 0
|
|
self.alert_count: int = 0
|
|
self.last_fired: float = 0
|
|
self.resolved: bool = False
|
|
|
|
def should_fire(self, now: float) -> bool:
|
|
"""Check if this condition should fire based on scaling cooldown."""
|
|
if self.resolved:
|
|
return False
|
|
if self.alert_count == 0:
|
|
return True
|
|
if self.alert_count >= len(ESCALATION_SCHEDULE):
|
|
return False
|
|
elapsed_since_last = now - self.last_fired
|
|
required_wait = ESCALATION_SCHEDULE[self.alert_count]
|
|
return elapsed_since_last >= required_wait
|
|
|
|
def fire(self, now: float):
|
|
"""Record that an alert was fired."""
|
|
if self.alert_count == 0:
|
|
self.first_fired = now
|
|
self.last_fired = now
|
|
self.alert_count += 1
|
|
|
|
def resolve(self):
|
|
"""Mark condition as resolved."""
|
|
self.resolved = True
|
|
|
|
def reset(self):
|
|
"""Full reset for new occurrence."""
|
|
self.first_fired = 0
|
|
self.alert_count = 0
|
|
self.last_fired = 0
|
|
self.resolved = False
|
|
|
|
|
|
class AlertEngine:
|
|
"""Detects mesh state changes and dispatches alerts."""
|
|
|
|
def __init__(
|
|
self,
|
|
health_engine: "MeshHealthEngine",
|
|
reporter: "MeshReporter",
|
|
config: "MeshIntelligenceConfig",
|
|
db_path: str = "",
|
|
timezone: str = "America/Boise",
|
|
):
|
|
self._health = health_engine
|
|
self._reporter = reporter
|
|
self._rules = config.alert_rules
|
|
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
|
|
self._db_path = db_path
|
|
self._timezone = timezone
|
|
|
|
self._states: dict[str, AlertState] = {}
|
|
self._prev_infra_online: dict[int, bool] = {}
|
|
self._prev_battery: dict[int, float] = {}
|
|
self._prev_power_source: dict[int, str] = {}
|
|
self._prev_gateways: dict[int, float] = {}
|
|
self._prev_mesh_score: Optional[float] = None
|
|
self._prev_region_scores: dict[str, float] = {}
|
|
self._prev_feeder_gateways: set[str] = set()
|
|
self._known_routers: set[int] = set()
|
|
self._util_exceeded_since: dict[int, float] = {}
|
|
self._first_run = True
|
|
self._pending_alerts: list[dict] = []
|
|
|
|
def _get_state(self, key: str) -> AlertState:
|
|
if key not in self._states:
|
|
self._states[key] = AlertState()
|
|
return self._states[key]
|
|
|
|
def check(self) -> list[dict]:
|
|
"""Run all alert checks. Returns list of alert dicts."""
|
|
health = self._health.mesh_health
|
|
if not health:
|
|
return []
|
|
|
|
now = time.time()
|
|
alerts = []
|
|
alerts.extend(self._check_infrastructure(health, now))
|
|
alerts.extend(self._check_power(health, now))
|
|
alerts.extend(self._check_utilization(health, now))
|
|
alerts.extend(self._check_coverage(health, now))
|
|
alerts.extend(self._check_health_scores(health, now))
|
|
|
|
self._first_run = False
|
|
self._pending_alerts = alerts
|
|
return alerts
|
|
|
|
def _check_infrastructure(self, health, now: float) -> list[dict]:
|
|
alerts = []
|
|
for node in health.nodes.values():
|
|
if not node.is_infrastructure:
|
|
continue
|
|
|
|
node_num = node.node_num
|
|
name = node.long_name or node.short_name or str(node_num)
|
|
short = (node.short_name or str(node_num)).upper()
|
|
region = node.region or "Unknown"
|
|
is_critical = short in self._critical_nodes
|
|
region_display = self._get_region_display(region)
|
|
|
|
was_online = self._prev_infra_online.get(node_num)
|
|
is_online = node.is_online
|
|
|
|
if not self._first_run and was_online is not None:
|
|
if was_online and not is_online and self._rules.infra_offline:
|
|
key = f"offline_{node_num}"
|
|
state = self._get_state(key)
|
|
state.resolved = False
|
|
if state.should_fire(now):
|
|
alert_type = "critical_node_down" if is_critical else "infra_offline"
|
|
emoji = "\U0001F6A8" if is_critical else "\u274C"
|
|
escalation = f" (alert {state.alert_count + 1}/4)" if state.alert_count > 0 else ""
|
|
alerts.append(self._make_alert(
|
|
alert_type, name, short, node_num, region,
|
|
f"{emoji} {name} went offline in {region_display}.{escalation}",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
|
|
elif not was_online and is_online and self._rules.infra_recovery:
|
|
key = f"offline_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
alerts.append(self._make_alert(
|
|
"infra_recovery", name, short, node_num, region,
|
|
f"\u2705 {name} is back online in {region_display}.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.resolve()
|
|
|
|
if self._rules.new_router and not self._first_run:
|
|
if node_num not in self._known_routers:
|
|
alerts.append(self._make_alert(
|
|
"new_router", name, short, node_num, region,
|
|
f"\U0001F4E1 New router appeared: {name} in {region_display}.",
|
|
False,
|
|
))
|
|
|
|
self._prev_infra_online[node_num] = is_online
|
|
self._known_routers.add(node_num)
|
|
|
|
return alerts
|
|
|
|
def _check_power(self, health, now: float) -> list[dict]:
|
|
alerts = []
|
|
for node in health.nodes.values():
|
|
if not node.is_infrastructure:
|
|
continue
|
|
if node.battery_percent is None:
|
|
continue
|
|
|
|
node_num = node.node_num
|
|
name = node.long_name or node.short_name or str(node_num)
|
|
short = (node.short_name or str(node_num)).upper()
|
|
region = node.region or "Unknown"
|
|
is_critical = short in self._critical_nodes
|
|
region_display = self._get_region_display(region)
|
|
bat = node.battery_percent
|
|
|
|
if self._rules.power_source_change and not self._first_run:
|
|
current_source = "usb" if bat > 100 else "battery"
|
|
prev_source = self._prev_power_source.get(node_num)
|
|
if prev_source == "usb" and current_source == "battery":
|
|
key = f"power_change_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"power_source_change", name, short, node_num, region,
|
|
f"\u26A1 {name} switched from USB to battery in {region_display}. Possible power outage.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
elif prev_source == "battery" and current_source == "usb":
|
|
key = f"power_change_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
self._prev_power_source[node_num] = current_source
|
|
|
|
if 0 < bat <= 100 and not self._first_run:
|
|
prev_bat = self._prev_battery.get(node_num)
|
|
|
|
if self._rules.battery_emergency and bat < self._rules.battery_emergency_threshold:
|
|
if prev_bat is None or prev_bat >= self._rules.battery_emergency_threshold:
|
|
key = f"bat_emergency_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"battery_emergency", name, short, node_num, region,
|
|
f"\U0001F6A8 {name} battery EMERGENCY at {bat:.0f}% in {region_display}.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
|
|
elif self._rules.battery_critical and bat < self._rules.battery_critical_threshold:
|
|
if prev_bat is None or prev_bat >= self._rules.battery_critical_threshold:
|
|
key = f"bat_critical_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"battery_critical", name, short, node_num, region,
|
|
f"\U0001F50B {name} battery critical at {bat:.0f}% in {region_display}.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
|
|
elif self._rules.battery_warning and bat < self._rules.battery_warning_threshold:
|
|
if prev_bat is None or prev_bat >= self._rules.battery_warning_threshold:
|
|
key = f"bat_warning_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"battery_warning", name, short, node_num, region,
|
|
f"\U0001F50B {name} battery low at {bat:.0f}% in {region_display}.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
|
|
if prev_bat is not None and bat > prev_bat + 5:
|
|
for prefix in ["bat_emergency", "bat_critical", "bat_warning"]:
|
|
key = f"{prefix}_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
|
|
if self._rules.battery_trend_declining and 0 < bat <= 100:
|
|
trend = self._get_battery_trend(node_num, days=7)
|
|
if trend and trend["direction"] == "declining" and trend["total_drop"] > 10:
|
|
key = f"bat_trend_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count == 0 and state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"battery_trend", name, short, node_num, region,
|
|
f"\U0001F50B {name} battery declining: {trend['start']:.0f}% \u2192 {trend['end']:.0f}% over 7 days ({trend['rate']:.1f}%/day) in {region_display}.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
|
|
# NOTE: has_solar is never populated in current version.
|
|
# Solar Quality Engine (v0.3) will replace this with real solar
|
|
# monitoring based on location, weather, and inversion data.
|
|
# For now this check effectively never fires.
|
|
if self._rules.solar_not_charging and getattr(node, "has_solar", False) and 0 < bat <= 100:
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
tz = ZoneInfo(self._timezone)
|
|
hour = datetime.now(tz).hour
|
|
if 8 <= hour <= 18:
|
|
prev_bat = self._prev_battery.get(node_num)
|
|
if prev_bat is not None and bat < prev_bat - 2:
|
|
key = f"solar_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"solar_not_charging", name, short, node_num, region,
|
|
f"\u2600\uFE0F {name} solar not charging in {region_display}.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
except Exception:
|
|
pass
|
|
|
|
self._prev_battery[node_num] = bat
|
|
|
|
return alerts
|
|
|
|
def _check_utilization(self, health, now: float) -> list[dict]:
|
|
alerts = []
|
|
for node in health.nodes.values():
|
|
node_num = node.node_num
|
|
name = node.long_name or node.short_name or str(node_num)
|
|
short = (node.short_name or str(node_num)).upper()
|
|
region = node.region or "Unknown"
|
|
region_display = self._get_region_display(region)
|
|
|
|
if self._rules.sustained_high_util and node.channel_utilization is not None:
|
|
threshold = self._rules.high_util_threshold
|
|
required_hours = self._rules.high_util_hours
|
|
if node.channel_utilization > threshold:
|
|
if node_num not in self._util_exceeded_since:
|
|
self._util_exceeded_since[node_num] = now
|
|
else:
|
|
duration_hours = (now - self._util_exceeded_since[node_num]) / 3600
|
|
if duration_hours >= required_hours:
|
|
key = f"util_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"sustained_high_util", name, short, node_num, region,
|
|
f"\U0001F525 {name} at {node.channel_utilization:.0f}% util for {duration_hours:.0f}+ hours in {region_display}.",
|
|
False,
|
|
))
|
|
state.fire(now)
|
|
else:
|
|
if node_num in self._util_exceeded_since:
|
|
del self._util_exceeded_since[node_num]
|
|
key = f"util_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
|
|
if self._rules.packet_flood and not self._first_run:
|
|
if getattr(node, "packets_sent_24h", 0) > self._rules.packet_flood_threshold:
|
|
key = f"flood_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count == 0:
|
|
alerts.append(self._make_alert(
|
|
"packet_flood", name, short, node_num, region,
|
|
f"\U0001F4E1 {name} sent {node.packets_sent_24h} packets in 24h (threshold: {self._rules.packet_flood_threshold}) in {region_display}.",
|
|
False,
|
|
))
|
|
state.fire(now)
|
|
|
|
return alerts
|
|
|
|
def _check_coverage(self, health, now: float) -> list[dict]:
|
|
alerts = []
|
|
for node in health.nodes.values():
|
|
if not node.is_infrastructure:
|
|
continue
|
|
|
|
node_num = node.node_num
|
|
name = node.long_name or node.short_name or str(node_num)
|
|
short = (node.short_name or str(node_num)).upper()
|
|
region = node.region or "Unknown"
|
|
is_critical = short in self._critical_nodes
|
|
region_display = self._get_region_display(region)
|
|
|
|
if self._rules.infra_single_gateway and node.avg_gateways is not None and not self._first_run:
|
|
prev_gw = self._prev_gateways.get(node_num)
|
|
if prev_gw is not None and prev_gw > 1.0 and node.avg_gateways <= 1.0:
|
|
key = f"single_gw_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append(self._make_alert(
|
|
"infra_single_gateway", name, short, node_num, region,
|
|
f"\u26A0\uFE0F {name} dropped to single gateway in {region_display}. At risk if gateway fails.",
|
|
"immediate" if is_critical else "priority",
|
|
))
|
|
state.fire(now)
|
|
elif prev_gw is not None and prev_gw <= 1.0 and node.avg_gateways > 1.0:
|
|
key = f"single_gw_{node_num}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
self._prev_gateways[node_num] = node.avg_gateways
|
|
|
|
if self._rules.feeder_offline and not self._first_run:
|
|
current_feeders = set()
|
|
for node in health.nodes.values():
|
|
for gw in getattr(node, "feeder_gateways", []):
|
|
gw_name = gw.get("gateway_name") or gw.get("gateway_id", "")
|
|
if gw_name:
|
|
current_feeders.add(gw_name)
|
|
|
|
if self._prev_feeder_gateways:
|
|
lost_feeders = self._prev_feeder_gateways - current_feeders
|
|
for feeder in lost_feeders:
|
|
key = f"feeder_{feeder}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append({
|
|
"type": "feeder_offline",
|
|
"node_name": feeder,
|
|
"node_short": feeder,
|
|
"node_num": 0,
|
|
"region": "",
|
|
"message": f"\U0001F4E1 Feeder gateway {feeder} stopped responding.",
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "routine",
|
|
})
|
|
state.fire(now)
|
|
|
|
recovered_feeders = current_feeders - self._prev_feeder_gateways
|
|
for feeder in recovered_feeders:
|
|
key = f"feeder_{feeder}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
|
|
self._prev_feeder_gateways = current_feeders
|
|
|
|
if self._rules.region_total_blackout and not self._first_run:
|
|
for region in health.regions:
|
|
if not region.node_ids:
|
|
continue
|
|
infra_in_region = []
|
|
for nid_str in region.node_ids:
|
|
try:
|
|
nid = int(nid_str)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
node = health.nodes.get(nid)
|
|
if node and node.is_infrastructure:
|
|
infra_in_region.append(node)
|
|
|
|
if infra_in_region and all(not n.is_online for n in infra_in_region):
|
|
key = f"blackout_{region.name}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
region_display = self._get_region_display(region.name)
|
|
alerts.append({
|
|
"type": "region_total_blackout",
|
|
"node_name": region.name,
|
|
"node_short": region.name,
|
|
"node_num": 0,
|
|
"region": region.name,
|
|
"message": f"\U0001F6A8 TOTAL BLACKOUT: All infrastructure in {region_display} is offline!",
|
|
"scope_type": "region",
|
|
"scope_value": region.name,
|
|
"severity": "immediate",
|
|
})
|
|
state.fire(now)
|
|
|
|
return alerts
|
|
|
|
def _check_health_scores(self, health, now: float) -> list[dict]:
|
|
alerts = []
|
|
|
|
if self._first_run:
|
|
self._prev_mesh_score = health.score.composite
|
|
for region in health.regions:
|
|
self._prev_region_scores[region.name] = region.score.composite
|
|
return alerts
|
|
|
|
if self._rules.mesh_score_alert:
|
|
current = health.score.composite
|
|
threshold = self._rules.mesh_score_threshold
|
|
if current < threshold and (self._prev_mesh_score is None or self._prev_mesh_score >= threshold):
|
|
key = "mesh_score"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
alerts.append({
|
|
"type": "mesh_score_low",
|
|
"node_name": "Mesh",
|
|
"node_short": "MESH",
|
|
"node_num": 0,
|
|
"region": "",
|
|
"message": f"\U0001F4C9 Mesh health dropped to {current:.0f}/100 (threshold: {threshold}).",
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "routine",
|
|
})
|
|
state.fire(now)
|
|
elif current >= threshold:
|
|
key = "mesh_score"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
self._prev_mesh_score = current
|
|
|
|
if self._rules.region_score_alert:
|
|
threshold = self._rules.region_score_threshold
|
|
for region in health.regions:
|
|
current = region.score.composite
|
|
prev = self._prev_region_scores.get(region.name)
|
|
if current < threshold and (prev is None or prev >= threshold):
|
|
key = f"region_score_{region.name}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
region_display = self._get_region_display(region.name)
|
|
alerts.append({
|
|
"type": "region_score_low",
|
|
"node_name": region.name,
|
|
"node_short": region.name,
|
|
"node_num": 0,
|
|
"region": region.name,
|
|
"message": f"\U0001F4C9 {region_display} health dropped to {current:.0f}/100 (threshold: {threshold}).",
|
|
"scope_type": "region",
|
|
"scope_value": region.name,
|
|
"severity": "routine",
|
|
})
|
|
state.fire(now)
|
|
elif current >= threshold:
|
|
key = f"region_score_{region.name}"
|
|
state = self._get_state(key)
|
|
if state.alert_count > 0:
|
|
state.resolve()
|
|
self._prev_region_scores[region.name] = current
|
|
|
|
return alerts
|
|
|
|
def _get_battery_trend(self, node_num: int, days: int = 7) -> Optional[dict]:
|
|
"""Query SQLite for battery trend over N days."""
|
|
if not self._db_path:
|
|
return None
|
|
try:
|
|
import sqlite3
|
|
conn = sqlite3.connect(self._db_path)
|
|
cursor = conn.cursor()
|
|
cutoff = time.time() - (days * 86400)
|
|
rows = cursor.execute("""
|
|
SELECT battery_percent, timestamp
|
|
FROM node_snapshots
|
|
WHERE node_num = ? AND timestamp > ? AND battery_percent IS NOT NULL
|
|
AND battery_percent > 0 AND battery_percent <= 100
|
|
ORDER BY timestamp ASC
|
|
""", (node_num, cutoff)).fetchall()
|
|
conn.close()
|
|
|
|
if len(rows) < 10:
|
|
return None
|
|
|
|
start_bat = rows[0][0]
|
|
end_bat = rows[-1][0]
|
|
total_drop = start_bat - end_bat
|
|
duration_days = (rows[-1][1] - rows[0][1]) / 86400
|
|
if duration_days < 1:
|
|
return None
|
|
rate = total_drop / duration_days
|
|
return {
|
|
"start": start_bat,
|
|
"end": end_bat,
|
|
"total_drop": total_drop,
|
|
"duration_days": duration_days,
|
|
"rate": rate,
|
|
"direction": "declining" if rate > 1.0 else "stable" if abs(rate) < 1.0 else "charging",
|
|
}
|
|
except Exception as e:
|
|
logger.debug(f"Battery trend query error: {e}")
|
|
return None
|
|
|
|
def _make_alert(self, alert_type, name, short, node_num, region, message, severity="priority"):
|
|
return {
|
|
"type": alert_type,
|
|
"node_name": name,
|
|
"node_short": short,
|
|
"node_num": node_num,
|
|
"region": region,
|
|
"message": message,
|
|
"scope_type": "region" if region and region != "Unknown" else "mesh",
|
|
"scope_value": region if region and region != "Unknown" else None,
|
|
"severity": severity,
|
|
}
|
|
|
|
def _get_region_display(self, region: str) -> str:
|
|
if not self._reporter:
|
|
return region
|
|
try:
|
|
context = self._reporter._region_context(region)
|
|
if context:
|
|
return context.split("(")[0].strip()
|
|
except Exception:
|
|
pass
|
|
return region
|
|
|
|
def get_pending_alerts(self) -> list[dict]:
|
|
return self._pending_alerts
|
|
|
|
def clear_pending(self):
|
|
self._pending_alerts = []
|
|
|
|
def check_environmental(self, env_store) -> list[dict]:
|
|
"""Check environmental feeds for alertable conditions.
|
|
|
|
Args:
|
|
env_store: EnvironmentalStore instance
|
|
|
|
Returns:
|
|
List of alert dicts
|
|
"""
|
|
alerts = []
|
|
now = time.time()
|
|
|
|
# NWS severe weather affecting mesh zones
|
|
mesh_zones = set(getattr(env_store, "_mesh_zones", []))
|
|
for evt in env_store.get_active(source="nws"):
|
|
if evt.get("severity") not in ("severe", "extreme", "warning"):
|
|
continue
|
|
event_zones = set(evt.get("areas", []))
|
|
if mesh_zones and not (event_zones & mesh_zones):
|
|
continue
|
|
key = f"env_nws_{evt['event_id']}"
|
|
state = self._get_state(key)
|
|
if not state.should_fire(now):
|
|
continue
|
|
state.fire(now)
|
|
alerts.append({
|
|
"type": "weather_warning",
|
|
"message": f"Warning: {evt['event_type']}: {evt.get('headline', '')[:150]}",
|
|
"node_num": None,
|
|
"node_name": evt["event_type"],
|
|
"node_short": "NWS",
|
|
"region": "",
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "immediate" if evt["severity"] == "extreme" else "priority",
|
|
})
|
|
|
|
# SWPC R-scale >= 3 (HF blackout affecting mesh backhaul)
|
|
swpc = env_store.get_swpc_status()
|
|
if swpc and swpc.get("r_scale", 0) >= 3:
|
|
r_scale = swpc["r_scale"]
|
|
key = f"env_swpc_r{r_scale}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
state.fire(now)
|
|
alerts.append({
|
|
"type": "hf_blackout",
|
|
"message": f"Warning: R{r_scale} HF Radio Blackout -- mesh backhaul links may degrade",
|
|
"severity": "priority",
|
|
"node_num": None,
|
|
"node_name": f"R{r_scale} Blackout",
|
|
"node_short": "SWPC",
|
|
"region": "",
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "immediate" if r_scale >= 4 else "priority",
|
|
})
|
|
|
|
# Tropospheric ducting (informational -- not critical but operators want to know)
|
|
ducting = env_store.get_ducting_status()
|
|
if ducting and ducting.get("condition") in ("surface_duct", "elevated_duct"):
|
|
key = "env_ducting_active"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
state.fire(now)
|
|
condition = ducting.get("condition", "ducting").replace("_", " ")
|
|
gradient = ducting.get("min_gradient", "?")
|
|
alerts.append({
|
|
"type": "tropospheric_ducting",
|
|
"message": f"Tropospheric {condition} detected (dM/dz {gradient} M-units/km)",
|
|
"severity": "routine",
|
|
"node_num": None,
|
|
"node_name": "Ducting",
|
|
"node_short": "TROPO",
|
|
"region": "",
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "routine",
|
|
})
|
|
|
|
# Wildfire proximity alerts
|
|
fires = env_store.get_active(source="nifc")
|
|
for fire in fires:
|
|
distance_km = fire.get("distance_km")
|
|
if distance_km is None:
|
|
continue
|
|
|
|
name = fire.get("name", "Unknown")
|
|
acres = fire.get("acres", 0)
|
|
pct = fire.get("pct_contained", 0)
|
|
anchor = fire.get("nearest_anchor", "mesh area")
|
|
|
|
if distance_km < 25:
|
|
# Critical - fire within 25km
|
|
key = f"env_fire_critical_{name}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
state.fire(now)
|
|
alerts.append({
|
|
"type": "wildfire_proximity",
|
|
"message": f"Wildfire '{name}' within {int(distance_km)} km of {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
|
"severity": "immediate",
|
|
"node_num": None,
|
|
"node_name": name,
|
|
"node_short": "FIRE",
|
|
"region": anchor,
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "immediate",
|
|
})
|
|
|
|
elif distance_km < 50:
|
|
# Warning - fire within 50km
|
|
key = f"env_fire_warning_{name}"
|
|
state = self._get_state(key)
|
|
if state.should_fire(now):
|
|
state.fire(now)
|
|
alerts.append({
|
|
"type": "wildfire_proximity",
|
|
"message": f"Wildfire '{name}' {int(distance_km)} km from {anchor} -- {int(acres):,} ac, {int(pct)}% contained",
|
|
"severity": "priority",
|
|
"node_num": None,
|
|
"node_name": name,
|
|
"node_short": "FIRE",
|
|
"region": anchor,
|
|
"scope_type": "mesh",
|
|
"scope_value": None,
|
|
"severity": "routine",
|
|
})
|
|
|
|
return alerts
|