mirror of
https://github.com/eddieoz/LoRa-Mesh-Analyzer.git
synced 2026-08-06 16:53:11 +02:00
feat: Add Auto-Discovery, Network Reporting, and Threading improvements
- Implemented Auto-Discovery of traceroute targets based on roles and geolocation. - Added Network Reporting feature (Markdown generation). - Refactored ActiveTester to use threading for non-blocking traceroutes. - Fixed self-exclusion logic in auto-discovery. - Fixed coordinate validation bugs in Analyzer. - Updated config and README.
This commit is contained in:
@@ -20,11 +20,17 @@ The monitor runs a continuous loop (every 60 seconds) and performs the following
|
||||
* **Placement Verification**: Flags `ROUTER` or `REPEATER` nodes that do not have a valid GPS position.
|
||||
* **Router Density**: Flags `ROUTER` nodes that are physically too close (< 500m) to each other, indicating redundancy.
|
||||
|
||||
### 2. Geospatial Analysis
|
||||
### 2. Auto-Discovery of Targets
|
||||
If `priority_nodes` is empty in `config.yaml`, the monitor will automatically select targets based on:
|
||||
- **Roles**: Prioritizes `ROUTER`, `ROUTER_CLIENT`, `REPEATER`, then `CLIENT` (configurable).
|
||||
- **Geolocation**: Selects a mix of the nearest and furthest nodes to test both neighborhood and long-range connectivity.
|
||||
- **Limit**: Configurable limit (default 5) to keep the test cycle manageable.
|
||||
|
||||
### 3. Geospatial Analysis
|
||||
* **Signal vs Distance**: Flags nodes that are close (< 1km) but have poor SNR (< -5dB), indicating potential hardware issues or obstructions.
|
||||
* **Distance Calculation**: Uses GPS coordinates to calculate distances between nodes for topology analysis.
|
||||
|
||||
### 3. Local Configuration Analysis (On Boot)
|
||||
### 4. Local Configuration Analysis (On Boot)
|
||||
* **Role Check**: Warns if the monitoring node itself is set to `ROUTER` or `ROUTER_CLIENT` (Monitoring is best done as `CLIENT`).
|
||||
* **Hop Limit**: Warns if the default hop limit is > 3, which can cause network congestion.
|
||||
|
||||
|
||||
+20
-6
@@ -2,11 +2,25 @@
|
||||
|
||||
# List of Node IDs to prioritize for active testing (Traceroute, etc.)
|
||||
# Format: "!<NodeID>"
|
||||
priority_nodes:
|
||||
- "!ad2836c3"
|
||||
- "!51165eae"
|
||||
- "!cdabef97"
|
||||
- "!d75ae2a0"
|
||||
# priority_nodes:
|
||||
# - "!ad2836c3"
|
||||
# - "!51165eae"
|
||||
# - "!cdabef97"
|
||||
# - "!d75ae2a0"
|
||||
|
||||
# Logging Level [info|debug]
|
||||
# Logging Level [warn|info|debug]
|
||||
log_level: info
|
||||
|
||||
# Auto-Discovery Settings (Used if priority_nodes is empty)
|
||||
# Roles to prioritize for auto-discovery
|
||||
auto_discovery_roles:
|
||||
- ROUTER_LATE
|
||||
- ROUTER
|
||||
- CLIENT
|
||||
|
||||
# Limit number of auto-discovered nodes
|
||||
auto_discovery_limit: 5
|
||||
|
||||
# Reporting Settings
|
||||
# Generate report after N full testing cycles
|
||||
report_cycles: 1
|
||||
|
||||
@@ -5,39 +5,224 @@ import meshtastic.util
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ActiveTester:
|
||||
def __init__(self, interface, priority_nodes=None):
|
||||
def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5):
|
||||
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.last_test_time = 0
|
||||
self.min_test_interval = 30 # Seconds between active tests
|
||||
self.min_test_interval = 60 # Seconds between active tests
|
||||
self.current_priority_index = 0
|
||||
self.pending_traceroute = None # Store ID of node we are waiting for
|
||||
self.traceroute_timeout = 60 # Seconds to wait for a response
|
||||
|
||||
# Reporting Data
|
||||
self.test_results = [] # List of dicts: {node_id, status, rtt, hops, snr, timestamp}
|
||||
self.completed_cycles = 0
|
||||
self.nodes_tested_in_cycle = set()
|
||||
|
||||
def run_next_test(self):
|
||||
"""
|
||||
Runs the next scheduled test. Prioritizes nodes in the config list.
|
||||
"""
|
||||
# If no priority nodes, try auto-discovery
|
||||
if not self.priority_nodes:
|
||||
return
|
||||
self.priority_nodes = self._auto_discover_nodes()
|
||||
if not self.priority_nodes:
|
||||
return # Still no nodes found
|
||||
|
||||
if time.time() - self.last_test_time < self.min_test_interval:
|
||||
current_time = time.time()
|
||||
|
||||
# Check if we are waiting for a timeout
|
||||
if self.pending_traceroute:
|
||||
if current_time - self.last_test_time < self.traceroute_timeout:
|
||||
# Still waiting, don't send new one
|
||||
return
|
||||
else:
|
||||
logger.warning(f"Traceroute to {self.pending_traceroute} timed out.")
|
||||
# Record the timeout
|
||||
self.record_timeout(self.pending_traceroute)
|
||||
|
||||
# Check throttling
|
||||
if current_time - self.last_test_time < self.min_test_interval:
|
||||
return
|
||||
|
||||
# Round-robin through priority nodes
|
||||
# Safety check if list changed or index out of bounds
|
||||
# Safety check if list changed or index out of bounds
|
||||
if self.current_priority_index >= len(self.priority_nodes):
|
||||
self.current_priority_index = 0
|
||||
|
||||
node_id = self.priority_nodes[self.current_priority_index]
|
||||
logger.info(f"Active Test Queue: {self.priority_nodes} (Index: {self.current_priority_index})")
|
||||
self.send_traceroute(node_id)
|
||||
|
||||
self.current_priority_index = (self.current_priority_index + 1) % len(self.priority_nodes)
|
||||
|
||||
def _auto_discover_nodes(self):
|
||||
"""
|
||||
Selects nodes based on roles and geolocation.
|
||||
"""
|
||||
candidates = []
|
||||
nodes = self.interface.nodes
|
||||
|
||||
# Helper to get attribute or dict key (same as in analyzer)
|
||||
def get_val(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Get local position
|
||||
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')
|
||||
|
||||
# Filter by Role
|
||||
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')
|
||||
# Normalize IDs (remove leading !)
|
||||
my_id_norm = my_id.lstrip('!') if my_id else ""
|
||||
node_id_norm = node_id.lstrip('!')
|
||||
|
||||
if my_id_norm and node_id_norm == my_id_norm:
|
||||
logger.debug(f"Skipping self: {node_id} (Matches local {my_id})")
|
||||
continue
|
||||
|
||||
user = get_val(node, 'user', {})
|
||||
role = get_val(user, 'role', 'CLIENT')
|
||||
|
||||
# Convert role to string if int
|
||||
if isinstance(role, int):
|
||||
try:
|
||||
from meshtastic.protobuf import config_pb2
|
||||
role = config_pb2.Config.DeviceConfig.Role.Name(role)
|
||||
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 not candidates:
|
||||
return []
|
||||
|
||||
# Sort by distance
|
||||
candidates.sort(key=lambda x: x['dist'])
|
||||
|
||||
# Select Mix: 50% nearest, 50% furthest
|
||||
limit = self.auto_discovery_limit
|
||||
if len(candidates) <= limit:
|
||||
return [c['id'] for c in candidates]
|
||||
|
||||
half = limit // 2
|
||||
remainder = limit - half
|
||||
|
||||
# Nearest
|
||||
selected = candidates[:half]
|
||||
# Furthest (from the end)
|
||||
selected.extend(candidates[-remainder:])
|
||||
|
||||
# Log the selection
|
||||
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):
|
||||
import math
|
||||
try:
|
||||
lon1, lat1, lon2, lat2 = map(math.radians, [float(lon1), float(lat1), float(lon2), float(lat2)])
|
||||
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))
|
||||
r = 6371000 # Meters
|
||||
return c * r
|
||||
except:
|
||||
return 0
|
||||
|
||||
def send_traceroute(self, dest_node_id):
|
||||
"""
|
||||
Sends a traceroute request to the destination node.
|
||||
Runs in a separate thread to avoid blocking the main loop.
|
||||
"""
|
||||
logger.info(f"Sending traceroute to priority node {dest_node_id}...")
|
||||
try:
|
||||
self.interface.sendTraceRoute(dest_node_id, hopLimit=7)
|
||||
self.last_test_time = time.time()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send traceroute: {e}")
|
||||
|
||||
def _send_task():
|
||||
try:
|
||||
self.interface.sendTraceRoute(dest_node_id, hopLimit=7)
|
||||
logger.debug(f"Traceroute command sent to {dest_node_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send traceroute to {dest_node_id}: {e}")
|
||||
|
||||
# Update state immediately so main loop knows we are busy
|
||||
self.last_test_time = time.time()
|
||||
self.pending_traceroute = dest_node_id
|
||||
|
||||
# Start background thread
|
||||
import threading
|
||||
t = threading.Thread(target=_send_task, daemon=True)
|
||||
t.start()
|
||||
|
||||
def record_result(self, node_id, packet, rtt=None):
|
||||
"""
|
||||
Records a successful test result.
|
||||
"""
|
||||
logger.info(f"Recording success for {node_id}")
|
||||
self.test_results.append({
|
||||
'node_id': node_id,
|
||||
'status': 'success',
|
||||
'rtt': rtt,
|
||||
'hops': packet.get('hopLimit', 0), # Approximate if not in packet
|
||||
'snr': packet.get('rxSnr', 0),
|
||||
'timestamp': time.time()
|
||||
})
|
||||
self._check_cycle_completion(node_id)
|
||||
if self.pending_traceroute == node_id:
|
||||
self.pending_traceroute = None # Clear pending if this was the node we were waiting for
|
||||
|
||||
def record_timeout(self, node_id):
|
||||
"""
|
||||
Records a failed test result (timeout).
|
||||
"""
|
||||
logger.info(f"Recording timeout for {node_id}")
|
||||
self.test_results.append({
|
||||
'node_id': node_id,
|
||||
'status': 'timeout',
|
||||
'timestamp': time.time()
|
||||
})
|
||||
self._check_cycle_completion(node_id)
|
||||
if self.pending_traceroute == node_id:
|
||||
self.pending_traceroute = None # Clear pending if this was the node we were waiting for
|
||||
|
||||
def _check_cycle_completion(self, node_id):
|
||||
"""
|
||||
Tracks which nodes have been tested in the current cycle.
|
||||
"""
|
||||
self.nodes_tested_in_cycle.add(node_id)
|
||||
|
||||
# Check if we have tested all priority nodes
|
||||
# Note: priority_nodes might change if auto-discovery re-runs,
|
||||
# but usually it's stable for a cycle.
|
||||
if self.priority_nodes:
|
||||
all_tested = all(n in self.nodes_tested_in_cycle for n in self.priority_nodes)
|
||||
logger.debug(f"Cycle Progress: {len(self.nodes_tested_in_cycle)}/{len(self.priority_nodes)} nodes tested.")
|
||||
if all_tested:
|
||||
self.completed_cycles += 1
|
||||
logger.info(f"Completed Test Cycle {self.completed_cycles}")
|
||||
self.nodes_tested_in_cycle.clear()
|
||||
|
||||
def flood_test(self, dest_node_id, count=5):
|
||||
"""
|
||||
|
||||
@@ -138,7 +138,7 @@ class NetworkHealthAnalyzer:
|
||||
if not self.ignore_no_position and (role == 'ROUTER' or role == 'REPEATER'):
|
||||
lat = get_val(position, 'latitude')
|
||||
lon = get_val(position, 'longitude')
|
||||
if not lat or not lon:
|
||||
if lat is None or lon is None:
|
||||
issues.append(f"Config: Node '{node_name}' is '{role}' but has no position. Verify placement.")
|
||||
|
||||
# 5. Battery
|
||||
@@ -258,7 +258,7 @@ class NetworkHealthAnalyzer:
|
||||
lat = get_val(pos, 'latitude')
|
||||
lon = get_val(pos, 'longitude')
|
||||
|
||||
if is_router and lat and lon:
|
||||
if is_router and lat is not None and lon is not None:
|
||||
routers.append({
|
||||
'id': node_id,
|
||||
'name': get_val(user, 'longName', node_id),
|
||||
@@ -294,7 +294,7 @@ class NetworkHealthAnalyzer:
|
||||
my_lat = get_val(my_pos, 'latitude')
|
||||
my_lon = get_val(my_pos, 'longitude')
|
||||
|
||||
if not my_lat or not my_lon:
|
||||
if my_lat is None or my_lon is None:
|
||||
return issues # Can't calculate distance relative to me
|
||||
|
||||
for node_id, node in nodes.items():
|
||||
@@ -307,7 +307,7 @@ class NetworkHealthAnalyzer:
|
||||
lat = get_val(pos, 'latitude')
|
||||
lon = get_val(pos, 'longitude')
|
||||
|
||||
if not lat or not lon:
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
|
||||
# Calculate distance
|
||||
|
||||
+56
-22
@@ -8,6 +8,7 @@ import meshtastic.tcp_interface
|
||||
import meshtastic.util
|
||||
from .analyzer import NetworkHealthAnalyzer
|
||||
from .active_tests import ActiveTester
|
||||
from .reporter import NetworkReporter
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
@@ -24,6 +25,7 @@ class MeshMonitor:
|
||||
self.interface_type = interface_type
|
||||
self.hostname = hostname
|
||||
self.analyzer = NetworkHealthAnalyzer(ignore_no_position=ignore_no_position)
|
||||
self.reporter = NetworkReporter()
|
||||
self.active_tester = None
|
||||
self.running = False
|
||||
self.config = self.load_config(config_file)
|
||||
@@ -35,6 +37,7 @@ class MeshMonitor:
|
||||
logger.setLevel(log_level)
|
||||
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
|
||||
|
||||
def load_config(self, config_file):
|
||||
if os.path.exists(config_file):
|
||||
@@ -62,10 +65,20 @@ class MeshMonitor:
|
||||
self.check_local_config()
|
||||
|
||||
priority_nodes = self.config.get('priority_nodes', [])
|
||||
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)
|
||||
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")
|
||||
@@ -175,7 +188,11 @@ class MeshMonitor:
|
||||
text = packet.get('decoded', {}).get('text', '')
|
||||
logger.info(f"Received Message: {text}")
|
||||
elif portnum == 'TRACEROUTE_APP':
|
||||
logger.info(f"Received Traceroute Packet: {packet}")
|
||||
logger.debug(f"Received Traceroute Packet: {packet}")
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing packet: {e}")
|
||||
@@ -191,31 +208,48 @@ class MeshMonitor:
|
||||
logger.info("Starting monitoring loop...")
|
||||
while self.running:
|
||||
try:
|
||||
logger.info("--- Running Network Analysis ---")
|
||||
nodes = self.interface.nodes
|
||||
|
||||
# Get local node info for distance calculations
|
||||
my_node = None
|
||||
if hasattr(self.interface, 'localNode'):
|
||||
my_node = self.interface.localNode
|
||||
|
||||
# Run Analysis
|
||||
issues = self.analyzer.analyze(nodes, packet_history=self.packet_history, my_node=my_node)
|
||||
|
||||
# Report Issues
|
||||
if issues:
|
||||
logger.warning(f"Found {len(issues)} potential issues:")
|
||||
for issue in issues:
|
||||
logger.warning(f" - {issue}")
|
||||
else:
|
||||
logger.info("No critical issues found in current scan.")
|
||||
# Run Analysis every 60 seconds
|
||||
current_time = time.time()
|
||||
if current_time - self.last_analysis_time >= 60:
|
||||
logger.debug("--- Running Network Analysis ---")
|
||||
nodes = self.interface.nodes
|
||||
|
||||
# Get local node info for distance calculations
|
||||
my_node = None
|
||||
if hasattr(self.interface, 'localNode'):
|
||||
my_node = self.interface.localNode
|
||||
|
||||
# Run Analysis
|
||||
issues = self.analyzer.analyze(nodes, packet_history=self.packet_history, my_node=my_node)
|
||||
|
||||
# Report Issues
|
||||
if issues:
|
||||
logger.warning(f"Found {len(issues)} potential issues:")
|
||||
for issue in issues:
|
||||
logger.warning(f" - {issue}")
|
||||
else:
|
||||
logger.debug("No critical issues found in current scan.")
|
||||
|
||||
self.last_analysis_time = current_time
|
||||
|
||||
# Run Active Tests
|
||||
# Check for Reporting Trigger
|
||||
if self.active_tester:
|
||||
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 [])
|
||||
|
||||
# Reset cycle count and results
|
||||
self.active_tester.completed_cycles = 0
|
||||
self.active_tester.test_results = []
|
||||
|
||||
# Run Active Tests (checks its own interval)
|
||||
if self.active_tester:
|
||||
self.active_tester.run_next_test()
|
||||
|
||||
# Wait for next scan
|
||||
time.sleep(60)
|
||||
time.sleep(1)
|
||||
# ... exceptions ...
|
||||
# ... exceptions ...
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Stopping monitor...")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
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):
|
||||
"""
|
||||
Generates a Markdown report based on collected data.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
filename = f"report-{timestamp}.md"
|
||||
filepath = os.path.join(self.report_dir, filename)
|
||||
|
||||
logger.info(f"Generating network report: {filepath}")
|
||||
|
||||
try:
|
||||
with open(filepath, "w") as f:
|
||||
# Header
|
||||
f.write(f"# Meshtastic Network Report\n")
|
||||
f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
||||
|
||||
# 1. Executive Summary
|
||||
self._write_executive_summary(f, nodes, test_results, analysis_issues)
|
||||
|
||||
# 2. Network Health (Analysis Findings)
|
||||
self._write_network_health(f, analysis_issues)
|
||||
|
||||
# 3. Traceroute Results
|
||||
self._write_traceroute_results(f, test_results, nodes)
|
||||
|
||||
# 4. Recommendations
|
||||
self._write_recommendations(f, analysis_issues, test_results)
|
||||
|
||||
logger.info(f"Report generated successfully: {filepath}")
|
||||
return filepath
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate report: {e}")
|
||||
return None
|
||||
|
||||
def _write_executive_summary(self, f, nodes, test_results, analysis_issues):
|
||||
f.write("## 1. Executive Summary\n")
|
||||
|
||||
total_nodes = len(nodes)
|
||||
total_tests = len(test_results)
|
||||
successful_tests = len([r for r in test_results if r.get('status') == 'success'])
|
||||
success_rate = (successful_tests / total_tests * 100) if total_tests > 0 else 0
|
||||
|
||||
critical_issues = len([i for i in analysis_issues if "Critical" in i or "Congestion" in i])
|
||||
|
||||
f.write(f"- **Total Nodes Visible:** {total_nodes}\n")
|
||||
f.write(f"- **Nodes Tested:** {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_network_health(self, f, analysis_issues):
|
||||
f.write("## 2. Network Health Analysis\n")
|
||||
if not analysis_issues:
|
||||
f.write("No significant network issues detected.\n\n")
|
||||
return
|
||||
|
||||
# Group issues by type
|
||||
congestion = []
|
||||
config = []
|
||||
topology = []
|
||||
other = []
|
||||
|
||||
for issue in analysis_issues:
|
||||
if "Congestion" in issue or "Spam" in issue:
|
||||
congestion.append(issue)
|
||||
elif "Config" in issue or "Role" in issue:
|
||||
config.append(issue)
|
||||
elif "Topology" in issue or "Density" in issue or "hops away" in issue:
|
||||
topology.append(issue)
|
||||
else:
|
||||
other.append(issue)
|
||||
|
||||
if congestion:
|
||||
f.write("### Congestion & Airtime\n")
|
||||
for i in congestion: f.write(f"- {i}\n")
|
||||
f.write("\n")
|
||||
|
||||
if config:
|
||||
f.write("### Configuration Issues\n")
|
||||
for i in config: f.write(f"- {i}\n")
|
||||
f.write("\n")
|
||||
|
||||
if topology:
|
||||
f.write("### Topology & Placement\n")
|
||||
for i in topology: f.write(f"- {i}\n")
|
||||
f.write("\n")
|
||||
|
||||
if other:
|
||||
f.write("### Other Findings\n")
|
||||
for i in other: f.write(f"- {i}\n")
|
||||
f.write("\n")
|
||||
|
||||
def _write_traceroute_results(self, f, test_results, nodes):
|
||||
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")
|
||||
|
||||
def get_node_name(node_id):
|
||||
node = nodes.get(node_id)
|
||||
if node:
|
||||
user = node.get('user', {}) if isinstance(node, dict) else getattr(node, 'user', {})
|
||||
# Handle nested object/dict for user
|
||||
if hasattr(user, 'longName'): return user.longName
|
||||
if isinstance(user, dict): return user.get('longName', node_id)
|
||||
return node_id
|
||||
|
||||
for res in test_results:
|
||||
node_id = res.get('node_id')
|
||||
name = get_node_name(node_id)
|
||||
status = res.get('status', 'unknown')
|
||||
rtt = res.get('rtt', '-')
|
||||
hops = res.get('hops', '-')
|
||||
snr = res.get('snr', '-')
|
||||
|
||||
# Format RTT
|
||||
if isinstance(rtt, (int, float)):
|
||||
rtt = f"{rtt:.2f}"
|
||||
|
||||
status_icon = "✅" if status == 'success' else "❌"
|
||||
|
||||
f.write(f"| {node_id} | {name} | {status_icon} {status} | {rtt} | {hops} | {snr} |\n")
|
||||
f.write("\n")
|
||||
|
||||
def _write_recommendations(self, f, analysis_issues, test_results):
|
||||
f.write("## 4. Recommendations\n")
|
||||
|
||||
recs = []
|
||||
|
||||
# Analyze issues for recommendations
|
||||
if any("Congestion" in i for i in analysis_issues):
|
||||
recs.append("- **Reduce Traffic:** High channel utilization detected. Identify spamming nodes or reduce broadcast frequency.")
|
||||
|
||||
if any("ROUTER_CLIENT" in i for i in analysis_issues):
|
||||
recs.append("- **Fix Roles:** Deprecated `ROUTER_CLIENT` role detected. Change these nodes to `CLIENT` or `CLIENT_MUTE`.")
|
||||
|
||||
if any("High Density" in i for i in analysis_issues):
|
||||
recs.append("- **Optimize Placement:** Routers are too close together. Convert redundant routers to clients to reduce noise.")
|
||||
|
||||
if any("poor SNR" in i for i in analysis_issues):
|
||||
recs.append("- **Check Hardware:** Nodes with poor SNR at close range may have antenna issues or bad placement.")
|
||||
|
||||
# Analyze test results
|
||||
failures = [r for r in test_results if r.get('status') != 'success']
|
||||
if failures:
|
||||
recs.append(f"- **Investigate Connectivity:** {len(failures)} nodes failed traceroute tests. Check if they are online or if the path is broken.")
|
||||
|
||||
if not recs:
|
||||
f.write("Network looks healthy! Keep up the good work.\n")
|
||||
else:
|
||||
for r in recs:
|
||||
f.write(f"{r}\n")
|
||||
f.write("\n")
|
||||
+16
-1
@@ -6,5 +6,20 @@ priority_nodes:
|
||||
# - "!12345678"
|
||||
- "!d75ae2a0"
|
||||
|
||||
# Logging Level [info|debug]
|
||||
# Logging Level [warn|info|debug]
|
||||
log_level: info
|
||||
|
||||
# Auto-Discovery Settings (Used if priority_nodes is empty)
|
||||
# Roles to prioritize for auto-discovery
|
||||
auto_discovery_roles:
|
||||
- ROUTER
|
||||
- ROUTER_CLIENT
|
||||
- REPEATER
|
||||
- CLIENT
|
||||
|
||||
# Limit number of auto-discovered nodes
|
||||
auto_discovery_limit: 5
|
||||
|
||||
# Reporting Settings
|
||||
# Generate report after N full testing cycles
|
||||
report_cycles: 1
|
||||
|
||||
@@ -89,6 +89,7 @@ class TestNetworkMonitor(unittest.TestCase):
|
||||
|
||||
# Force time advance to bypass interval check
|
||||
tester.last_test_time = 0
|
||||
tester.pending_traceroute = None # Clear pending to simulate completion
|
||||
|
||||
# 2. Run second test
|
||||
tester.run_next_test()
|
||||
@@ -98,6 +99,7 @@ class TestNetworkMonitor(unittest.TestCase):
|
||||
# Reset mock
|
||||
mock_interface.reset_mock()
|
||||
tester.last_test_time = 0
|
||||
tester.pending_traceroute = None # Clear pending
|
||||
|
||||
# 3. Run third test (should loop back to first)
|
||||
tester.run_next_test()
|
||||
@@ -174,6 +176,75 @@ class TestNetworkMonitor(unittest.TestCase):
|
||||
print(" [Skip] Local Config Test requires complex protobuf mocking. Relying on manual verification.")
|
||||
print("Local Config Check Test Skipped.")
|
||||
|
||||
def test_auto_discovery(self):
|
||||
print("\nRunning Auto-Discovery Test...")
|
||||
from mesh_monitor.active_tests import ActiveTester
|
||||
|
||||
# Mock Interface
|
||||
mock_interface = MagicMock()
|
||||
|
||||
# Mock Nodes
|
||||
mock_interface.nodes = {
|
||||
'!node1': {'user': {'id': '!node1', 'role': 'ROUTER'}, 'position': {'latitude': 10.0, 'longitude': 10.0}}, # Far (~1500km from 0,0)
|
||||
'!node2': {'user': {'id': '!node2', 'role': 'CLIENT'}, 'position': {'latitude': 1.0, 'longitude': 1.0}}, # Near but CLIENT
|
||||
'!node3': {'user': {'id': '!node3', 'role': 'ROUTER'}, 'position': {'latitude': 0.01, 'longitude': 0.01}}, # Very Near (~1.5km)
|
||||
'!node4': {'user': {'id': '!node4', 'role': 'REPEATER'}, 'position': {'latitude': 5.0, 'longitude': 5.0}}, # Mid (~700km)
|
||||
'!node5': {'user': {'id': '!node5', 'role': 'ROUTER'}, 'position': {'latitude': 8.0, 'longitude': 8.0}}, # Far-ish
|
||||
'!local': {'user': {'id': '!local', 'role': 'ROUTER'}, 'position': {'latitude': 0.0, 'longitude': 0.0}}, # Local Node (Self) - Should be skipped
|
||||
}
|
||||
# Mock Local Node at 0,0
|
||||
mock_interface.localNode = {'user': {'id': '!local'}, 'position': {'latitude': 0.0, 'longitude': 0.0}}
|
||||
|
||||
# Initialize ActiveTester with auto-discovery settings
|
||||
tester = ActiveTester(
|
||||
mock_interface,
|
||||
priority_nodes=[],
|
||||
auto_discovery_roles=['ROUTER', 'REPEATER'],
|
||||
auto_discovery_limit=2
|
||||
)
|
||||
|
||||
# Run test - this should trigger auto-discovery
|
||||
tester.run_next_test()
|
||||
|
||||
discovered = tester.priority_nodes
|
||||
print(f" Discovered: {discovered}")
|
||||
|
||||
# Logic Check:
|
||||
# Candidates:
|
||||
# !node1 (ROUTER, Far)
|
||||
# !node3 (ROUTER, Very Near)
|
||||
# !node4 (REPEATER, Mid)
|
||||
# !node5 (ROUTER, Far-ish)
|
||||
# !node2 is CLIENT -> Ignored
|
||||
|
||||
# Distances (approx):
|
||||
# !node3: ~1.5 km
|
||||
# !node4: ~780 km
|
||||
# !node5: ~1200 km
|
||||
# !node1: ~1500 km
|
||||
|
||||
# Sorted: [!node3, !node4, !node5, !node1]
|
||||
|
||||
# Limit 2, Mixed (50/50):
|
||||
# Nearest: !node3
|
||||
# Furthest: !node1
|
||||
# Expected: ['!node3', '!node1']
|
||||
|
||||
self.assertIn('!node3', discovered)
|
||||
self.assertIn('!node1', discovered)
|
||||
self.assertIn('!node3', discovered)
|
||||
self.assertIn('!node1', discovered)
|
||||
self.assertNotIn('!local', discovered) # Ensure self is skipped
|
||||
self.assertNotIn('local', discovered) # Ensure self is skipped even without !
|
||||
self.assertEqual(len(discovered), 2)
|
||||
|
||||
# Verify a traceroute was sent to the first one (which is !node3 or !node1 depending on sort/mix order)
|
||||
# The mix logic appends nearest then furthest. So !node3 then !node1.
|
||||
# run_next_test() sends to the first one.
|
||||
mock_interface.sendTraceRoute.assert_called()
|
||||
|
||||
print("Auto-Discovery Test Passed!")
|
||||
|
||||
def test_geospatial_analysis(self):
|
||||
print("\nRunning Geospatial Analysis Test...")
|
||||
|
||||
@@ -217,5 +288,51 @@ class TestNetworkMonitor(unittest.TestCase):
|
||||
|
||||
print("Geospatial Analysis Test Passed!")
|
||||
|
||||
def test_reporting(self):
|
||||
print("\nRunning Reporting Test...")
|
||||
from mesh_monitor.reporter import NetworkReporter
|
||||
|
||||
# Initialize self.monitor mock since setUp doesn't do it
|
||||
self.monitor = MagicMock()
|
||||
self.monitor.interface = MagicMock()
|
||||
self.monitor.config = {'report_cycles': 1}
|
||||
|
||||
# Mock Reporter
|
||||
self.monitor.reporter = MagicMock(spec=NetworkReporter)
|
||||
|
||||
# Mock ActiveTester with completed cycles
|
||||
self.monitor.active_tester = MagicMock()
|
||||
self.monitor.active_tester.completed_cycles = 1
|
||||
self.monitor.active_tester.test_results = [{'node_id': '!node1', 'status': 'success'}]
|
||||
|
||||
# Mock Interface Nodes
|
||||
self.monitor.interface.nodes = {'!node1': {'user': {'id': '!node1'}}}
|
||||
|
||||
# Trigger main loop logic manually (simulate one iteration)
|
||||
# We can't run the actual main_loop because it's infinite,
|
||||
# so we extract the reporting logic block or simulate the condition.
|
||||
|
||||
# In monitor.py main_loop:
|
||||
# if self.active_tester.completed_cycles >= report_cycles:
|
||||
# self.reporter.generate_report(...)
|
||||
|
||||
# Let's verify the logic by running a snippet that mirrors main_loop's reporting check
|
||||
report_cycles = self.monitor.config.get('report_cycles', 1)
|
||||
if self.monitor.active_tester.completed_cycles >= report_cycles:
|
||||
self.monitor.reporter.generate_report(
|
||||
self.monitor.interface.nodes,
|
||||
self.monitor.active_tester.test_results,
|
||||
[] # issues
|
||||
)
|
||||
self.monitor.active_tester.completed_cycles = 0
|
||||
self.monitor.active_tester.test_results = []
|
||||
|
||||
# Assert Report Generated
|
||||
self.monitor.reporter.generate_report.assert_called_once()
|
||||
self.assertEqual(self.monitor.active_tester.completed_cycles, 0)
|
||||
self.assertEqual(self.monitor.active_tester.test_results, [])
|
||||
|
||||
print("Reporting Test Passed!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user