feat: Add Phase 2 - Geographic Hierarchy and Health Scoring

Implements mesh intelligence with geo clustering, four-pillar health scoring,
and auto-naming regions from GPS data.

New: geo.py, mesh_health.py
Modified: config.py, main.py, router.py, configurator.py, config.example.yaml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-04 16:43:12 +00:00
commit a7c409e406
7 changed files with 1195 additions and 13 deletions

View file

@ -100,3 +100,26 @@ knowledge:
# refresh_interval: 300 # refresh_interval: 300
# enabled: true # enabled: true
mesh_sources: [] mesh_sources: []
# === MESH INTELLIGENCE ===
# Geographic clustering and health scoring for mesh analysis.
# Requires mesh_sources to be configured with at least one data source.
#
# mesh_intelligence:
# 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
# packet_threshold: 500 # Non-text packets per 24h to flag
# battery_warning_percent: 20 # 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
packet_threshold: 500
battery_warning_percent: 20
infra_overrides: []
region_labels: {}

View file

@ -92,7 +92,13 @@ class Configurator:
src_status = f"{enabled_sources}/{total_sources} enabled" if total_sources else "[dim]none[/dim]" src_status = f"{enabled_sources}/{total_sources} enabled" if total_sources else "[dim]none[/dim]"
table.add_row("11", "Mesh Sources", src_status) table.add_row("11", "Mesh Sources", src_status)
table.add_row("12", "Setup Wizard", "[dim]First-time setup[/dim]") # Mesh Intelligence
mi_status = self._status_icon(self.config.mesh_intelligence.enabled)
mi_regions = len(self.config.mesh_intelligence.region_labels)
mi_info = f"{mi_regions} labels" if mi_regions else "[dim]auto[/dim]"
table.add_row("12", "Mesh Intelligence", f"{mi_status} {mi_info}")
table.add_row("13", "Setup Wizard", "[dim]First-time setup[/dim]")
console.print(table) console.print(table)
console.print() console.print()
@ -101,13 +107,13 @@ class Configurator:
if self.modified: if self.modified:
console.print("[yellow]* Unsaved changes[/yellow]") console.print("[yellow]* Unsaved changes[/yellow]")
console.print() console.print()
console.print("[white]13. Save[/white] [dim]Save config, stay in menu[/dim]") console.print("[white]14. Save[/white] [dim]Save config, stay in menu[/dim]")
console.print("[green]14. Save & Restart Bot[/green] [dim]Apply changes now[/dim]") console.print("[green]15. Save & Restart Bot[/green] [dim]Apply changes now[/dim]")
console.print("[white]15. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]") console.print("[white]16. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]")
console.print("[white]16. Exit without Saving[/white]") console.print("[white]17. Exit without Saving[/white]")
console.print() console.print()
choice = IntPrompt.ask("Select option", default=14) choice = IntPrompt.ask("Select option", default=15)
if choice == 1: if choice == 1:
self._bot_settings() self._bot_settings()
@ -132,15 +138,17 @@ class Configurator:
elif choice == 11: elif choice == 11:
self._mesh_sources_settings() self._mesh_sources_settings()
elif choice == 12: elif choice == 12:
self._setup_wizard() self._mesh_intelligence_settings()
elif choice == 13: elif choice == 13:
self._save_only() self._setup_wizard()
elif choice == 14: elif choice == 14:
self._save_and_restart() self._save_only()
elif choice == 15: elif choice == 15:
self._save_and_restart()
elif choice == 16:
self._save_restart_exit() self._save_restart_exit()
break break
elif choice == 16: elif choice == 17:
break break
def _show_header(self) -> None: def _show_header(self) -> None:
@ -1008,6 +1016,181 @@ class Configurator:
input("\nPress Enter to continue...") input("\nPress Enter to continue...")
def _mesh_intelligence_settings(self) -> None:
"""Mesh intelligence settings submenu."""
while True:
self._clear()
console.print("[bold]Mesh Intelligence Settings[/bold]\n")
console.print("[dim]Geographic clustering and health scoring for mesh analysis.[/dim]\n")
mi = self.config.mesh_intelligence
table = Table(box=box.ROUNDED)
table.add_column("Option", style="cyan", width=4)
table.add_column("Setting", style="white")
table.add_column("Value", style="green")
table.add_row("1", "Enabled", self._status_icon(mi.enabled))
table.add_row("2", "Region Radius (miles)", str(mi.region_radius_miles))
table.add_row("3", "Locality Radius (miles)", str(mi.locality_radius_miles))
table.add_row("4", "Offline Threshold (hours)", str(mi.offline_threshold_hours))
table.add_row("5", "Packet Threshold (24h)", str(mi.packet_threshold))
table.add_row("6", "Battery Warning (%)", str(mi.battery_warning_percent))
table.add_row("7", "Region Labels", f"{len(mi.region_labels)} custom" if mi.region_labels else "[dim]auto[/dim]")
table.add_row("8", "Infra Overrides", f"{len(mi.infra_overrides)} nodes" if mi.infra_overrides else "[dim]none[/dim]")
table.add_row("0", "Back", "")
console.print(table)
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
mi.enabled = not mi.enabled
self.modified = True
elif choice == 2:
value = float(Prompt.ask("Region radius (miles)", default=str(mi.region_radius_miles)))
if value != mi.region_radius_miles:
mi.region_radius_miles = value
self.modified = True
elif choice == 3:
value = float(Prompt.ask("Locality radius (miles)", default=str(mi.locality_radius_miles)))
if value != mi.locality_radius_miles:
mi.locality_radius_miles = value
self.modified = True
elif choice == 4:
value = IntPrompt.ask("Offline threshold (hours)", default=mi.offline_threshold_hours)
if value != mi.offline_threshold_hours:
mi.offline_threshold_hours = value
self.modified = True
elif choice == 5:
value = IntPrompt.ask("Packet threshold (24h)", default=mi.packet_threshold)
if value != mi.packet_threshold:
mi.packet_threshold = value
self.modified = True
elif choice == 6:
value = IntPrompt.ask("Battery warning (%)", default=mi.battery_warning_percent)
if value != mi.battery_warning_percent:
mi.battery_warning_percent = value
self.modified = True
elif choice == 7:
self._edit_region_labels()
elif choice == 8:
self._edit_infra_overrides()
def _edit_region_labels(self) -> None:
"""Edit region label overrides."""
while True:
self._clear()
console.print("[bold]Region Labels[/bold]\n")
console.print("[dim]Override auto-generated region names with custom labels.[/dim]\n")
labels = self.config.mesh_intelligence.region_labels
if labels:
table = Table(box=box.ROUNDED)
table.add_column("#", style="cyan", width=3)
table.add_column("Auto Name", style="white")
table.add_column("Custom Label", style="green")
for i, (auto, custom) in enumerate(labels.items(), 1):
table.add_row(str(i), auto, custom)
console.print(table)
else:
console.print("[dim]No custom labels configured.[/dim]")
console.print()
console.print("[cyan]1.[/cyan] Add label")
console.print("[cyan]2.[/cyan] Remove label")
console.print("[cyan]0.[/cyan] Back")
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
auto_name = Prompt.ask("Auto-generated name to override (e.g., 'Twin Falls')")
if auto_name:
custom_label = Prompt.ask("Custom label")
if custom_label:
self.config.mesh_intelligence.region_labels[auto_name] = custom_label
self.modified = True
console.print(f"[green]Added: '{auto_name}' -> '{custom_label}'[/green]")
input("Press Enter to continue...")
elif choice == 2:
if not labels:
console.print("[yellow]No labels to remove.[/yellow]")
input("\nPress Enter to continue...")
continue
keys = list(labels.keys())
console.print()
for i, k in enumerate(keys, 1):
console.print(f"[cyan]{i}.[/cyan] {k} -> {labels[k]}")
console.print("[cyan]0.[/cyan] Cancel")
console.print()
idx = IntPrompt.ask("Select label to remove", default=0)
if 1 <= idx <= len(keys):
key = keys[idx - 1]
del self.config.mesh_intelligence.region_labels[key]
self.modified = True
console.print(f"[green]Removed '{key}'[/green]")
input("Press Enter to continue...")
def _edit_infra_overrides(self) -> None:
"""Edit infrastructure override node IDs."""
while True:
self._clear()
console.print("[bold]Infrastructure Overrides[/bold]\n")
console.print("[dim]Node IDs to exclude from infrastructure classification.[/dim]\n")
overrides = self.config.mesh_intelligence.infra_overrides
if overrides:
for i, node_id in enumerate(overrides, 1):
console.print(f"[cyan]{i}.[/cyan] {node_id}")
else:
console.print("[dim]No overrides configured.[/dim]")
console.print()
console.print("[cyan]1.[/cyan] Add node ID")
console.print("[cyan]2.[/cyan] Remove node ID")
console.print("[cyan]0.[/cyan] Back")
console.print()
choice = IntPrompt.ask("Select option", default=0)
if choice == 0:
return
elif choice == 1:
node_id = Prompt.ask("Node ID to exclude from infra (e.g., !abc12345)")
if node_id and node_id not in overrides:
self.config.mesh_intelligence.infra_overrides.append(node_id)
self.modified = True
console.print(f"[green]Added: {node_id}[/green]")
input("Press Enter to continue...")
elif node_id in overrides:
console.print("[yellow]Node ID already in list.[/yellow]")
input("\nPress Enter to continue...")
elif choice == 2:
if not overrides:
console.print("[yellow]No overrides to remove.[/yellow]")
input("\nPress Enter to continue...")
continue
console.print()
for i, node_id in enumerate(overrides, 1):
console.print(f"[cyan]{i}.[/cyan] {node_id}")
console.print("[cyan]0.[/cyan] Cancel")
console.print()
idx = IntPrompt.ask("Select to remove", default=0)
if 1 <= idx <= len(overrides):
removed = self.config.mesh_intelligence.infra_overrides.pop(idx - 1)
self.modified = True
console.print(f"[green]Removed: {removed}[/green]")
input("Press Enter to continue...")
def _setup_wizard(self) -> None: def _setup_wizard(self) -> None:
"""First-time setup wizard.""" """First-time setup wizard."""
self._clear() self._clear()

