feat: Show hop count and GPS distance in node detail and single-gw listings

- Added hops_away to Connectivity section in node detail
- Added nearest infra distance after Position in node detail
- Added distance from reference infra to single-gw client listings
- Added _haversine_km and _format_distance helper functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-05 21:16:55 +00:00
commit 99c952b432

View file

@ -4,6 +4,7 @@ Refactored to consume MeshDataStore and UnifiedNode directly.
""" """
import logging import logging
import math
import time import time
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
@ -150,6 +151,24 @@ def _is_valid_temperature(temp_c: Optional[float]) -> bool:
return -50 <= temp_c <= 60 return -50 <= temp_c <= 60
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two GPS points in km."""
R = 6371 # Earth radius in km
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
return R * c
def _format_distance(km: float) -> str:
"""Format distance as km/miles."""
miles = km * 0.621371
if km < 1:
return f"{km*1000:.0f}m ({miles*5280:.0f}ft)"
return f"{km:.1f}km ({miles:.1f}mi)"
class MeshReporter: class MeshReporter:
"""Builds text blocks for mesh health prompt injection.""" """Builds text blocks for mesh health prompt injection."""
@ -356,11 +375,36 @@ class MeshReporter:
lines.append(f" INFRA at risk: {sgn_name} - only 1 gateway{src_info}") lines.append(f" INFRA at risk: {sgn_name} - only 1 gateway{src_info}")
if single_clients > 0: if single_clients > 0:
single_client_nodes = [n for n in single_gw_nodes if not n.is_infrastructure] single_client_nodes = [n for n in single_gw_nodes if not n.is_infrastructure]
# Find nearest infra with GPS for distance reference
ref_node = None
for inf in single_infra if single_infra else []:
if inf.latitude and inf.longitude:
ref_node = inf
break
if not ref_node:
for nid_str in region.node_ids:
try:
nid = int(nid_str)
except (ValueError, TypeError):
continue
inf = health.nodes.get(nid)
if inf and inf.is_infrastructure and inf.latitude and inf.longitude:
ref_node = inf
break
lines.append(f" Single-gw clients ({single_clients}):") lines.append(f" Single-gw clients ({single_clients}):")
for scn in single_client_nodes[:10]: for scn in single_client_nodes[:10]:
scn_name = _node_display_name(scn.long_name, scn.short_name, str(scn.node_num)) scn_name = _node_display_name(scn.long_name, scn.short_name, str(scn.node_num))
src_info = f" via {scn.sources[0]}" if len(scn.sources) == 1 else "" src_info = f" via {scn.sources[0]}" if len(scn.sources) == 1 else ""
lines.append(f" {scn_name}{src_info}")
dist_str = ""
if scn.latitude and scn.longitude and ref_node:
km = _haversine_km(scn.latitude, scn.longitude, ref_node.latitude, ref_node.longitude)
dist_str = f" ~{_format_distance(km)} from {ref_node.short_name}"
elif scn.hops_away is not None:
dist_str = f" {scn.hops_away} hops"
lines.append(f" {scn_name}{src_info}{dist_str}")
if len(single_client_nodes) > 10: if len(single_client_nodes) > 10:
lines.append(f" ...and {len(single_client_nodes) - 10} more") lines.append(f" ...and {len(single_client_nodes) - 10} more")
@ -922,6 +966,18 @@ class MeshReporter:
if node.latitude and node.longitude: if node.latitude and node.longitude:
lines.append(f"Position: {node.latitude:.4f}, {node.longitude:.4f}") lines.append(f"Position: {node.latitude:.4f}, {node.longitude:.4f}")
# Distance from nearest infra node
nearest_infra = None
nearest_dist = float('inf')
for n in self.health_engine.mesh_health.nodes.values():
if n.is_infrastructure and n.latitude and n.longitude and n.node_num != node.node_num:
d = _haversine_km(node.latitude, node.longitude, n.latitude, n.longitude)
if d < nearest_dist:
nearest_dist = d
nearest_infra = n
if nearest_infra and nearest_dist < 500:
lines.append(f" Nearest infra: {nearest_infra.short_name} at {_format_distance(nearest_dist)}")
age = _format_age(node.last_heard) age = _format_age(node.last_heard)
status = "Online" if node.is_online else "OFFLINE" status = "Online" if node.is_online else "OFFLINE"
lines.append(f"Last Seen: {age} ({status})") lines.append(f"Last Seen: {age} ({status})")
@ -992,6 +1048,8 @@ class MeshReporter:
# Connectivity # Connectivity
lines.append("") lines.append("")
lines.append("Connectivity:") lines.append("Connectivity:")
if node.hops_away is not None:
lines.append(f" Hops: {node.hops_away}")
lines.append(f" MQTT Uplink: {'Enabled' if node.uplink_enabled else 'Disabled'}") lines.append(f" MQTT Uplink: {'Enabled' if node.uplink_enabled else 'Disabled'}")
# Coverage # Coverage