diff --git a/mesh_monitor/active_tests.py b/mesh_monitor/active_tests.py index 802ee7a..8fc0142 100644 --- a/mesh_monitor/active_tests.py +++ b/mesh_monitor/active_tests.py @@ -7,7 +7,7 @@ from .utils import get_val, haversine 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, traceroute_timeout=60, test_interval=30): + 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, analysis_mode='distance', cluster_radius=2000): 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'] @@ -19,6 +19,8 @@ class ActiveTester: self.current_priority_index = 0 self.pending_traceroute = None # Store ID of node we are waiting for self.traceroute_timeout = traceroute_timeout # Seconds to wait for a response + self.analysis_mode = analysis_mode + self.cluster_radius = cluster_radius # Reporting Data self.test_results = [] # List of dicts: {node_id, status, rtt, hops, snr, timestamp} @@ -70,6 +72,9 @@ class ActiveTester: Selects nodes based on lastHeard timestamp, roles, and geolocation. Uses the existing node database instead of waiting for packets. """ + if self.analysis_mode == 'router_clusters': + return self._get_router_cluster_nodes() + candidates = [] nodes = self.interface.nodes @@ -218,6 +223,88 @@ class ActiveTester: selected_ids = [c['id'] for c in final_candidates] return selected_ids + def _get_router_cluster_nodes(self): + """ + Selects nodes that are within cluster_radius of known routers. + """ + logger.info(f"Auto-discovery mode: Router Clusters (Radius: {self.cluster_radius}m)") + nodes = self.interface.nodes + routers = [] + + # 1. Identify Routers with Position + for node_id, node in nodes.items(): + user = get_val(node, 'user', {}) + role = get_val(user, 'role') + + is_router = False + if isinstance(role, int): + if role in [2, 3, 4, 9]: # ROUTER, ROUTER_CLIENT, REPEATER, ROUTER_LATE + is_router = True + elif role in ['ROUTER', 'REPEATER', 'ROUTER_CLIENT', 'ROUTER_LATE']: + is_router = True + + if is_router: + pos = get_val(node, 'position', {}) + lat = get_val(pos, 'latitude') + lon = get_val(pos, 'longitude') + + # Handle integer coordinates if needed + if lat is None: + lat_i = get_val(pos, 'latitude_i') or get_val(pos, 'latitudeI') + if lat_i is not None: lat = lat_i / 1e7 + if lon is None: + lon_i = get_val(pos, 'longitude_i') or get_val(pos, 'longitudeI') + if lon_i is not None: lon = lon_i / 1e7 + + if lat is not None and lon is not None: + routers.append({ + 'id': node_id, + 'lat': lat, + 'lon': lon + }) + + logger.info(f"Found {len(routers)} routers with position.") + + # 2. Find Neighbors for each Router + candidates = set() + + for r in routers: + for node_id, node in nodes.items(): + if node_id == r['id']: continue + + # Check if we should ignore this node (e.g. no lastHeard) + last_heard = get_val(node, 'lastHeard') + if not last_heard: continue + + pos = get_val(node, 'position', {}) + lat = get_val(pos, 'latitude') + lon = get_val(pos, 'longitude') + + # Handle integer coordinates + if lat is None: + lat_i = get_val(pos, 'latitude_i') or get_val(pos, 'latitudeI') + if lat_i is not None: lat = lat_i / 1e7 + if lon is None: + lon_i = get_val(pos, 'longitude_i') or get_val(pos, 'longitudeI') + if lon_i is not None: lon = lon_i / 1e7 + + if lat is not None and lon is not None: + dist = haversine(r['lat'], r['lon'], lat, lon) + if dist <= self.cluster_radius: + candidates.add(node_id) + + # 3. Select Nodes + # Convert to list and sort/limit + candidate_list = list(candidates) + + # Sort by lastHeard (most recent first) + candidate_list.sort(key=lambda nid: get_val(nodes[nid], 'lastHeard', 0), reverse=True) + + selected = candidate_list[:self.auto_discovery_limit] + logger.info(f"Selected {len(selected)} nodes near routers: {selected}") + + return selected + def send_traceroute(self, dest_node_id): """ Sends a traceroute request to the destination node. diff --git a/mesh_monitor/analyzer.py b/mesh_monitor/analyzer.py index 805384f..8dffc3e 100644 --- a/mesh_monitor/analyzer.py +++ b/mesh_monitor/analyzer.py @@ -14,9 +14,10 @@ class NetworkHealthAnalyzer: self.ch_util_threshold = thresholds.get('channel_utilization', 25.0) self.air_util_threshold = thresholds.get('air_util_tx', 7.0) # Updated default to 7% self.router_density_threshold = thresholds.get('router_density_threshold', 2000) + self.active_threshold_seconds = thresholds.get('active_threshold_seconds', 7200) self.max_nodes_long_fast = self.config.get('max_nodes_for_long_fast', 60) - def analyze(self, nodes, packet_history=None, my_node=None): + def analyze(self, nodes, packet_history=None, my_node=None, test_results=None): """ Analyzes the node DB and packet history for potential issues. Returns a list of issue strings. @@ -79,7 +80,7 @@ class NetworkHealthAnalyzer: issues.extend(self.check_hop_counts(packet_history, nodes)) # --- Geospatial Analysis --- - issues.extend(self.check_router_density(nodes)) + issues.extend(self.check_router_density(nodes, test_results)) issues.extend(self.check_network_size_and_preset(nodes)) if my_node: issues.extend(self.check_signal_vs_distance(nodes, my_node)) @@ -155,6 +156,12 @@ class NetworkHealthAnalyzer: if r['id'] in route_hex: relay_count += 1 + + # Check return path as well + route_back = res.get('route_back', []) + route_back_hex = [f"!{n:08x}" if isinstance(n, int) else n for n in route_back] + if r['id'] in route_back_hex: + relay_count += 1 # C. Channel Util ch_util = get_val(r['metrics'], 'channelUtilization', 0) @@ -171,6 +178,8 @@ class NetworkHealthAnalyzer: stats.append({ 'id': r['id'], 'name': r['name'], + 'lat': r['lat'], + 'lon': r['lon'], 'role': r['role'], 'neighbors_2km': total_neighbors, 'routers_2km': nearby_routers, @@ -326,62 +335,97 @@ class NetworkHealthAnalyzer: but we can warn based on size. """ issues = [] - total_nodes = len(nodes) + issues = [] - if total_nodes > self.max_nodes_long_fast: - issues.append(f"Network Size: {total_nodes} nodes detected. If using LONG_FAST, consider switching to a faster preset (e.g. LONG_MODERATE or SHORT_FAST) to reduce collision probability.") + # Filter for active nodes + current_time = time.time() + active_nodes = 0 + + for node in nodes.values(): + last_heard = get_val(node, 'lastHeard', 0) + # Some nodes might use 'last_heard' or other keys, but standard is usually lastHeard in the node dict + # If it's 0, it might be very old or unknown. + + if current_time - last_heard < self.active_threshold_seconds: + active_nodes += 1 + + if active_nodes > self.max_nodes_long_fast: + issues.append(f"Network Size: {active_nodes} active nodes detected (seen in last {self.active_threshold_seconds/3600:.1f}h). If using LONG_FAST, consider switching to a faster preset (e.g. LONG_MODERATE or SHORT_FAST) to reduce collision probability.") return issues - def check_router_density(self, nodes): + def check_router_density(self, nodes, test_results=None): """ Checks for high density of routers. - New Logic: Check for > 2 routers within 2km radius of each other. + Identifies clusters of routers within 'router_density_threshold'. + Recommends keeping the most effective router (highest relay count) and demoting others. """ issues = [] - routers = [] + + # 1. Get Router Stats (includes relay counts) + stats = self.get_router_stats(nodes, test_results) + # Map ID to stat for easy lookup + stat_map = {s['id']: s for s in stats} # Filter for routers with valid position - for node_id, node in nodes.items(): - user = get_val(node, 'user', {}) - role = get_val(user, 'role') - - is_router = False - if isinstance(role, int): - if role in [2, 3, 4, 9]: # ROUTER, ROUTER_CLIENT, REPEATER, ROUTER_LATE - is_router = True - elif role in ['ROUTER', 'REPEATER', 'ROUTER_CLIENT', 'ROUTER_LATE']: - is_router = True - - pos = get_val(node, 'position', {}) - lat = get_val(pos, 'latitude') - lon = get_val(pos, 'longitude') - - if is_router and lat is not None and lon is not None: - routers.append({ - 'id': node_id, - 'name': get_node_name(node, node_id), - 'lat': lat, - 'lon': lon - }) + routers = [] + for s in stats: + # get_router_stats already filters for routers with position + routers.append(s) + + if not routers: + return issues + + # 2. Build Clusters + # Adjacency list: index -> list of neighbor indices + adj = {i: [] for i in range(len(routers))} - # Check density for each router - reported_pairs = set() - - for i, r1 in enumerate(routers): - nearby_routers = [] - for j, r2 in enumerate(routers): - if i == j: continue - + for i in range(len(routers)): + for j in range(i + 1, len(routers)): + r1 = routers[i] + r2 = routers[j] dist = haversine(r1['lat'], r1['lon'], r2['lat'], r2['lon']) - if dist < self.router_density_threshold: - nearby_routers.append(r2) + + if dist < self.router_density_threshold: + adj[i].append(j) + adj[j].append(i) + + # Find connected components (clusters) + visited = set() + clusters = [] + + for i in range(len(routers)): + if i not in visited: + component = [] + stack = [i] + visited.add(i) + while stack: + curr = stack.pop() + component.append(routers[curr]) + for neighbor in adj[curr]: + if neighbor not in visited: + visited.add(neighbor) + stack.append(neighbor) + if len(component) > 1: + clusters.append(component) + + # 3. Analyze Clusters and Generate Recommendations + for cluster in clusters: + # Sort by relay_count (desc), then neighbors_2km (desc) + # We want the "best" router first + cluster.sort(key=lambda x: (x['relay_count'], x['neighbors_2km']), reverse=True) - if len(nearby_routers) >= 1: - # Construct a unique key for this cluster to avoid duplicate messages - # (Simple approach: just report for the center node) - names = [r['name'] for r in nearby_routers] - issues.append(f"Topology: High Router Density! '{r1['name']}' has {len(nearby_routers)} other routers within {self.router_density_threshold}m ({', '.join(names)}). Consider changing some to CLIENT.") + best_router = cluster[0] + others = cluster[1:] + + other_names = [o['name'] for o in others] + + # Construct message + msg = f"Topology: High Router Density! Found cluster of {len(cluster)} routers. " + msg += f"Best positioned seems to be '{best_router['name']}' ({best_router['relay_count']} relays). " + msg += f"Consider changing others to CLIENT: {', '.join(other_names)}." + + issues.append(msg) return issues diff --git a/mesh_monitor/monitor.py b/mesh_monitor/monitor.py index 722860e..e365a37 100644 --- a/mesh_monitor/monitor.py +++ b/mesh_monitor/monitor.py @@ -112,6 +112,9 @@ class MeshMonitor: # 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) + analysis_mode = self.config.get('analysis_mode', 'distance') + cluster_radius = self.config.get('cluster_radius', 2000) + self.active_tester = ActiveTester( self.interface, priority_nodes=[], # Empty - will trigger auto-discovery @@ -120,7 +123,9 @@ class MeshMonitor: online_nodes=set(), # Not used anymore - discovery uses lastHeard local_node_id=local_id, traceroute_timeout=traceroute_timeout, - test_interval=test_interval + test_interval=test_interval, + analysis_mode=analysis_mode, + cluster_radius=cluster_radius ) logger.info("Active testing started with auto-discovered nodes.") @@ -152,6 +157,9 @@ class MeshMonitor: traceroute_timeout = self.config.get('traceroute_timeout', 60) test_interval = self.config.get('active_test_interval', 30) + analysis_mode = self.config.get('analysis_mode', 'distance') + cluster_radius = self.config.get('cluster_radius', 2000) + self.active_tester = ActiveTester( self.interface, priority_nodes=priority_nodes, @@ -159,7 +167,9 @@ class MeshMonitor: auto_discovery_limit=auto_discovery_limit, local_node_id=local_id, traceroute_timeout=traceroute_timeout, - test_interval=test_interval + test_interval=test_interval, + analysis_mode=analysis_mode, + cluster_radius=cluster_radius ) self.main_loop() @@ -330,7 +340,8 @@ class MeshMonitor: my_node = self.interface.localNode # Run Analysis - issues = self.analyzer.analyze(nodes, packet_history=self.packet_history, my_node=my_node) + test_results = self.active_tester.test_results if self.active_tester else [] + issues = self.analyzer.analyze(nodes, packet_history=self.packet_history, my_node=my_node, test_results=test_results) # Run Router Efficiency Analysis (using accumulated test results if available) if self.active_tester: diff --git a/sample-config.yaml b/sample-config.yaml index 9bb9dc7..5b793bb 100644 --- a/sample-config.yaml +++ b/sample-config.yaml @@ -9,6 +9,12 @@ priority_nodes: log_level: info # Auto-Discovery Settings (Used if priority_nodes is empty) +# Analysis Mode: 'distance' (default) or 'router_clusters' +analysis_mode: distance + +# Radius for router cluster analysis (in meters) +cluster_radius: 2000 + # Roles to prioritize for auto-discovery auto_discovery_roles: - ROUTER diff --git a/tests/test_network_size.py b/tests/test_network_size.py new file mode 100644 index 0000000..6d49c34 --- /dev/null +++ b/tests/test_network_size.py @@ -0,0 +1,56 @@ +import unittest +import time +from mesh_monitor.analyzer import NetworkHealthAnalyzer + +class TestNetworkSize(unittest.TestCase): + def setUp(self): + # Config with small max nodes for testing + self.config = { + 'max_nodes_for_long_fast': 5, + 'thresholds': { + 'active_threshold_seconds': 3600 # 1 hour + } + } + self.analyzer = NetworkHealthAnalyzer(config=self.config) + + def test_active_nodes_warning(self): + current_time = time.time() + nodes = {} + + # Add 6 active nodes (should trigger warning since max is 5) + for i in range(6): + nodes[f'!active{i}'] = {'lastHeard': current_time - 100} + + # Add 10 inactive nodes (should be ignored) + for i in range(10): + nodes[f'!inactive{i}'] = {'lastHeard': current_time - 7200} # 2 hours ago + + issues = self.analyzer.check_network_size_and_preset(nodes) + + print("\nActive Nodes Warning Issues:", issues) + + # Should have a warning because 6 active > 5 max + self.assertTrue(any("Network Size" in i for i in issues)) + self.assertTrue(any("6 active nodes" in i for i in issues)) + + def test_no_warning_if_inactive(self): + current_time = time.time() + nodes = {} + + # Add 3 active nodes (under limit of 5) + for i in range(3): + nodes[f'!active{i}'] = {'lastHeard': current_time - 100} + + # Add 50 inactive nodes (total > 5, but active < 5) + for i in range(50): + nodes[f'!inactive{i}'] = {'lastHeard': current_time - 7200} + + issues = self.analyzer.check_network_size_and_preset(nodes) + + print("\nNo Warning Issues:", issues) + + # Should NOT have a warning + self.assertFalse(any("Network Size" in i for i in issues)) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_router_clusters.py b/tests/test_router_clusters.py new file mode 100644 index 0000000..6785c07 --- /dev/null +++ b/tests/test_router_clusters.py @@ -0,0 +1,140 @@ +import unittest +from unittest.mock import MagicMock +from mesh_monitor.active_tests import ActiveTester + +class TestRouterClusters(unittest.TestCase): + def setUp(self): + self.mock_interface = MagicMock() + self.tester = ActiveTester( + self.mock_interface, + analysis_mode='router_clusters', + cluster_radius=2000, + auto_discovery_limit=5 + ) + + def test_router_identification(self): + # Setup nodes: 1 Router, 1 Client + self.mock_interface.nodes = { + '!router1': { + 'user': {'role': 'ROUTER'}, + 'position': {'latitude': 10.0, 'longitude': 10.0}, + 'lastHeard': 100 + }, + '!client1': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 10.0, 'longitude': 10.0}, + 'lastHeard': 100 + } + } + + # We need to mock _get_router_cluster_nodes to NOT be called automatically if we were testing run_next_test + # But here we are testing _get_router_cluster_nodes directly + + # This test just verifies the logic inside _get_router_cluster_nodes + # It doesn't really test "identification" in isolation because the method does everything. + # Let's test the whole flow. + + selected = self.tester._get_router_cluster_nodes() + # Client1 is at same location as Router1, so dist is 0 < 2000 + self.assertIn('!client1', selected) + # Router1 is excluded from its own neighbor list? + # The logic says: if node_id == r['id']: continue + # So Router1 should NOT be in the list unless it's near ANOTHER router. + self.assertNotIn('!router1', selected) + + def test_radius_check(self): + # Router at (0,0) + # Client1 at (0.01, 0) ~ 1.1km (Inside 2km) + # Client2 at (0.03, 0) ~ 3.3km (Outside 2km) + self.mock_interface.nodes = { + '!router1': { + 'user': {'role': 'ROUTER'}, + 'position': {'latitude': 0.0, 'longitude': 0.0}, + 'lastHeard': 100 + }, + '!client1': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0}, + 'lastHeard': 100 + }, + '!client2': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.03, 'longitude': 0.0}, + 'lastHeard': 100 + } + } + + selected = self.tester._get_router_cluster_nodes() + self.assertIn('!client1', selected) + self.assertNotIn('!client2', selected) + + def test_limit_and_sorting(self): + # Router at (0,0) + # 3 Clients inside radius + # Limit is 2 + # Sort by lastHeard + self.tester.auto_discovery_limit = 2 + self.mock_interface.nodes = { + '!router1': { + 'user': {'role': 'ROUTER'}, + 'position': {'latitude': 0.0, 'longitude': 0.0}, + 'lastHeard': 100 + }, + '!client1': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0}, + 'lastHeard': 200 # Newest + }, + '!client2': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0}, + 'lastHeard': 100 # Oldest + }, + '!client3': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0}, + 'lastHeard': 150 # Middle + } + } + + selected = self.tester._get_router_cluster_nodes() + self.assertEqual(len(selected), 2) + self.assertEqual(selected[0], '!client1') # Newest first + self.assertEqual(selected[1], '!client3') # Then middle + self.assertNotIn('!client2', selected) # Oldest dropped + + def test_active_node_selection(self): + # Router at (0,0) + # Client1: Inside radius, but no lastHeard (inactive/unknown) + # Client2: Inside radius, lastHeard=0 (inactive) + # Client3: Inside radius, lastHeard=100 (active) + self.mock_interface.nodes = { + '!router1': { + 'user': {'role': 'ROUTER'}, + 'position': {'latitude': 0.0, 'longitude': 0.0}, + 'lastHeard': 100 + }, + '!client1': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0} + # No lastHeard + }, + '!client2': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0}, + 'lastHeard': 0 + }, + '!client3': { + 'user': {'role': 'CLIENT'}, + 'position': {'latitude': 0.01, 'longitude': 0.0}, + 'lastHeard': 100 + } + } + + selected = self.tester._get_router_cluster_nodes() + self.assertIn('!client3', selected) + self.assertNotIn('!client1', selected) + self.assertNotIn('!client2', selected) + +if __name__ == '__main__': + unittest.main()