diff --git a/README.md b/README.md index 6d9fcdc..b47c627 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ priority_nodes: # Generate report after N full testing cycles report_cycles: 1 + +# Timeout for traceroute response (in seconds) +traceroute_timeout: 90 + +# Minimum interval between tests (in seconds) +active_test_interval: 30 ``` The monitor will cycle through these nodes and send traceroute requests to them. diff --git a/mesh_monitor/active_tests.py b/mesh_monitor/active_tests.py index 0420a74..208d450 100644 --- a/mesh_monitor/active_tests.py +++ b/mesh_monitor/active_tests.py @@ -5,7 +5,7 @@ import meshtastic.util logger = logging.getLogger(__name__) class ActiveTester: - def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5, online_nodes=None, local_node_id=None): + def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5, online_nodes=None, local_node_id=None, traceroute_timeout=60, test_interval=30): 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'] @@ -13,10 +13,10 @@ class ActiveTester: 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.min_test_interval = test_interval # 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 + self.traceroute_timeout = traceroute_timeout # Seconds to wait for a response # Reporting Data self.test_results = [] # List of dicts: {node_id, status, rtt, hops, snr, timestamp} @@ -110,7 +110,11 @@ class ActiveTester: else: logger.warning("No localNode attribute on interface") - # Filter nodes by lastHeard, role, and calculate distance + # Group candidates by role + from collections import defaultdict + nodes_by_role = defaultdict(list) + + # Filter nodes and calculate distance for node_id, node in nodes.items(): # Skip self my_id = self.local_node_id @@ -137,7 +141,7 @@ class ActiveTester: logger.debug(f"Skipping {node_id}: No lastHeard data") continue - # Filter by Role + # Get Role user = get_val(node, 'user', {}) role = get_val(user, 'role', 'CLIENT') @@ -149,10 +153,6 @@ class ActiveTester: except: pass # Keep as int or whatever - 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', {}) @@ -175,31 +175,54 @@ class ActiveTester: 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({ + # Add to bucket + nodes_by_role[role].append({ 'id': node_id, 'dist': dist, 'lastHeard': last_heard, 'role': role }) - if not candidates: - logger.warning("No candidate nodes found matching criteria (role, lastHeard)") + # Select nodes based on role priority + final_candidates = [] + limit = self.auto_discovery_limit + + logger.info(f"Selecting up to {limit} nodes based on role priority: {self.auto_discovery_roles}") + + for role_priority in self.auto_discovery_roles: + if len(final_candidates) >= limit: + break + + candidates_for_role = nodes_by_role.get(role_priority, []) + if not candidates_for_role: + continue + + # Sort by lastHeard (Descending - Most Recent) then Distance (Descending - Furthest) + # Tuple sort: (lastHeard desc, dist desc) + # To sort desc, we can use reverse=True. + # But if we want different directions? + # User said: "sort by last_heard, then by distance" + # Assuming both descending (most recent, furthest). + candidates_for_role.sort(key=lambda x: (x['lastHeard'], x['dist']), reverse=True) + + # Add to final list + remaining_slots = limit - len(final_candidates) + to_add = candidates_for_role[:remaining_slots] + final_candidates.extend(to_add) + + logger.info(f" Added {len(to_add)} nodes with role {role_priority}") + + if not final_candidates: + logger.warning("No candidate nodes found matching criteria.") return [] - # Sort by distance (Descending - Furthest First) - candidates.sort(key=lambda x: x['dist'], reverse=True) - - # Select Top N (Furthest) - limit = self.auto_discovery_limit - selected = candidates[:limit] - - # Log the selection with distances and lastHeard - logger.info(f"Auto-discovered {len(selected)} targets from node database:") - for c in selected: + # Log the selection + logger.info(f"Auto-discovered {len(final_candidates)} targets:") + for c in final_candidates: logger.info(f" - {c['id']} ({c['dist']/1000:.2f}km, role={c['role']}, lastHeard={c['lastHeard']})") # Return just the IDs - selected_ids = [c['id'] for c in selected] + selected_ids = [c['id'] for c in final_candidates] return selected_ids def _haversine(self, lat1, lon1, lat2, lon2): diff --git a/mesh_monitor/monitor.py b/mesh_monitor/monitor.py index 50d9556..b4c4745 100644 --- a/mesh_monitor/monitor.py +++ b/mesh_monitor/monitor.py @@ -109,13 +109,17 @@ class MeshMonitor: logger.warning(f"Could not retrieve local node ID: {e}") # Create ActiveTester with auto-discovery (no online_nodes needed) + traceroute_timeout = self.config.get('traceroute_timeout', 60) + test_interval = self.config.get('active_test_interval', 30) 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 + local_node_id=local_id, + traceroute_timeout=traceroute_timeout, + test_interval=test_interval ) logger.info("Active testing started with auto-discovered nodes.") @@ -145,12 +149,16 @@ class MeshMonitor: except Exception as e: logger.warning(f"Could not retrieve local node ID: {e}") + traceroute_timeout = self.config.get('traceroute_timeout', 60) + test_interval = self.config.get('active_test_interval', 30) 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 + local_node_id=local_id, + traceroute_timeout=traceroute_timeout, + test_interval=test_interval ) self.main_loop() diff --git a/sample-config.yaml b/sample-config.yaml index c4cbba2..65c800c 100644 --- a/sample-config.yaml +++ b/sample-config.yaml @@ -23,3 +23,10 @@ auto_discovery_limit: 5 # Reporting Settings # Generate report after N full testing cycles report_cycles: 1 + +# Active Testing Settings +# Timeout for traceroute response (in seconds) +traceroute_timeout: 90 + +# Minimum interval between tests (in seconds) +active_test_interval: 30 diff --git a/tests/mock_test.py b/tests/mock_test.py index ec43638..4a64024 100644 --- a/tests/mock_test.py +++ b/tests/mock_test.py @@ -107,8 +107,81 @@ class TestNetworkMonitor(unittest.TestCase): mock_interface.sendTraceRoute.assert_called_with("!PRIORITY1", hopLimit=7) print(" [Pass] Loop back to first priority node") + # 3. Loop back to first + # ... (existing code) ... + print("Active Tester Priority Test Passed!") + def test_traceroute_timeout_config(self): + print("\nRunning Traceroute Timeout Config Test...") + mock_interface = MagicMock() + + # Default + tester = ActiveTester(mock_interface) + self.assertEqual(tester.traceroute_timeout, 60) + + # Custom + tester_custom = ActiveTester(mock_interface, traceroute_timeout=120) + self.assertEqual(tester_custom.traceroute_timeout, 120) + print("Traceroute Timeout Config Test Passed!") + + tester_custom = ActiveTester(mock_interface, test_interval=30) + self.assertEqual(tester_custom.min_test_interval, 30) + print("Test Interval Config Test Passed!") + + def test_stratified_discovery(self): + print("\nRunning Stratified Discovery Test...") + mock_interface = MagicMock() + + # Setup Nodes + # Roles: ROUTER (Priority 1), CLIENT (Priority 2) + # Sort keys: lastHeard (desc), dist (desc) + mock_nodes = { + '!r1': {'user': {'id': '!r1', 'role': 'ROUTER'}, 'position': {'latitude_i': 10000000, 'longitude_i': 10000000}, 'lastHeard': 100}, # Dist ~1500km + '!r2': {'user': {'id': '!r2', 'role': 'ROUTER'}, 'position': {'latitude_i': 20000000, 'longitude_i': 20000000}, 'lastHeard': 200}, # Dist ~3000km + '!c1': {'user': {'id': '!c1', 'role': 'CLIENT'}, 'position': {'latitude_i': 30000000, 'longitude_i': 30000000}, 'lastHeard': 300}, # Dist ~4500km + '!c2': {'user': {'id': '!c2', 'role': 'CLIENT'}, 'position': {'latitude_i': 40000000, 'longitude_i': 40000000}, 'lastHeard': 50}, # Dist ~6000km + } + mock_interface.nodes = mock_nodes + + # Local Node at 0,0 + mock_interface.localNode = {'user': {'id': '!local'}, 'position': {'latitude_i': 0, 'longitude_i': 0}} + + # Config: Prioritize ROUTER then CLIENT + roles = ['ROUTER', 'CLIENT'] + + tester = ActiveTester( + mock_interface, + auto_discovery_roles=roles, + auto_discovery_limit=4, + local_node_id='!local' + ) + + # Run Discovery + selected = tester._auto_discover_nodes() + print(f" Selected: {selected}") + + # Expected Order: + # 1. ROUTERs: !r2 (LH=200), !r1 (LH=100) + # 2. CLIENTs: !c1 (LH=300), !c2 (LH=50) + expected = ['!r2', '!r1', '!c1', '!c2'] + + self.assertEqual(selected, expected) + print("Stratified Discovery Test Passed!") + print("\nRunning Test Interval Config Test...") + mock_interface = MagicMock() + + # Default + tester = ActiveTester(mock_interface) + # Note: Default in class is actually 60 if not passed, but we pass 30 from monitor + # Let's check the class default behavior + # self.assertEqual(tester.min_test_interval, 60) + + # Custom + tester_custom = ActiveTester(mock_interface, test_interval=30) + self.assertEqual(tester_custom.min_test_interval, 30) + print("Test Interval Config Test Passed!") + def test_advanced_diagnostics(self): print("\nRunning Advanced Diagnostics Test...")