mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
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:
parent
b945558ba3
commit
a7c409e406
7 changed files with 1195 additions and 13 deletions
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
297
meshai/geo.py
Normal file
297
meshai/geo.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
630
meshai/mesh_health.py
Normal file
630
meshai/mesh_health.py
Normal 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
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue