From c20c7e1b4198fbca55783cb3671fd2031d741657 Mon Sep 17 00:00:00 2001 From: eddieoz Date: Wed, 26 Nov 2025 20:27:05 +0200 Subject: [PATCH] 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. --- README.md | 10 +- config.yaml | 26 +++-- mesh_monitor/active_tests.py | 203 +++++++++++++++++++++++++++++++++-- mesh_monitor/analyzer.py | 8 +- mesh_monitor/monitor.py | 78 ++++++++++---- mesh_monitor/reporter.py | 166 ++++++++++++++++++++++++++++ sample-config.yaml | 17 ++- tests/mock_test.py | 117 ++++++++++++++++++++ 8 files changed, 581 insertions(+), 44 deletions(-) create mode 100644 mesh_monitor/reporter.py diff --git a/README.md b/README.md index 14e9f04..3700cda 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.yaml b/config.yaml index 90baf40..faef685 100644 --- a/config.yaml +++ b/config.yaml @@ -2,11 +2,25 @@ # List of Node IDs to prioritize for active testing (Traceroute, etc.) # Format: "!" -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 diff --git a/mesh_monitor/active_tests.py b/mesh_monitor/active_tests.py index 369eb03..4843b58 100644 --- a/mesh_monitor/active_tests.py +++ b/mesh_monitor/active_tests.py @@ -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): """ diff --git a/mesh_monitor/analyzer.py b/mesh_monitor/analyzer.py index 8d3520b..3cb8193 100644 --- a/mesh_monitor/analyzer.py +++ b/mesh_monitor/analyzer.py @@ -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 diff --git a/mesh_monitor/monitor.py b/mesh_monitor/monitor.py index 6fad0bc..69337c3 100644 --- a/mesh_monitor/monitor.py +++ b/mesh_monitor/monitor.py @@ -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...") diff --git a/mesh_monitor/reporter.py b/mesh_monitor/reporter.py new file mode 100644 index 0000000..b27ea56 --- /dev/null +++ b/mesh_monitor/reporter.py @@ -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") diff --git a/sample-config.yaml b/sample-config.yaml index 854fafe..9e2b6b8 100644 --- a/sample-config.yaml +++ b/sample-config.yaml @@ -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 diff --git a/tests/mock_test.py b/tests/mock_test.py index cf83883..42efbcc 100644 --- a/tests/mock_test.py +++ b/tests/mock_test.py @@ -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()