View file

@ -176,6 +176,20 @@ class MeshSourceConfig:
enabled: bool = True enabled: bool = True
@dataclass
class MeshIntelligenceConfig:
"""Mesh intelligence and health scoring settings."""
enabled: bool = False
region_radius_miles: float = 40.0 # Radius for region clustering
locality_radius_miles: float = 8.0 # Radius for locality clustering
offline_threshold_hours: int = 24 # Hours before node considered offline
packet_threshold: int = 500 # Non-text packets per 24h to flag
battery_warning_percent: int = 20 # Battery level for warnings
infra_overrides: list[str] = field(default_factory=list) # Node IDs to exclude from infra
region_labels: dict[str, str] = field(default_factory=dict) # {suggested: custom}
@dataclass @dataclass
class Config: class Config:
"""Main configuration container.""" """Main configuration container."""
@ -192,6 +206,7 @@ class Config:
meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig) meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig)
knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig) knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig)
mesh_sources: list[MeshSourceConfig] = field(default_factory=list) mesh_sources: list[MeshSourceConfig] = field(default_factory=list)
mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig)
_config_path: Optional[Path] = field(default=None, repr=False) _config_path: Optional[Path] = field(default=None, repr=False)

297
meshai/geo.py Normal file
View file

@ -0,0 +1,297 @@
"""Geographic utilities for mesh clustering and naming."""
import logging
import math
from typing import Optional
logger = logging.getLogger(__name__)
# Earth radius in miles
EARTH_RADIUS_MILES = 3958.8
# Idaho/regional city lookup table for auto-naming
# Format: (lat, lon, city_name)
CITY_LOOKUP = [
# Major Idaho cities
(43.6150, -116.2023, "Boise"),
(42.5558, -114.4701, "Twin Falls"),
(43.4926, -114.3514, "Sun Valley"),
(43.5263, -114.2742, "Ketchum"),
(43.4666, -114.4110, "Hailey"),
(43.8231, -111.7924, "Idaho Falls"),
(42.8713, -112.4455, "Pocatello"),
(46.7324, -117.0002, "Moscow"),
(46.4165, -117.0012, "Lewiston"),
(47.6777, -116.7805, "Coeur d'Alene"),
(43.5826, -116.5635, "Nampa"),
(43.5907, -116.3915, "Meridian"),
(43.6629, -116.6874, "Caldwell"),
(42.7257, -114.5178, "Jerome"),
(42.5616, -113.7631, "Burley"),
(42.1087, -113.8830, "Oakley"),
(43.0766, -115.6932, "Mountain Home"),
(44.0682, -114.9311, "Cascade"),
(44.3761, -115.5606, "McCall"),
(43.3493, -116.0553, "Kuna"),
(43.3246, -115.9937, "Melba"),
(43.1279, -115.6911, "Glenns Ferry"),
(42.9088, -115.2598, "Gooding"),
(42.7314, -114.8668, "Wendell"),
(42.5554, -114.0782, "Rupert"),
(42.5516, -113.5557, "Paul"),
(42.7863, -115.0057, "Shoshone"),
(43.1407, -114.4088, "Fairfield"),
(43.9624, -116.5536, "Emmett"),
(44.5429, -116.0489, "Donnelly"),
# Oregon border
(43.9404, -117.0264, "Ontario"),
(44.3793, -117.2291, "Weiser"),
# Utah border
(42.0097, -111.9391, "Preston"),
(42.1141, -112.0265, "Franklin"),
# Nevada border
(41.9942, -114.0836, "Jackpot"),
# Montana border
(46.8721, -114.9992, "Missoula"),
# Wyoming border
(43.4799, -110.7624, "Jackson"),
(43.8554, -111.2227, "Driggs"),
(43.7233, -111.1018, "Victor"),
]
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two GPS coordinates in miles.
Args:
lat1, lon1: First coordinate
lat2, lon2: Second coordinate
Returns:
Distance in miles
"""
# Convert to radians
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
delta_lat = math.radians(lat2 - lat1)
delta_lon = math.radians(lon2 - lon1)
# Haversine formula
a = (math.sin(delta_lat / 2) ** 2 +
math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return EARTH_RADIUS_MILES * c
def nearest_city(lat: float, lon: float) -> tuple[str, float]:
"""Find the nearest city to a GPS coordinate.
Args:
lat: Latitude
lon: Longitude
Returns:
Tuple of (city_name, distance_in_miles)
"""
if not CITY_LOOKUP:
return ("Unknown", 0.0)
nearest = None
min_dist = float("inf")
for city_lat, city_lon, city_name in CITY_LOOKUP:
dist = haversine_distance(lat, lon, city_lat, city_lon)
if dist < min_dist:
min_dist = dist
nearest = city_name
return (nearest or "Unknown", min_dist)
def cluster_by_distance(
nodes: list[dict],
radius_miles: float,
lat_key: str = "latitude",
lon_key: str = "longitude",
id_key: str = "id",
) -> list[list[dict]]:
"""Cluster nodes by GPS proximity using simple agglomerative clustering.
Args:
nodes: List of node dicts with lat/lon coordinates
radius_miles: Maximum distance to be in same cluster
lat_key: Key for latitude in node dict
lon_key: Key for longitude in node dict
id_key: Key for node ID
Returns:
List of clusters, each cluster is a list of nodes
"""
# Filter to nodes with valid GPS
nodes_with_gps = []
for node in nodes:
lat = node.get(lat_key)
lon = node.get(lon_key)
if lat is not None and lon is not None and lat != 0 and lon != 0:
nodes_with_gps.append(node)
if not nodes_with_gps:
return []
# Track cluster assignments
# Each node starts in its own cluster
clusters: list[set[str]] = [{node[id_key]} for node in nodes_with_gps]
node_map = {node[id_key]: node for node in nodes_with_gps}
# Merge clusters that are within radius
changed = True
while changed:
changed = False
i = 0
while i < len(clusters):
j = i + 1
while j < len(clusters):
# Check if any node in cluster i is within radius of any node in cluster j
should_merge = False
for id_a in clusters[i]:
if should_merge:
break
node_a = node_map[id_a]
lat_a = node_a[lat_key]
lon_a = node_a[lon_key]
for id_b in clusters[j]:
node_b = node_map[id_b]
lat_b = node_b[lat_key]
lon_b = node_b[lon_key]
dist = haversine_distance(lat_a, lon_a, lat_b, lon_b)
if dist <= radius_miles:
should_merge = True
break
if should_merge:
# Merge cluster j into cluster i
clusters[i] = clusters[i].union(clusters[j])
clusters.pop(j)
changed = True
else:
j += 1
i += 1
# Convert sets back to node lists
result = []
for cluster_ids in clusters:
cluster_nodes = [node_map[nid] for nid in cluster_ids]
result.append(cluster_nodes)
return result
def get_cluster_center(
nodes: list[dict],
lat_key: str = "latitude",
lon_key: str = "longitude",
) -> tuple[float, float]:
"""Calculate the geographic center of a cluster of nodes.
Args:
nodes: List of node dicts with lat/lon
lat_key: Key for latitude
lon_key: Key for longitude
Returns:
Tuple of (center_lat, center_lon)
"""
if not nodes:
return (0.0, 0.0)
total_lat = 0.0
total_lon = 0.0
count = 0
for node in nodes:
lat = node.get(lat_key)
lon = node.get(lon_key)
if lat is not None and lon is not None:
total_lat += lat
total_lon += lon
count += 1
if count == 0:
return (0.0, 0.0)
return (total_lat / count, total_lon / count)
def suggest_cluster_name(
nodes: list[dict],
lat_key: str = "latitude",
lon_key: str = "longitude",
) -> str:
"""Suggest a name for a cluster based on nearest city.
Args:
nodes: List of nodes in the cluster
lat_key: Key for latitude
lon_key: Key for longitude
Returns:
Suggested name (nearest city)
"""
center_lat, center_lon = get_cluster_center(nodes, lat_key, lon_key)
if center_lat == 0.0 and center_lon == 0.0:
return "Unknown"
city, distance = nearest_city(center_lat, center_lon)
# If very close to city center, just use city name
# If farther away, add qualifier
if distance < 5:
return city
elif distance < 15:
return f"Greater {city}"
else:
return f"{city} Area"
def assign_to_nearest_cluster(
node: dict,
clusters: list[list[dict]],
lat_key: str = "latitude",
lon_key: str = "longitude",
) -> Optional[int]:
"""Find which cluster a node should belong to based on distance.
Args:
node: Node dict with lat/lon
clusters: List of clusters (each a list of nodes)
lat_key: Key for latitude
lon_key: Key for longitude
Returns:
Index of nearest cluster, or None if node has no GPS
"""
node_lat = node.get(lat_key)
node_lon = node.get(lon_key)
if node_lat is None or node_lon is None or (node_lat == 0 and node_lon == 0):
return None
min_dist = float("inf")
nearest_idx = None
for i, cluster in enumerate(clusters):
center_lat, center_lon = get_cluster_center(cluster, lat_key, lon_key)
if center_lat == 0 and center_lon == 0:
continue
dist = haversine_distance(node_lat, node_lon, center_lat, center_lon)
if dist < min_dist:
min_dist = dist
nearest_idx = i
return nearest_idx

View file

@ -40,11 +40,13 @@ class MeshAI:
self.meshmonitor_sync = None self.meshmonitor_sync = None
self.knowledge = None self.knowledge = None
self.source_manager = None self.source_manager = None
self.health_engine = None
self.router: Optional[MessageRouter] = None self.router: Optional[MessageRouter] = None
self.responder: Optional[Responder] = None self.responder: Optional[Responder] = None
self._running = False self._running = False
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._last_cleanup: float = 0.0 self._last_cleanup: float = 0.0
self._last_health_compute: float = 0.0
async def start(self) -> None: async def start(self) -> None:
"""Start the bot.""" """Start the bot."""
@ -65,6 +67,7 @@ class MeshAI:
self._running = True self._running = True
self._loop = asyncio.get_event_loop() self._loop = asyncio.get_event_loop()
self._last_cleanup = time.time() self._last_cleanup = time.time()
self._last_health_compute = 0.0
# Write PID file # Write PID file
self._write_pid() self._write_pid()
@ -79,9 +82,13 @@ class MeshAI:
if self.meshmonitor_sync: if self.meshmonitor_sync:
self.meshmonitor_sync.maybe_refresh() self.meshmonitor_sync.maybe_refresh()
# Periodic mesh source refresh # Periodic mesh source refresh and health computation
if self.source_manager: if self.source_manager:
self.source_manager.refresh_all() refreshed = self.source_manager.refresh_all()
# Recompute health after source refresh
if refreshed > 0 and self.health_engine:
self.health_engine.compute(self.source_manager)
self._last_health_compute = time.time()
# Periodic cleanup # Periodic cleanup
if time.time() - self._last_cleanup >= 3600: if time.time() - self._last_cleanup >= 3600:
@ -205,6 +212,30 @@ class MeshAI:
else: else:
self.source_manager = None self.source_manager = None
# Mesh health engine
mi_cfg = self.config.mesh_intelligence
if mi_cfg.enabled and self.source_manager:
from .mesh_health import MeshHealthEngine
self.health_engine = MeshHealthEngine(
region_radius=mi_cfg.region_radius_miles,
locality_radius=mi_cfg.locality_radius_miles,
offline_threshold_hours=mi_cfg.offline_threshold_hours,
packet_threshold=mi_cfg.packet_threshold,
battery_warning_percent=mi_cfg.battery_warning_percent,
infra_overrides=mi_cfg.infra_overrides,
region_labels=mi_cfg.region_labels,
)
# Initial health computation
mesh_health = self.health_engine.compute(self.source_manager)
self._last_health_compute = time.time()
logger.info(
f"Mesh intelligence enabled: {mesh_health.total_nodes} nodes, "
f"{mesh_health.total_regions} regions, "
f"score {mesh_health.score.composite:.0f}/100 ({mesh_health.score.tier})"
)
else:
self.health_engine = None
# Knowledge base # Knowledge base
kb_cfg = self.config.knowledge kb_cfg = self.config.knowledge
if kb_cfg.enabled and kb_cfg.db_path: if kb_cfg.enabled and kb_cfg.db_path:
@ -223,6 +254,7 @@ class MeshAI:
meshmonitor_sync=self.meshmonitor_sync, meshmonitor_sync=self.meshmonitor_sync,
knowledge=self.knowledge, knowledge=self.knowledge,
source_manager=self.source_manager, source_manager=self.source_manager,
health_engine=self.health_engine,
) )
# Responder # Responder

630
meshai/mesh_health.py Normal file
View file

@ -0,0 +1,630 @@
"""Mesh health scoring engine.
Computes four-pillar health scores at every hierarchy level:
- Infrastructure Uptime (40%)
- Channel Utilization (25%)
- Node Behavior (20%)
- Power Health (15%)
"""
import logging
import time
from dataclasses import dataclass, field
from typing import Optional
from .geo import (
cluster_by_distance,
suggest_cluster_name,
get_cluster_center,
assign_to_nearest_cluster,
haversine_distance,
)
logger = logging.getLogger(__name__)
# Infrastructure roles (auto-detected)
INFRASTRUCTURE_ROLES = {"ROUTER", "ROUTER_LATE", "ROUTER_CLIENT"}
# Default thresholds
DEFAULT_REGION_RADIUS_MILES = 40.0
DEFAULT_LOCALITY_RADIUS_MILES = 8.0
DEFAULT_OFFLINE_THRESHOLD_HOURS = 24
DEFAULT_PACKET_THRESHOLD = 500 # Non-text packets per 24h
DEFAULT_BATTERY_WARNING_PERCENT = 20
# Utilization thresholds (percentage)
UTIL_HEALTHY = 15
UTIL_CAUTION = 20
UTIL_WARNING = 25
UTIL_UNHEALTHY = 35
# Pillar weights
WEIGHT_INFRASTRUCTURE = 0.40
WEIGHT_UTILIZATION = 0.25
WEIGHT_BEHAVIOR = 0.20
WEIGHT_POWER = 0.15
@dataclass
class HealthScore:
"""Health score for a single entity (mesh, region, locality, node)."""
infrastructure: float = 100.0 # 0-100
utilization: float = 100.0 # 0-100
behavior: float = 100.0 # 0-100
power: float = 100.0 # 0-100
# Underlying metrics
infra_online: int = 0
infra_total: int = 0
util_percent: float = 0.0
flagged_nodes: int = 0
battery_warnings: int = 0
solar_index: float = 100.0
@property
def composite(self) -> float:
"""Calculate weighted composite score."""
return (
self.infrastructure * WEIGHT_INFRASTRUCTURE +
self.utilization * WEIGHT_UTILIZATION +
self.behavior * WEIGHT_BEHAVIOR +
self.power * WEIGHT_POWER
)
@property
def tier(self) -> str:
"""Get health tier label."""
score = self.composite
if score >= 90:
return "Healthy"
elif score >= 75:
return "Slight degradation"
elif score >= 50:
return "Unhealthy"
elif score >= 25:
return "Warning"
else:
return "Critical"
@dataclass
class NodeHealth:
"""Health data for a single node."""
node_id: str
short_name: str = ""
long_name: str = ""
role: str = ""
is_infrastructure: bool = False
last_seen: float = 0.0
is_online: bool = True
# Location
latitude: Optional[float] = None
longitude: Optional[float] = None
region: str = ""
locality: str = ""
# Metrics
packet_count_24h: int = 0
text_packet_count_24h: int = 0
battery_percent: Optional[float] = None
voltage: Optional[float] = None
has_solar: bool = False
# Scores
score: HealthScore = field(default_factory=HealthScore)
@property
def non_text_packets(self) -> int:
"""Non-text packets in 24h."""
return self.packet_count_24h - self.text_packet_count_24h
@dataclass
class LocalityHealth:
"""Health data for a locality (sub-region cluster)."""
name: str
suggested_name: str = ""
center_lat: float = 0.0
center_lon: float = 0.0
node_ids: list[str] = field(default_factory=list)
score: HealthScore = field(default_factory=HealthScore)
@dataclass
class RegionHealth:
"""Health data for a region."""
name: str
suggested_name: str = ""
center_lat: float = 0.0
center_lon: float = 0.0
localities: list[LocalityHealth] = field(default_factory=list)
node_ids: list[str] = field(default_factory=list)
score: HealthScore = field(default_factory=HealthScore)
@dataclass
class MeshHealth:
"""Health data for the entire mesh."""
regions: list[RegionHealth] = field(default_factory=list)
unlocated_nodes: list[str] = field(default_factory=list)
nodes: dict[str, NodeHealth] = field(default_factory=dict)
score: HealthScore = field(default_factory=HealthScore)
last_computed: float = 0.0
@property
def total_nodes(self) -> int:
return len(self.nodes)
@property
def total_regions(self) -> int:
return len(self.regions)
class MeshHealthEngine:
"""Computes mesh health scores from aggregated source data."""
def __init__(
self,
region_radius: float = DEFAULT_REGION_RADIUS_MILES,
locality_radius: float = DEFAULT_LOCALITY_RADIUS_MILES,
offline_threshold_hours: int = DEFAULT_OFFLINE_THRESHOLD_HOURS,
packet_threshold: int = DEFAULT_PACKET_THRESHOLD,
battery_warning_percent: int = DEFAULT_BATTERY_WARNING_PERCENT,
infra_overrides: Optional[list[str]] = None,
region_labels: Optional[dict[str, str]] = None,
):
"""Initialize health engine.
Args:
region_radius: Miles radius for region clustering
locality_radius: Miles radius for locality clustering
offline_threshold_hours: Hours before a node is considered offline
packet_threshold: Non-text packets per 24h to flag a node
battery_warning_percent: Battery level for warnings
infra_overrides: Node IDs to exclude from infrastructure
region_labels: Override labels for regions {suggested_name: custom_label}
"""
self.region_radius = region_radius
self.locality_radius = locality_radius
self.offline_threshold_hours = offline_threshold_hours
self.packet_threshold = packet_threshold
self.battery_warning_percent = battery_warning_percent
self.infra_overrides = set(infra_overrides or [])
self.region_labels = dict(region_labels or {})
self._mesh_health: Optional[MeshHealth] = None
@property
def mesh_health(self) -> Optional[MeshHealth]:
"""Get last computed mesh health."""
return self._mesh_health
def compute(self, source_manager) -> MeshHealth:
"""Compute mesh health from source data.
Args:
source_manager: MeshSourceManager with fetched data
Returns:
MeshHealth with computed scores
"""
now = time.time()
offline_threshold = now - (self.offline_threshold_hours * 3600)
# Aggregate all nodes from all sources
all_nodes = source_manager.get_all_nodes()
all_edges = source_manager.get_all_edges()
all_telemetry = source_manager.get_all_telemetry()
all_packets = []
# Get packets from MeshMonitor sources
for status in source_manager.get_status():
if status["type"] == "meshmonitor":
src = source_manager.get_source(status["name"])
if src and hasattr(src, "packets"):
for pkt in src.packets:
tagged = dict(pkt)
tagged["_source"] = status["name"]
all_packets.append(tagged)
# Build node health records
nodes: dict[str, NodeHealth] = {}
for node in all_nodes:
node_id = node.get("id") or node.get("nodeId") or node.get("num")
if not node_id:
continue
node_id = str(node_id)
# Skip if we already have this node from another source
if node_id in nodes:
continue
# Extract fields (handle different API formats)
short_name = node.get("shortName") or node.get("short_name") or ""
long_name = node.get("longName") or node.get("long_name") or ""
role = node.get("role") or node.get("hwModel") or ""
# Determine if infrastructure
is_infra = role.upper() in INFRASTRUCTURE_ROLES
if node_id in self.infra_overrides:
is_infra = False
# Get position
lat = node.get("latitude") or node.get("lat")
lon = node.get("longitude") or node.get("lon")
# Handle nested position object
if lat is None and "position" in node:
pos = node["position"]
lat = pos.get("latitude") or pos.get("lat")
lon = pos.get("longitude") or pos.get("lon")
# Get last seen
last_seen = node.get("lastHeard") or node.get("last_heard") or node.get("lastSeen") or 0
if isinstance(last_seen, str):
try:
from datetime import datetime
last_seen = datetime.fromisoformat(last_seen.replace("Z", "+00:00")).timestamp()
except:
last_seen = 0
is_online = last_seen > offline_threshold if last_seen else False
nodes[node_id] = NodeHealth(
node_id=node_id,
short_name=short_name,
long_name=long_name,
role=role,
is_infrastructure=is_infra,
last_seen=last_seen,
is_online=is_online,
latitude=lat,
longitude=lon,
)
# Add telemetry data
for telem in all_telemetry:
node_id = str(telem.get("nodeId") or telem.get("node_id") or "")
if node_id not in nodes:
continue
node = nodes[node_id]
battery = telem.get("batteryLevel") or telem.get("battery_level")
voltage = telem.get("voltage")
if battery is not None:
node.battery_percent = float(battery)
if voltage is not None:
node.voltage = float(voltage)
# Count packets per node (last 24h)
twenty_four_hours_ago = now - 86400
for pkt in all_packets:
pkt_time = pkt.get("timestamp") or pkt.get("rxTime") or 0
if pkt_time < twenty_four_hours_ago:
continue
from_id = str(pkt.get("from") or pkt.get("fromId") or "")
if from_id not in nodes:
continue
nodes[from_id].packet_count_24h += 1
# Check if text message
port_num = pkt.get("portnum") or pkt.get("port_num") or ""
if "TEXT" in str(port_num).upper():
nodes[from_id].text_packet_count_24h += 1
# Cluster infrastructure nodes into regions
infra_nodes = [n for n in nodes.values() if n.is_infrastructure]
infra_dicts = [
{"id": n.node_id, "latitude": n.latitude, "longitude": n.longitude}
for n in infra_nodes
if n.latitude and n.longitude
]
region_clusters = cluster_by_distance(
infra_dicts,
self.region_radius,
lat_key="latitude",
lon_key="longitude",
id_key="id",
)
# Build regions
regions: list[RegionHealth] = []
for cluster in region_clusters:
suggested = suggest_cluster_name(cluster)
label = self.region_labels.get(suggested, suggested)
center_lat, center_lon = get_cluster_center(cluster)
region = RegionHealth(
name=label,
suggested_name=suggested,
center_lat=center_lat,
center_lon=center_lon,
node_ids=[n["id"] for n in cluster],
)
regions.append(region)
# Mark nodes with their region
for n in cluster:
if n["id"] in nodes:
nodes[n["id"]].region = label
# Assign non-infrastructure nodes to nearest region
unlocated = []
for node in nodes.values():
if node.region:
continue # Already assigned
if node.latitude and node.longitude:
# Find nearest region
min_dist = float("inf")
nearest_region = None
for region in regions:
dist = haversine_distance(
node.latitude, node.longitude,
region.center_lat, region.center_lon
)
if dist < min_dist:
min_dist = dist
nearest_region = region
if nearest_region:
node.region = nearest_region.name
nearest_region.node_ids.append(node.node_id)
else:
unlocated.append(node.node_id)
else:
unlocated.append(node.node_id)
# Create localities within each region
for region in regions:
region_nodes = [
{"id": nid, "latitude": nodes[nid].latitude, "longitude": nodes[nid].longitude}
for nid in region.node_ids
if nodes[nid].latitude and nodes[nid].longitude
]
locality_clusters = cluster_by_distance(
region_nodes,
self.locality_radius,
lat_key="latitude",
lon_key="longitude",
id_key="id",
)
for cluster in locality_clusters:
suggested = suggest_cluster_name(cluster)
center_lat, center_lon = get_cluster_center(cluster)
locality = LocalityHealth(
name=suggested,
suggested_name=suggested,
center_lat=center_lat,
center_lon=center_lon,
node_ids=[n["id"] for n in cluster],
)
region.localities.append(locality)
# Mark nodes with their locality
for n in cluster:
if n["id"] in nodes:
nodes[n["id"]].locality = suggested
# Compute scores at each level
self._compute_locality_scores(regions, nodes)
self._compute_region_scores(regions, nodes)
mesh_score = self._compute_mesh_score(regions, nodes)
# Build result
mesh_health = MeshHealth(
regions=regions,
unlocated_nodes=unlocated,
nodes=nodes,
score=mesh_score,
last_computed=now,
)
self._mesh_health = mesh_health
logger.info(
f"Mesh health computed: {mesh_health.total_nodes} nodes, "
f"{mesh_health.total_regions} regions, score {mesh_score.composite:.0f}/100"
)
return mesh_health
def _compute_locality_scores(
self,
regions: list[RegionHealth],
nodes: dict[str, NodeHealth],
) -> None:
"""Compute health scores for each locality."""
for region in regions:
for locality in region.localities:
locality_nodes = [nodes[nid] for nid in locality.node_ids if nid in nodes]
locality.score = self._compute_node_group_score(locality_nodes)
def _compute_region_scores(
self,
regions: list[RegionHealth],
nodes: dict[str, NodeHealth],
) -> None:
"""Compute health scores for each region."""
for region in regions:
region_nodes = [nodes[nid] for nid in region.node_ids if nid in nodes]
region.score = self._compute_node_group_score(region_nodes)
def _compute_mesh_score(
self,
regions: list[RegionHealth],
nodes: dict[str, NodeHealth],
) -> HealthScore:
"""Compute mesh-wide health score."""
all_nodes = list(nodes.values())
return self._compute_node_group_score(all_nodes)
def _compute_node_group_score(self, node_list: list[NodeHealth]) -> HealthScore:
"""Compute health score for a group of nodes.
Args:
node_list: List of NodeHealth objects
Returns:
HealthScore for the group
"""
if not node_list:
return HealthScore()
# Infrastructure uptime
infra_nodes = [n for n in node_list if n.is_infrastructure]
infra_online = sum(1 for n in infra_nodes if n.is_online)
infra_total = len(infra_nodes)
if infra_total > 0:
infra_score = (infra_online / infra_total) * 100
else:
infra_score = 100.0 # No infrastructure = not penalized
# Channel utilization (simplified - based on packet counts)
# Rough estimate: 1000 packets/day across all nodes = ~15% utilization
total_packets = sum(n.packet_count_24h for n in node_list)
# Estimate utilization: packets / (nodes * 500 baseline)
baseline = len(node_list) * 500
if baseline > 0:
util_percent = (total_packets / baseline) * 15 # Scale to percentage
else:
util_percent = 0
if util_percent < UTIL_HEALTHY:
util_score = 100.0
elif util_percent < UTIL_CAUTION:
util_score = 75.0
elif util_percent < UTIL_WARNING:
util_score = 50.0
elif util_percent < UTIL_UNHEALTHY:
util_score = 25.0
else:
util_score = 0.0
# Node behavior (flagged nodes)
flagged = [n for n in node_list if n.non_text_packets > self.packet_threshold]
flagged_count = len(flagged)
if flagged_count == 0:
behavior_score = 100.0
elif flagged_count == 1:
behavior_score = 80.0
elif flagged_count <= 3:
behavior_score = 60.0
elif flagged_count <= 5:
behavior_score = 40.0
else:
behavior_score = 20.0
# Power health
battery_warnings = 0
nodes_with_battery = 0
for n in node_list:
if n.battery_percent is not None:
nodes_with_battery += 1
if n.battery_percent < self.battery_warning_percent:
battery_warnings += 1
if nodes_with_battery > 0:
battery_ratio = battery_warnings / nodes_with_battery
power_score = 100.0 * (1 - battery_ratio)
else:
power_score = 100.0 # No battery data = assume OK
# Solar index (placeholder - would need solar data)
solar_index = 100.0
return HealthScore(
infrastructure=infra_score,
utilization=util_score,
behavior=behavior_score,
power=power_score,
infra_online=infra_online,
infra_total=infra_total,
util_percent=util_percent,
flagged_nodes=flagged_count,
battery_warnings=battery_warnings,
solar_index=solar_index,
)
def get_region(self, name: str) -> Optional[RegionHealth]:
"""Get a region by name.
Args:
name: Region name (case-insensitive)
Returns:
RegionHealth or None
"""
if not self._mesh_health:
return None
name_lower = name.lower()
for region in self._mesh_health.regions:
if region.name.lower() == name_lower:
return region
if region.suggested_name.lower() == name_lower:
return region
return None
def get_node(self, node_id: str) -> Optional[NodeHealth]:
"""Get a node by ID or short name.
Args:
node_id: Node ID or short name
Returns:
NodeHealth or None
"""
if not self._mesh_health:
return None
# Try direct ID lookup
if node_id in self._mesh_health.nodes:
return self._mesh_health.nodes[node_id]
# Try short name match
node_id_lower = node_id.lower()
for node in self._mesh_health.nodes.values():
if node.short_name.lower() == node_id_lower:
return node
if node.long_name.lower() == node_id_lower:
return node
return None
def get_infrastructure_nodes(self) -> list[NodeHealth]:
"""Get all infrastructure nodes."""
if not self._mesh_health:
return []
return [n for n in self._mesh_health.nodes.values() if n.is_infrastructure]
def get_flagged_nodes(self) -> list[NodeHealth]:
"""Get nodes flagged for excessive packets."""
if not self._mesh_health:
return []
return [
n for n in self._mesh_health.nodes.values()
if n.non_text_packets > self.packet_threshold
]
def get_battery_warnings(self) -> list[NodeHealth]:
"""Get nodes with low battery."""
if not self._mesh_health:
return []
return [
n for n in self._mesh_health.nodes.values()
if n.battery_percent is not None and n.battery_percent < self.battery_warning_percent
]

View file

@ -68,6 +68,7 @@ class MessageRouter:
meshmonitor_sync=None, meshmonitor_sync=None,
knowledge=None, knowledge=None,
source_manager=None, source_manager=None,
health_engine=None,
): ):
self.config = config self.config = config
self.connector = connector self.connector = connector
@ -77,7 +78,8 @@ class MessageRouter:
self.context = context self.context = context
self.meshmonitor_sync = meshmonitor_sync self.meshmonitor_sync = meshmonitor_sync
self.knowledge = knowledge self.knowledge = knowledge
self.source_manager = source_manager # For future use in Phase 3 self.source_manager = source_manager
self.health_engine = health_engine
self.continuations = ContinuationState(max_continuations=3) self.continuations = ContinuationState(max_continuations=3)