mirror of
https://github.com/eddieoz/LoRa-Mesh-Analyzer.git
synced 2026-08-11 11:12:54 +02:00
feat: Add Route Analysis, fix traceroute parsing and distance calc
- Fix: Correctly parse RouteDiscovery protobuf from decoded['traceroute'] - Fix: Handle integer coordinates for distance calculation - Feat: Add RouteAnalyzer for relay usage, bottlenecks, and path stability analysis - Feat: Integrate route analysis into NetworkReporter - Docs: Update README and sample-config - Test: Update mock tests and add local ID test
This commit is contained in:
+157
-36
@@ -5,11 +5,13 @@ import meshtastic.util
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ActiveTester:
|
||||
def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5):
|
||||
def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5, online_nodes=None, local_node_id=None):
|
||||
self.interface = interface
|
||||
self.priority_nodes = priority_nodes if priority_nodes else []
|
||||
self.auto_discovery_roles = auto_discovery_roles if auto_discovery_roles else ['ROUTER', 'REPEATER']
|
||||
self.auto_discovery_limit = auto_discovery_limit
|
||||
self.online_nodes = online_nodes if online_nodes else set()
|
||||
self.local_node_id = local_node_id
|
||||
self.last_test_time = 0
|
||||
self.min_test_interval = 60 # Seconds between active tests
|
||||
self.current_priority_index = 0
|
||||
@@ -61,7 +63,8 @@ class ActiveTester:
|
||||
|
||||
def _auto_discover_nodes(self):
|
||||
"""
|
||||
Selects nodes based on roles and geolocation.
|
||||
Selects nodes based on lastHeard timestamp, roles, and geolocation.
|
||||
Uses the existing node database instead of waiting for packets.
|
||||
"""
|
||||
candidates = []
|
||||
nodes = self.interface.nodes
|
||||
@@ -76,23 +79,65 @@ class ActiveTester:
|
||||
my_lat = None
|
||||
my_lon = None
|
||||
if hasattr(self.interface, 'localNode'):
|
||||
pos = get_val(self.interface.localNode, 'position', {})
|
||||
my_lat = get_val(pos, 'latitude')
|
||||
my_lon = get_val(pos, 'longitude')
|
||||
# localNode is a Node object, need to look it up in nodes dict
|
||||
local_node_id = None
|
||||
if hasattr(self.interface.localNode, 'nodeNum'):
|
||||
local_node_id = f"!{self.interface.localNode.nodeNum:08x}"
|
||||
logger.debug(f"Local node ID from nodeNum: {local_node_id}")
|
||||
|
||||
if local_node_id and local_node_id in nodes:
|
||||
local_node_data = nodes[local_node_id]
|
||||
pos = get_val(local_node_data, 'position', {})
|
||||
|
||||
# Try float first
|
||||
my_lat = get_val(pos, 'latitude')
|
||||
my_lon = get_val(pos, 'longitude')
|
||||
|
||||
# Fallback to int
|
||||
if my_lat is None:
|
||||
lat_i = get_val(pos, 'latitude_i') or get_val(pos, 'latitudeI')
|
||||
if lat_i is not None:
|
||||
my_lat = lat_i / 1e7
|
||||
|
||||
if my_lon is None:
|
||||
lon_i = get_val(pos, 'longitude_i') or get_val(pos, 'longitudeI')
|
||||
if lon_i is not None:
|
||||
my_lon = lon_i / 1e7
|
||||
|
||||
logger.info(f"Local node position: lat={my_lat}, lon={my_lon}")
|
||||
else:
|
||||
logger.warning(f"Local node {local_node_id} not found in nodes dict or no nodeNum")
|
||||
else:
|
||||
logger.warning("No localNode attribute on interface")
|
||||
|
||||
# Filter by Role
|
||||
# Filter nodes by lastHeard, role, and calculate distance
|
||||
for node_id, node in nodes.items():
|
||||
# Skip self
|
||||
if hasattr(self.interface, 'localNode'):
|
||||
my_id = get_val(get_val(self.interface.localNode, 'user', {}), 'id')
|
||||
my_id = self.local_node_id
|
||||
|
||||
# Fallback if not passed
|
||||
if not my_id:
|
||||
if hasattr(self.interface, 'localNode'):
|
||||
my_id = get_val(get_val(self.interface.localNode, 'user', {}), 'id')
|
||||
if not my_id and hasattr(self.interface, 'myNode'):
|
||||
my_id = get_val(get_val(self.interface.myNode, 'user', {}), 'id')
|
||||
|
||||
if my_id:
|
||||
# Normalize IDs (remove leading !)
|
||||
my_id_norm = my_id.lstrip('!') if my_id else ""
|
||||
my_id_norm = my_id.lstrip('!')
|
||||
node_id_norm = node_id.lstrip('!')
|
||||
|
||||
if my_id_norm and node_id_norm == my_id_norm:
|
||||
if node_id_norm == my_id_norm:
|
||||
logger.debug(f"Skipping self: {node_id} (Matches local {my_id})")
|
||||
continue
|
||||
|
||||
# Filter by lastHeard - only include nodes that have been heard
|
||||
last_heard = get_val(node, 'lastHeard')
|
||||
if not last_heard or last_heard == 0:
|
||||
logger.debug(f"Skipping {node_id}: No lastHeard data")
|
||||
continue
|
||||
|
||||
# Filter by Role
|
||||
user = get_val(node, 'user', {})
|
||||
role = get_val(user, 'role', 'CLIENT')
|
||||
|
||||
@@ -104,40 +149,57 @@ class ActiveTester:
|
||||
except:
|
||||
pass # Keep as int or whatever
|
||||
|
||||
if role in self.auto_discovery_roles:
|
||||
# Calculate distance if possible
|
||||
dist = 0
|
||||
pos = get_val(node, 'position', {})
|
||||
lat = get_val(pos, 'latitude')
|
||||
lon = get_val(pos, 'longitude')
|
||||
|
||||
if my_lat is not None and my_lon is not None and lat is not None and lon is not None:
|
||||
dist = self._haversine(my_lat, my_lon, lat, lon)
|
||||
|
||||
candidates.append({'id': node_id, 'dist': dist})
|
||||
if role not in self.auto_discovery_roles:
|
||||
logger.debug(f"Skipping {node_id}: Role {role} not in {self.auto_discovery_roles}")
|
||||
continue
|
||||
|
||||
# Calculate distance if possible
|
||||
dist = 0
|
||||
pos = get_val(node, 'position', {})
|
||||
|
||||
# Try float coordinates first
|
||||
lat = get_val(pos, 'latitude')
|
||||
lon = get_val(pos, 'longitude')
|
||||
|
||||
# Fallback to integer coordinates (divide by 1e7)
|
||||
if lat is None:
|
||||
lat_i = get_val(pos, 'latitude_i') or get_val(pos, 'latitudeI')
|
||||
if lat_i is not None:
|
||||
lat = lat_i / 1e7
|
||||
|
||||
if lon is None:
|
||||
lon_i = get_val(pos, 'longitude_i') or get_val(pos, 'longitudeI')
|
||||
if lon_i is not None:
|
||||
lon = lon_i / 1e7
|
||||
|
||||
if my_lat is not None and my_lon is not None and lat is not None and lon is not None:
|
||||
dist = self._haversine(my_lat, my_lon, lat, lon)
|
||||
|
||||
candidates.append({
|
||||
'id': node_id,
|
||||
'dist': dist,
|
||||
'lastHeard': last_heard,
|
||||
'role': role
|
||||
})
|
||||
|
||||
if not candidates:
|
||||
logger.warning("No candidate nodes found matching criteria (role, lastHeard)")
|
||||
return []
|
||||
|
||||
# Sort by distance
|
||||
candidates.sort(key=lambda x: x['dist'])
|
||||
# Sort by distance (Descending - Furthest First)
|
||||
candidates.sort(key=lambda x: x['dist'], reverse=True)
|
||||
|
||||
# Select Mix: 50% nearest, 50% furthest
|
||||
# Select Top N (Furthest)
|
||||
limit = self.auto_discovery_limit
|
||||
if len(candidates) <= limit:
|
||||
return [c['id'] for c in candidates]
|
||||
selected = candidates[:limit]
|
||||
|
||||
half = limit // 2
|
||||
remainder = limit - half
|
||||
# Log the selection with distances and lastHeard
|
||||
logger.info(f"Auto-discovered {len(selected)} targets from node database:")
|
||||
for c in selected:
|
||||
logger.info(f" - {c['id']} ({c['dist']/1000:.2f}km, role={c['role']}, lastHeard={c['lastHeard']})")
|
||||
|
||||
# Nearest
|
||||
selected = candidates[:half]
|
||||
# Furthest (from the end)
|
||||
selected.extend(candidates[-remainder:])
|
||||
|
||||
# Log the selection
|
||||
# Return just the IDs
|
||||
selected_ids = [c['id'] for c in selected]
|
||||
logger.info(f"Auto-discovered {len(selected_ids)} targets: {selected_ids}")
|
||||
return selected_ids
|
||||
|
||||
def _haversine(self, lat1, lon1, lat2, lon2):
|
||||
@@ -181,11 +243,70 @@ class ActiveTester:
|
||||
Records a successful test result.
|
||||
"""
|
||||
logger.info(f"Recording success for {node_id}")
|
||||
|
||||
# Extract route information from traceroute packet
|
||||
decoded = packet.get('decoded', {})
|
||||
|
||||
logger.debug(f"Decoded packet keys: {list(decoded.keys())}")
|
||||
|
||||
# The traceroute data is in decoded['traceroute'] (parsed by library)
|
||||
# or in RouteDiscovery protobuf in payload (if raw)
|
||||
route = []
|
||||
route_back = []
|
||||
|
||||
# 1. Check for pre-parsed 'traceroute' dict (Meshtastic python lib does this)
|
||||
if 'traceroute' in decoded:
|
||||
tr = decoded['traceroute']
|
||||
if isinstance(tr, dict):
|
||||
route = tr.get('route', [])
|
||||
route_back = tr.get('routeBack', [])
|
||||
logger.debug(f"Found parsed traceroute: route={route}, route_back={route_back}")
|
||||
|
||||
# 2. Fallback: Try to parse RouteDiscovery protobuf from payload
|
||||
elif 'payload' in decoded:
|
||||
try:
|
||||
from meshtastic import mesh_pb2
|
||||
# If payload is bytes, parse it
|
||||
if isinstance(decoded['payload'], bytes):
|
||||
route_discovery = mesh_pb2.RouteDiscovery()
|
||||
route_discovery.ParseFromString(decoded['payload'])
|
||||
route = list(route_discovery.route)
|
||||
route_back = list(route_discovery.route_back)
|
||||
logger.debug(f"Parsed from bytes - route: {route}, route_back: {route_back}")
|
||||
# If it's already a protobuf object
|
||||
elif hasattr(decoded['payload'], 'route'):
|
||||
route = list(decoded['payload'].route)
|
||||
route_back = list(decoded['payload'].route_back)
|
||||
logger.debug(f"Extracted from protobuf - route: {route}, route_back: {route_back}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse RouteDiscovery protobuf: {e}")
|
||||
|
||||
# 3. Fallback: Old dict keys
|
||||
if not route:
|
||||
route = decoded.get('route', [])
|
||||
route_back = decoded.get('routeBack', [])
|
||||
|
||||
# Count hops (number of nodes in route - 1, excluding source)
|
||||
# Route includes: source -> hop1 -> hop2 -> destination
|
||||
# So hops = len(route) - 1 (we don't count the source)
|
||||
hops_to = len(route) - 1 if route and len(route) > 0 else 0
|
||||
hops_back = len(route_back) - 1 if route_back and len(route_back) > 0 else 0
|
||||
|
||||
# Convert route node numbers to hex IDs for logging
|
||||
route_ids = [f"!{node:08x}" if isinstance(node, int) else str(node) for node in route]
|
||||
route_back_ids = [f"!{node:08x}" if isinstance(node, int) else str(node) for node in route_back]
|
||||
|
||||
logger.info(f"Route to {node_id}: {' -> '.join(route_ids)} ({hops_to} hops)")
|
||||
logger.info(f"Route back: {' -> '.join(route_back_ids)} ({hops_back} hops)")
|
||||
|
||||
self.test_results.append({
|
||||
'node_id': node_id,
|
||||
'status': 'success',
|
||||
'rtt': rtt,
|
||||
'hops': packet.get('hopLimit', 0), # Approximate if not in packet
|
||||
'hops_to': hops_to,
|
||||
'hops_back': hops_back,
|
||||
'route': route_ids,
|
||||
'route_back': route_back_ids,
|
||||
'snr': packet.get('rxSnr', 0),
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
+102
-15
@@ -38,6 +38,12 @@ class MeshMonitor:
|
||||
logging.getLogger().setLevel(log_level) # Set root logger too to capture lib logs if needed
|
||||
logger.info(f"Log level set to: {log_level_str}")
|
||||
self.last_analysis_time = 0
|
||||
|
||||
# Discovery State
|
||||
self.discovery_mode = False
|
||||
self.discovery_start_time = 0
|
||||
self.discovery_wait_seconds = self.config.get('discovery_wait_seconds', 60)
|
||||
self.online_nodes = set()
|
||||
|
||||
def load_config(self, config_file):
|
||||
if os.path.exists(config_file):
|
||||
@@ -68,18 +74,6 @@ class MeshMonitor:
|
||||
auto_discovery_roles = self.config.get('auto_discovery_roles', ['ROUTER', 'REPEATER'])
|
||||
auto_discovery_limit = self.config.get('auto_discovery_limit', 5)
|
||||
|
||||
if priority_nodes:
|
||||
logger.info(f"Loaded {len(priority_nodes)} priority nodes for active testing.")
|
||||
else:
|
||||
logger.info(f"No priority nodes found. Auto-discovery enabled (Limit: {auto_discovery_limit}, Roles: {auto_discovery_roles})")
|
||||
|
||||
self.active_tester = ActiveTester(
|
||||
self.interface,
|
||||
priority_nodes=priority_nodes,
|
||||
auto_discovery_roles=auto_discovery_roles,
|
||||
auto_discovery_limit=auto_discovery_limit
|
||||
)
|
||||
|
||||
# ... subscriptions ...
|
||||
pub.subscribe(self.on_receive, "meshtastic.receive")
|
||||
pub.subscribe(self.on_connection, "meshtastic.connection.established")
|
||||
@@ -87,6 +81,78 @@ class MeshMonitor:
|
||||
|
||||
logger.info("Connected to node.")
|
||||
self.running = True
|
||||
|
||||
# Start Discovery Phase if no priority nodes are set
|
||||
if not priority_nodes:
|
||||
logger.info("Auto-discovery mode: Using node database to select targets...")
|
||||
logger.info(f"Will select up to {auto_discovery_limit} nodes matching roles: {auto_discovery_roles}")
|
||||
|
||||
# Get Local Node ID for self-exclusion
|
||||
local_id = None
|
||||
try:
|
||||
# Try myInfo first (protobuf object with my_node_num attribute)
|
||||
if hasattr(self.interface, 'myInfo') and self.interface.myInfo:
|
||||
my_node_num = getattr(self.interface.myInfo, 'my_node_num', None)
|
||||
if my_node_num:
|
||||
# Convert decimal node number to hex ID format (!42bb5074)
|
||||
local_id = f"!{my_node_num:08x}"
|
||||
|
||||
# Fallback: use localNode
|
||||
if not local_id and hasattr(self.interface, 'localNode') and self.interface.localNode:
|
||||
if hasattr(self.interface.localNode, 'user'):
|
||||
local_id = getattr(self.interface.localNode.user, 'id', None)
|
||||
elif isinstance(self.interface.localNode, dict):
|
||||
local_id = self.interface.localNode.get('user', {}).get('id')
|
||||
|
||||
logger.info(f"Local Node ID: {local_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not retrieve local node ID: {e}")
|
||||
|
||||
# Create ActiveTester with auto-discovery (no online_nodes needed)
|
||||
self.active_tester = ActiveTester(
|
||||
self.interface,
|
||||
priority_nodes=[], # Empty - will trigger auto-discovery
|
||||
auto_discovery_roles=auto_discovery_roles,
|
||||
auto_discovery_limit=auto_discovery_limit,
|
||||
online_nodes=set(), # Not used anymore - discovery uses lastHeard
|
||||
local_node_id=local_id
|
||||
)
|
||||
|
||||
logger.info("Active testing started with auto-discovered nodes.")
|
||||
|
||||
else:
|
||||
# Direct start if priority nodes exist
|
||||
logger.info(f"Loaded {len(priority_nodes)} priority nodes for active testing.")
|
||||
|
||||
# Get Local Node ID explicitly
|
||||
local_id = None
|
||||
try:
|
||||
# Try myInfo first (protobuf object with my_node_num attribute)
|
||||
if hasattr(self.interface, 'myInfo') and self.interface.myInfo:
|
||||
my_node_num = getattr(self.interface.myInfo, 'my_node_num', None)
|
||||
if my_node_num:
|
||||
# Convert decimal node number to hex ID format (!42bb5074)
|
||||
local_id = f"!{my_node_num:08x}"
|
||||
|
||||
# Fallback: use localNode
|
||||
if not local_id and hasattr(self.interface, 'localNode') and self.interface.localNode:
|
||||
if hasattr(self.interface.localNode, 'user'):
|
||||
local_id = getattr(self.interface.localNode.user, 'id', None)
|
||||
elif isinstance(self.interface.localNode, dict):
|
||||
local_id = self.interface.localNode.get('user', {}).get('id')
|
||||
|
||||
logger.info(f"Local Node ID: {local_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not retrieve local node ID: {e}")
|
||||
|
||||
self.active_tester = ActiveTester(
|
||||
self.interface,
|
||||
priority_nodes=priority_nodes,
|
||||
auto_discovery_roles=auto_discovery_roles,
|
||||
auto_discovery_limit=auto_discovery_limit,
|
||||
local_node_id=local_id
|
||||
)
|
||||
|
||||
self.main_loop()
|
||||
|
||||
except Exception as e:
|
||||
@@ -178,6 +244,11 @@ class MeshMonitor:
|
||||
current_time = time.time()
|
||||
self.packet_history = [p for p in self.packet_history if current_time - p['rxTime'] < 60]
|
||||
|
||||
# Track Online Nodes (for Discovery)
|
||||
sender_id = packet.get('fromId')
|
||||
if sender_id:
|
||||
self.online_nodes.add(sender_id)
|
||||
|
||||
if packet.get('decoded', {}).get('portnum') == 'ROUTING_APP':
|
||||
# This might be a traceroute response
|
||||
pass
|
||||
@@ -188,11 +259,14 @@ class MeshMonitor:
|
||||
text = packet.get('decoded', {}).get('text', '')
|
||||
logger.info(f"Received Message: {text}")
|
||||
elif portnum == 'TRACEROUTE_APP':
|
||||
logger.debug(f"Received Traceroute Packet: {packet}")
|
||||
logger.info(f"Received Traceroute Packet from {packet.get('fromId')}")
|
||||
logger.debug(f"Full packet: {packet}")
|
||||
logger.debug(f"Decoded: {packet.get('decoded', {})}")
|
||||
if self.active_tester:
|
||||
# Calculate RTT if possible (requires original send time, which we track in active_tester)
|
||||
rtt = time.time() - self.active_tester.last_test_time
|
||||
self.active_tester.record_result(packet.get('fromId'), packet.get('decoded', {}), rtt=rtt)
|
||||
# Pass the full packet so record_result can extract hopLimit and rxSnr
|
||||
self.active_tester.record_result(packet.get('fromId'), packet, rtt=rtt)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing packet: {e}")
|
||||
@@ -210,6 +284,9 @@ class MeshMonitor:
|
||||
try:
|
||||
# Run Analysis every 60 seconds
|
||||
current_time = time.time()
|
||||
|
||||
# --- Active Testing & Analysis ---
|
||||
|
||||
if current_time - self.last_analysis_time >= 60:
|
||||
logger.debug("--- Running Network Analysis ---")
|
||||
nodes = self.interface.nodes
|
||||
@@ -237,11 +314,21 @@ class MeshMonitor:
|
||||
report_cycles = self.config.get('report_cycles', 1)
|
||||
if self.active_tester.completed_cycles >= report_cycles:
|
||||
logger.info(f"Reporting threshold reached ({self.active_tester.completed_cycles} cycles). Generating report...")
|
||||
self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [])
|
||||
|
||||
# Get local node for distance calculations
|
||||
local_node = None
|
||||
if hasattr(self.interface, 'localNode'):
|
||||
local_node = self.interface.localNode
|
||||
|
||||
self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [], local_node=local_node)
|
||||
|
||||
# Reset cycle count and results
|
||||
self.active_tester.completed_cycles = 0
|
||||
self.active_tester.test_results = []
|
||||
|
||||
logger.info("Report generated. Exiting...")
|
||||
self.running = False
|
||||
break
|
||||
|
||||
# Run Active Tests (checks its own interval)
|
||||
if self.active_tester:
|
||||
|
||||
+139
-10
@@ -3,13 +3,15 @@ import time
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from mesh_monitor.route_analyzer import RouteAnalyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class NetworkReporter:
|
||||
def __init__(self, report_dir="."):
|
||||
self.report_dir = report_dir
|
||||
|
||||
def generate_report(self, nodes, test_results, analysis_issues):
|
||||
def generate_report(self, nodes, test_results, analysis_issues, local_node=None):
|
||||
"""
|
||||
Generates a Markdown report based on collected data.
|
||||
"""
|
||||
@@ -19,6 +21,10 @@ class NetworkReporter:
|
||||
|
||||
logger.info(f"Generating network report: {filepath}")
|
||||
|
||||
# Run Route Analysis
|
||||
route_analyzer = RouteAnalyzer(nodes)
|
||||
route_analysis = route_analyzer.analyze_routes(test_results)
|
||||
|
||||
try:
|
||||
with open(filepath, "w") as f:
|
||||
# Header
|
||||
@@ -31,10 +37,13 @@ class NetworkReporter:
|
||||
# 2. Network Health (Analysis Findings)
|
||||
self._write_network_health(f, analysis_issues)
|
||||
|
||||
# 3. Traceroute Results
|
||||
self._write_traceroute_results(f, test_results, nodes)
|
||||
# 3. Route Analysis (New Section)
|
||||
self._write_route_analysis(f, route_analysis)
|
||||
|
||||
# 4. Recommendations
|
||||
# 4. Traceroute Results
|
||||
self._write_traceroute_results(f, test_results, nodes, local_node)
|
||||
|
||||
# 5. Recommendations
|
||||
self._write_recommendations(f, analysis_issues, test_results)
|
||||
|
||||
logger.info(f"Report generated successfully: {filepath}")
|
||||
@@ -53,11 +62,61 @@ class NetworkReporter:
|
||||
|
||||
critical_issues = len([i for i in analysis_issues if "Critical" in i or "Congestion" in i])
|
||||
|
||||
# Get unique nodes from test results (selected online nodes)
|
||||
unique_tested_nodes = len(set([r.get('node_id') for r in test_results]))
|
||||
|
||||
f.write(f"- **Total Nodes Visible:** {total_nodes}\n")
|
||||
f.write(f"- **Nodes Tested:** {total_tests}\n")
|
||||
f.write(f"- **Selected Online Nodes:** {unique_tested_nodes}\n")
|
||||
f.write(f"- **Total Tests Performed:** {total_tests}\n")
|
||||
f.write(f"- **Test Success Rate:** {success_rate:.1f}%\n")
|
||||
f.write(f"- **Critical Issues Found:** {critical_issues}\n\n")
|
||||
|
||||
def _write_route_analysis(self, f, analysis):
|
||||
f.write("## 3. Route Analysis\n")
|
||||
|
||||
if not analysis:
|
||||
f.write("No route analysis data available (no successful traceroutes).\n\n")
|
||||
return
|
||||
|
||||
# 3.1 Relay Usage
|
||||
f.write("### 3.1 Top Relays (Backbone Nodes)\n")
|
||||
relays = analysis.get('relay_usage', [])
|
||||
if relays:
|
||||
f.write("| Node ID | Name | Times Used as Relay |\n")
|
||||
f.write("|---|---|---|\n")
|
||||
for r in relays[:10]: # Top 10
|
||||
f.write(f"| `{r['id']}` | {r['name']} | {r['count']} |\n")
|
||||
f.write("\n")
|
||||
else:
|
||||
f.write("No intermediate relays detected in successful traceroutes.\n\n")
|
||||
|
||||
# 3.2 Bottlenecks
|
||||
f.write("### 3.2 Potential Bottlenecks (High Centrality)\n")
|
||||
bottlenecks = analysis.get('bottlenecks', [])
|
||||
if bottlenecks:
|
||||
f.write("Nodes that appear in routes to multiple different destinations:\n\n")
|
||||
f.write("| Node ID | Name | Destinations Served |\n")
|
||||
f.write("|---|---|---|\n")
|
||||
for b in bottlenecks:
|
||||
f.write(f"| `{b['id']}` | {b['name']} | {b['destinations_served']} |\n")
|
||||
f.write("\n")
|
||||
else:
|
||||
f.write("No significant bottlenecks identified.\n\n")
|
||||
|
||||
# 3.3 Common Paths
|
||||
f.write("### 3.3 Most Common Paths\n")
|
||||
paths = analysis.get('common_paths', {})
|
||||
if paths:
|
||||
f.write("| Destination | Most Common Path | Stability |\n")
|
||||
f.write("|---|---|---|\n")
|
||||
for dest, data in paths.items():
|
||||
stability = f"{data['stability']:.1f}%"
|
||||
path = data['path'].replace("->", "→")
|
||||
f.write(f"| `{dest}` | {path} | {stability} |\n")
|
||||
f.write("\n")
|
||||
else:
|
||||
f.write("No path data available.\n\n")
|
||||
|
||||
def _write_network_health(self, f, analysis_issues):
|
||||
f.write("## 2. Network Health Analysis\n")
|
||||
if not analysis_issues:
|
||||
@@ -100,14 +159,14 @@ class NetworkReporter:
|
||||
for i in other: f.write(f"- {i}\n")
|
||||
f.write("\n")
|
||||
|
||||
def _write_traceroute_results(self, f, test_results, nodes):
|
||||
def _write_traceroute_results(self, f, test_results, nodes, local_node=None):
|
||||
f.write("## 3. Traceroute Results\n")
|
||||
if not test_results:
|
||||
f.write("No active tests performed in this cycle.\n\n")
|
||||
return
|
||||
|
||||
f.write("| Node ID | Name | Status | RTT (s) | Hops | SNR |\n")
|
||||
f.write("|---|---|---|---|---|---|\n")
|
||||
f.write("| Node ID | Name | Status | Distance (km) | RTT (s) | Hops (To/Back) | SNR |\n")
|
||||
f.write("|---|---|---|---|---|---|---|\n")
|
||||
|
||||
def get_node_name(node_id):
|
||||
node = nodes.get(node_id)
|
||||
@@ -117,22 +176,92 @@ class NetworkReporter:
|
||||
if hasattr(user, 'longName'): return user.longName
|
||||
if isinstance(user, dict): return user.get('longName', node_id)
|
||||
return node_id
|
||||
|
||||
def get_distance(node_id):
|
||||
"""Calculate distance from local node to target node in km."""
|
||||
import math
|
||||
|
||||
if not local_node:
|
||||
return '-'
|
||||
|
||||
# Get local node ID (localNode is a Node object, not in the nodes dict directly)
|
||||
# We need to find the local node in the nodes dict
|
||||
local_node_id = None
|
||||
if hasattr(local_node, 'nodeNum'):
|
||||
# Convert node number to hex ID format
|
||||
local_node_id = f"!{local_node.nodeNum:08x}"
|
||||
|
||||
if not local_node_id:
|
||||
return '-'
|
||||
|
||||
# Look up local node in nodes dict to get position
|
||||
local_node_data = nodes.get(local_node_id)
|
||||
if not local_node_data:
|
||||
return '-'
|
||||
|
||||
# Get local position from nodes dict
|
||||
local_pos = local_node_data.get('position', {}) if isinstance(local_node_data, dict) else getattr(local_node_data, 'position', {})
|
||||
if isinstance(local_pos, dict):
|
||||
my_lat = local_pos.get('latitude')
|
||||
my_lon = local_pos.get('longitude')
|
||||
else:
|
||||
my_lat = getattr(local_pos, 'latitude', None)
|
||||
my_lon = getattr(local_pos, 'longitude', None)
|
||||
|
||||
if my_lat is None or my_lon is None:
|
||||
return '-'
|
||||
|
||||
# Get target node position
|
||||
node = nodes.get(node_id)
|
||||
if not node:
|
||||
return '-'
|
||||
|
||||
target_pos = node.get('position', {}) if isinstance(node, dict) else getattr(node, 'position', {})
|
||||
if isinstance(target_pos, dict):
|
||||
target_lat = target_pos.get('latitude')
|
||||
target_lon = target_pos.get('longitude')
|
||||
else:
|
||||
target_lat = getattr(target_pos, 'latitude', None)
|
||||
target_lon = getattr(target_pos, 'longitude', None)
|
||||
|
||||
if target_lat is None or target_lon is None:
|
||||
return '-'
|
||||
|
||||
# Haversine formula
|
||||
try:
|
||||
lon1, lat1, lon2, lat2 = map(math.radians, [float(my_lon), float(my_lat), float(target_lon), float(target_lat)])
|
||||
dlon = lon2 - lon1
|
||||
dlat = lat2 - lat1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
km = c * 6371 # Earth radius in kilometers
|
||||
return f"{km:.2f}"
|
||||
except:
|
||||
return '-'
|
||||
|
||||
for res in test_results:
|
||||
node_id = res.get('node_id')
|
||||
name = get_node_name(node_id)
|
||||
status = res.get('status', 'unknown')
|
||||
distance = get_distance(node_id)
|
||||
rtt = res.get('rtt', '-')
|
||||
hops = res.get('hops', '-')
|
||||
hops_to = res.get('hops_to', '-')
|
||||
hops_back = res.get('hops_back', '-')
|
||||
snr = res.get('snr', '-')
|
||||
|
||||
# Format RTT
|
||||
if isinstance(rtt, (int, float)):
|
||||
rtt = f"{rtt:.2f}"
|
||||
|
||||
# Format hops
|
||||
if hops_to != '-' and hops_back != '-':
|
||||
hops = f"{hops_to}/{hops_back}"
|
||||
else:
|
||||
hops = '-'
|
||||
|
||||
status_icon = "✅" if status == 'success' else "❌"
|
||||
|
||||
f.write(f"| {node_id} | {name} | {status_icon} {status} | {rtt} | {hops} | {snr} |\n")
|
||||
f.write(f"| {node_id} | {name} | {status_icon} {status} | {distance} | {rtt} | {hops} | {snr} |\n")
|
||||
f.write("\n")
|
||||
|
||||
def _write_recommendations(self, f, analysis_issues, test_results):
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
|
||||
import logging
|
||||
from collections import defaultdict, Counter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RouteAnalyzer:
|
||||
"""
|
||||
Analyzes traceroute history to identify network topology, bottlenecks, and stability.
|
||||
"""
|
||||
def __init__(self, nodes_db=None):
|
||||
self.nodes_db = nodes_db or {}
|
||||
|
||||
def analyze_routes(self, test_results):
|
||||
"""
|
||||
Main entry point for route analysis.
|
||||
Returns a dictionary containing various analysis metrics.
|
||||
"""
|
||||
if not test_results:
|
||||
return {}
|
||||
|
||||
# Filter only successful traceroutes
|
||||
successful_tests = [r for r in test_results if r.get('status') == 'success']
|
||||
|
||||
analysis = {
|
||||
'total_routes': len(successful_tests),
|
||||
'relay_usage': self._analyze_relay_usage(successful_tests),
|
||||
'common_paths': self._analyze_common_paths(successful_tests),
|
||||
'link_quality': self._analyze_link_quality(successful_tests),
|
||||
'bottlenecks': self._identify_bottlenecks(successful_tests)
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
||||
def _analyze_relay_usage(self, results):
|
||||
"""
|
||||
Counts how often each node appears as a relay (excluding source and destination).
|
||||
"""
|
||||
relay_counts = Counter()
|
||||
|
||||
for res in results:
|
||||
# Combine route to and route back
|
||||
# Route lists usually exclude source but include destination (or intermediate hops)
|
||||
# We want strictly intermediate relays
|
||||
|
||||
# Route To: [hop1, hop2, dest]
|
||||
route_to = res.get('route', [])
|
||||
target_id = res.get('node_id')
|
||||
|
||||
for node in route_to:
|
||||
# Normalize ID
|
||||
node_hex = f"!{node:08x}" if isinstance(node, int) else node
|
||||
if node_hex != target_id: # Don't count the destination as a relay
|
||||
relay_counts[node_hex] += 1
|
||||
|
||||
# Route Back: [hop1, hop2, source]
|
||||
# Route back usually ends at us, so we exclude us (which is implicit)
|
||||
route_back = res.get('route_back', [])
|
||||
for node in route_back:
|
||||
node_hex = f"!{node:08x}" if isinstance(node, int) else node
|
||||
# We assume we are not in the list, but just in case
|
||||
relay_counts[node_hex] += 1
|
||||
|
||||
# Convert to list of dicts for easier reporting
|
||||
usage_stats = []
|
||||
for node_id, count in relay_counts.most_common():
|
||||
name = self._get_node_name(node_id)
|
||||
usage_stats.append({
|
||||
'id': node_id,
|
||||
'name': name,
|
||||
'count': count
|
||||
})
|
||||
|
||||
return usage_stats
|
||||
|
||||
def _analyze_common_paths(self, results):
|
||||
"""
|
||||
Identifies the most common path to each destination.
|
||||
"""
|
||||
paths_by_dest = defaultdict(Counter)
|
||||
|
||||
for res in results:
|
||||
target_id = res.get('node_id')
|
||||
route = res.get('route', [])
|
||||
|
||||
# Convert to tuple of hex IDs for hashing
|
||||
route_hex = tuple(f"!{n:08x}" if isinstance(n, int) else n for n in route)
|
||||
|
||||
if route_hex:
|
||||
paths_by_dest[target_id][route_hex] += 1
|
||||
|
||||
# Format for report
|
||||
common_paths = {}
|
||||
for dest, counter in paths_by_dest.items():
|
||||
most_common = counter.most_common(1)[0] # (path_tuple, count)
|
||||
path_str = " -> ".join(most_common[0])
|
||||
common_paths[dest] = {
|
||||
'path': path_str,
|
||||
'count': most_common[1],
|
||||
'total': sum(counter.values()),
|
||||
'stability': (most_common[1] / sum(counter.values())) * 100
|
||||
}
|
||||
|
||||
return common_paths
|
||||
|
||||
def _analyze_link_quality(self, results):
|
||||
"""
|
||||
Aggregates SNR values for specific links (A -> B).
|
||||
"""
|
||||
link_stats = defaultdict(list)
|
||||
|
||||
for res in results:
|
||||
# We need SNR values which correspond to hops
|
||||
# This is tricky because 'route' is just IDs.
|
||||
# We need the 'snr_towards' list if available (which we haven't fully implemented capturing yet)
|
||||
# For now, we can only analyze the final SNR (Us -> First Hop -> ... -> Dest)
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
def _identify_bottlenecks(self, results):
|
||||
"""
|
||||
Identifies nodes that appear in routes to MANY different destinations.
|
||||
High 'betweenness'.
|
||||
"""
|
||||
node_destinations = defaultdict(set)
|
||||
|
||||
for res in results:
|
||||
target_id = res.get('node_id')
|
||||
route = res.get('route', [])
|
||||
|
||||
for node in route:
|
||||
node_hex = f"!{node:08x}" if isinstance(node, int) else node
|
||||
if node_hex != target_id:
|
||||
node_destinations[node_hex].add(target_id)
|
||||
|
||||
# Sort by number of unique destinations served
|
||||
bottlenecks = []
|
||||
for node, dests in node_destinations.items():
|
||||
bottlenecks.append({
|
||||
'id': node,
|
||||
'name': self._get_node_name(node),
|
||||
'destinations_served': len(dests),
|
||||
'destinations': list(dests)
|
||||
})
|
||||
|
||||
bottlenecks.sort(key=lambda x: x['destinations_served'], reverse=True)
|
||||
return bottlenecks[:5] # Top 5
|
||||
|
||||
def _get_node_name(self, node_id):
|
||||
"""Helper to get node name from DB"""
|
||||
if node_id in self.nodes_db:
|
||||
user = self.nodes_db[node_id].get('user', {})
|
||||
return user.get('longName') or user.get('shortName') or node_id
|
||||
return node_id
|
||||
Reference in New Issue
Block a user