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:
eddieoz
2025-11-27 02:06:40 +02:00
parent 0650631103
commit cbda7e8432
12 changed files with 930 additions and 184 deletions
+157 -36
View File
@@ -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
View File
@@ -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
View File
@@ -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("->", "&rarr;")
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):
+155
View File
@@ -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