From a7c409e4068d53597e837a6ed28fc98c46ccfe3b Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Mon, 4 May 2026 16:43:12 +0000 Subject: [PATCH] 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 --- config.example.yaml | 23 ++ meshai/cli/configurator.py | 203 +++++++++++- meshai/config.py | 15 + meshai/geo.py | 297 +++++++++++++++++ meshai/main.py | 36 ++- meshai/mesh_health.py | 630 +++++++++++++++++++++++++++++++++++++ meshai/router.py | 4 +- 7 files changed, 1195 insertions(+), 13 deletions(-) create mode 100644 meshai/geo.py create mode 100644 meshai/mesh_health.py diff --git a/config.example.yaml b/config.example.yaml index da94d17..66e5677 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -100,3 +100,26 @@ knowledge: # refresh_interval: 300 # enabled: true 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: {} diff --git a/meshai/cli/configurator.py b/meshai/cli/configurator.py index 80131d7..57991e1 100644 --- a/meshai/cli/configurator.py +++ b/meshai/cli/configurator.py @@ -92,7 +92,13 @@ class Configurator: 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("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() @@ -101,13 +107,13 @@ class Configurator: if self.modified: console.print("[yellow]* Unsaved changes[/yellow]") console.print() - console.print("[white]13. Save[/white] [dim]Save config, stay in menu[/dim]") - console.print("[green]14. 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. Exit without Saving[/white]") + console.print("[white]14. Save[/white] [dim]Save config, stay in menu[/dim]") + console.print("[green]15. Save & Restart Bot[/green] [dim]Apply changes now[/dim]") + console.print("[white]16. Save & Exit[/white] [dim]Save, restart bot, exit[/dim]") + console.print("[white]17. Exit without Saving[/white]") console.print() - choice = IntPrompt.ask("Select option", default=14) + choice = IntPrompt.ask("Select option", default=15) if choice == 1: self._bot_settings() @@ -132,15 +138,17 @@ class Configurator: elif choice == 11: self._mesh_sources_settings() elif choice == 12: - self._setup_wizard() + self._mesh_intelligence_settings() elif choice == 13: - self._save_only() + self._setup_wizard() elif choice == 14: - self._save_and_restart() + self._save_only() elif choice == 15: + self._save_and_restart() + elif choice == 16: self._save_restart_exit() break - elif choice == 16: + elif choice == 17: break def _show_header(self) -> None: @@ -1008,6 +1016,181 @@ class Configurator: 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: """First-time setup wizard.""" self._clear() diff --git a/meshai/config.py b/meshai/config.py index 7122861..58d70c1 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -176,6 +176,20 @@ class MeshSourceConfig: 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 class Config: """Main configuration container.""" @@ -192,6 +206,7 @@ class Config: meshmonitor: MeshMonitorConfig = field(default_factory=MeshMonitorConfig) knowledge: KnowledgeConfig = field(default_factory=KnowledgeConfig) mesh_sources: list[MeshSourceConfig] = field(default_factory=list) + mesh_intelligence: MeshIntelligenceConfig = field(default_factory=MeshIntelligenceConfig) _config_path: Optional[Path] = field(default=None, repr=False) diff --git a/meshai/geo.py b/meshai/geo.py new file mode 100644 index 0000000..b5cdc9f --- /dev/null +++ b/meshai/geo.py @@ -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 diff --git a/meshai/main.py b/meshai/main.py index 7f890d1..74b9120 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -40,11 +40,13 @@ class MeshAI: self.meshmonitor_sync = None self.knowledge = None self.source_manager = None + self.health_engine = None self.router: Optional[MessageRouter] = None self.responder: Optional[Responder] = None self._running = False self._loop: Optional[asyncio.AbstractEventLoop] = None self._last_cleanup: float = 0.0 + self._last_health_compute: float = 0.0 async def start(self) -> None: """Start the bot.""" @@ -65,6 +67,7 @@ class MeshAI: self._running = True self._loop = asyncio.get_event_loop() self._last_cleanup = time.time() + self._last_health_compute = 0.0 # Write PID file self._write_pid() @@ -79,9 +82,13 @@ class MeshAI: if self.meshmonitor_sync: self.meshmonitor_sync.maybe_refresh() - # Periodic mesh source refresh + # Periodic mesh source refresh and health computation 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 if time.time() - self._last_cleanup >= 3600: @@ -205,6 +212,30 @@ class MeshAI: else: 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 kb_cfg = self.config.knowledge if kb_cfg.enabled and kb_cfg.db_path: @@ -223,6 +254,7 @@ class MeshAI: meshmonitor_sync=self.meshmonitor_sync, knowledge=self.knowledge, source_manager=self.source_manager, + health_engine=self.health_engine, ) # Responder diff --git a/meshai/mesh_health.py b/meshai/mesh_health.py new file mode 100644 index 0000000..c65b432 --- /dev/null +++ b/meshai/mesh_health.py @@ -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 + ] diff --git a/meshai/router.py b/meshai/router.py index 103d664..f008c40 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -68,6 +68,7 @@ class MessageRouter: meshmonitor_sync=None, knowledge=None, source_manager=None, + health_engine=None, ): self.config = config self.connector = connector @@ -77,7 +78,8 @@ class MessageRouter: self.context = context self.meshmonitor_sync = meshmonitor_sync 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)