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:
eddieoz
2025-11-26 20:27:05 +02:00
parent 99c9f72995
commit c20c7e1b41
8 changed files with 581 additions and 44 deletions
+8 -2
View File
@@ -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
View File
@@ -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
+194 -9
View File
@@ -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):
"""
+4 -4
View File
@@ -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
View File
@@ -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...")
+166
View File
@@ -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
View File
@@ -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
+117
View File
@@ -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()