diff --git a/mesh_monitor/analyzer.py b/mesh_monitor/analyzer.py index 0cc30c7..90c4b59 100644 --- a/mesh_monitor/analyzer.py +++ b/mesh_monitor/analyzer.py @@ -79,12 +79,12 @@ class NetworkHealthAnalyzer: return issues - def check_router_efficiency(self, nodes, test_results=None): + def get_router_stats(self, nodes, test_results=None): """ - Analyzes router placement and efficiency. - Returns a list of issue strings. + Calculates detailed statistics for each router. + Returns a list of dictionaries. """ - issues = [] + stats = [] routers = [] # 1. Identify Routers @@ -94,9 +94,10 @@ class NetworkHealthAnalyzer: is_router = False if isinstance(role, int): - if role in [2, 3]: # ROUTER, ROUTER_CLIENT (REPEATER is 4, usually dumb) + # 2=ROUTER_CLIENT, 3=ROUTER, 4=REPEATER, 5=TRACKER, 6=SENSOR, 7=TAK, 8=CLIENT_MUTE, 9=ROUTER_LATE + if role in [2, 3, 9]: is_router = True - elif role in ['ROUTER', 'ROUTER_CLIENT']: + elif role in ['ROUTER', 'ROUTER_CLIENT', 'ROUTER_LATE']: is_router = True if is_router: @@ -108,6 +109,7 @@ class NetworkHealthAnalyzer: routers.append({ 'id': node_id, 'name': get_node_name(node, node_id), + 'role': 'ROUTER' if role in [3, 'ROUTER'] else ('ROUTER_LATE' if role in [9, 'ROUTER_LATE'] else 'ROUTER_CLIENT'), 'lat': lat, 'lon': lon, 'metrics': get_val(node, 'deviceMetrics', {}) @@ -115,54 +117,78 @@ class NetworkHealthAnalyzer: # 2. Analyze Each Router for r in routers: - # A. Check Density (Redundancy) + # A. Neighbors (2km) nearby_routers = 0 - for other in routers: - if r['id'] == other['id']: continue - dist = haversine(r['lat'], r['lon'], other['lat'], other['lon']) - if dist < 2000: # 2km radius - nearby_routers += 1 + total_neighbors = 0 - if nearby_routers >= 2: - issues.append(f"Efficiency: Router '{r['name']}' is Redundant. Has {nearby_routers} other routers within 2km. Consolidate?") + for node_id, node in nodes.items(): + if node_id == r['id']: continue + pos = get_val(node, 'position', {}) + lat = get_val(pos, 'latitude') + lon = get_val(pos, 'longitude') + + if lat and lon: + dist = haversine(r['lat'], r['lon'], lat, lon) + if dist < 2000: + total_neighbors += 1 + # Check if it's also a router + # (Simplified check, ideally we'd check against the routers list but this is O(N)) + user = get_val(node, 'user', {}) + role = get_val(user, 'role') + if role in [2, 3, 'ROUTER', 'ROUTER_CLIENT']: + nearby_routers += 1 - # B. Check Congestion - ch_util = get_val(r['metrics'], 'channelUtilization', 0) - if ch_util > 20: - issues.append(f"Efficiency: Router '{r['name']}' is Congested (ChUtil {ch_util:.1f}% > 20%).") - - # C. Check Relay Efficiency (if we have test results) + # B. Relay Count + relay_count = 0 if test_results: - # Count how many times this router was used as a relay - relay_count = 0 for res in test_results: route = res.get('route', []) - # route is list of IDs (int or hex string? usually int in packet, but we need to match) - # Let's normalize to check - r_id_num = r['id'].replace('!', '') - try: - r_id_int = int(r_id_num, 16) - except: - r_id_int = 0 - - if r_id_int in route: + # Normalize route IDs to hex strings for comparison + route_hex = [f"!{n:08x}" if isinstance(n, int) else n for n in route] + + if r['id'] in route_hex: relay_count += 1 - - # Check for "Ineffective" (High Density of Neighbors but Low Relay Count) - # Count ALL neighbors (clients + routers) - total_neighbors = 0 - for node_id, node in nodes.items(): - if node_id == r['id']: continue - pos = get_val(node, 'position', {}) - lat = get_val(pos, 'latitude') - lon = get_val(pos, 'longitude') - if lat and lon: - dist = haversine(r['lat'], r['lon'], lat, lon) - if dist < 2000: - total_neighbors += 1 - - if total_neighbors > 5 and relay_count == 0: - issues.append(f"Efficiency: Router '{r['name']}' is Ineffective. Has {total_neighbors} neighbors but relayed 0 packets in tests.") + + # C. Channel Util + ch_util = get_val(r['metrics'], 'channelUtilization', 0) + + # D. Status / Issues + status_issues = [] + if nearby_routers >= 2: + status_issues.append("Redundant") + if ch_util > 20: + status_issues.append("Congested") + if total_neighbors > 5 and relay_count == 0: + status_issues.append("Ineffective") + + stats.append({ + 'id': r['id'], + 'name': r['name'], + 'role': r['role'], + 'neighbors_2km': total_neighbors, + 'routers_2km': nearby_routers, + 'ch_util': ch_util, + 'relay_count': relay_count, + 'status': ", ".join(status_issues) if status_issues else "OK" + }) + + return stats + + def check_router_efficiency(self, nodes, test_results=None): + """ + Analyzes router placement and efficiency. + Returns a list of issue strings. + """ + issues = [] + stats = self.get_router_stats(nodes, test_results) + + for s in stats: + if "Redundant" in s['status']: + issues.append(f"Efficiency: Router '{s['name']}' is Redundant. Has {s['routers_2km']} other routers within 2km. Consolidate?") + if "Congested" in s['status']: + issues.append(f"Efficiency: Router '{s['name']}' is Congested (ChUtil {s['ch_util']:.1f}% > 20%).") + if "Ineffective" in s['status']: + issues.append(f"Efficiency: Router '{s['name']}' is Ineffective. Has {s['neighbors_2km']} neighbors but relayed 0 packets in tests.") return issues diff --git a/mesh_monitor/monitor.py b/mesh_monitor/monitor.py index 23e052a..ed14155 100644 --- a/mesh_monitor/monitor.py +++ b/mesh_monitor/monitor.py @@ -286,6 +286,27 @@ class MeshMonitor: # logger.debug(f"Node info updated: {node}") pass + def apply_manual_positions(self, nodes): + """ + Applies manual positions from config to nodes. + """ + manual_positions = self.config.get('manual_positions', {}) + if not manual_positions: + return + + for node_id, pos in manual_positions.items(): + if node_id in nodes: + node = nodes[node_id] + # Ensure position dict exists + if 'position' not in node: + node['position'] = {} + + # Update position + if 'lat' in pos and 'lon' in pos: + node['position']['latitude'] = pos['lat'] + node['position']['longitude'] = pos['lon'] + logger.debug(f"Applied manual position to {node_id}: {pos}") + def main_loop(self): logger.info("Starting monitoring loop...") while self.running: @@ -299,6 +320,9 @@ class MeshMonitor: logger.debug("--- Running Network Analysis ---") nodes = self.interface.nodes + # Apply Manual Positions + self.apply_manual_positions(nodes) + # Get local node info for distance calculations my_node = None if hasattr(self.interface, 'localNode'): @@ -310,6 +334,7 @@ class MeshMonitor: # Run Router Efficiency Analysis (using accumulated test results if available) if self.active_tester: issues.extend(self.analyzer.check_router_efficiency(nodes, test_results=self.active_tester.test_results)) + issues.extend(self.analyzer.check_route_quality(nodes, test_results=self.active_tester.test_results)) else: issues.extend(self.analyzer.check_router_efficiency(nodes)) @@ -334,7 +359,10 @@ class MeshMonitor: if hasattr(self.interface, 'localNode'): local_node = self.interface.localNode - self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [], local_node=local_node) + # Calculate Router Stats for Report + router_stats = self.analyzer.get_router_stats(nodes, self.active_tester.test_results) + + self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [], local_node=local_node, router_stats=router_stats) # Reset cycle count and results self.active_tester.completed_cycles = 0 @@ -354,6 +382,19 @@ class MeshMonitor: # ... exceptions ... except KeyboardInterrupt: logger.info("Stopping monitor...") + # Generate partial report if we have nodes (even if no test results yet) + if nodes: + logger.info("Generating partial report before exit...") + local_node = None + if hasattr(self.interface, 'localNode'): + local_node = self.interface.localNode + + # Use whatever results we have (could be empty) + results = self.active_tester.test_results if self.active_tester else [] + + router_stats = self.analyzer.get_router_stats(nodes, results) + self.reporter.generate_report(nodes, results, issues if 'issues' in locals() else [], local_node=local_node, router_stats=router_stats) + self.stop() break except Exception as e: diff --git a/mesh_monitor/reporter.py b/mesh_monitor/reporter.py index f52158c..400fca3 100644 --- a/mesh_monitor/reporter.py +++ b/mesh_monitor/reporter.py @@ -12,7 +12,7 @@ class NetworkReporter: def __init__(self, report_dir="."): self.report_dir = report_dir - def generate_report(self, nodes, test_results, analysis_issues, local_node=None): + def generate_report(self, nodes, test_results, analysis_issues, local_node=None, router_stats=None): """ Generates a Markdown report based on collected data. """ @@ -37,6 +37,10 @@ class NetworkReporter: # 2. Network Health (Analysis Findings) self._write_network_health(f, analysis_issues) + + # 2.1 Router Performance Table (New) + if router_stats: + self._write_router_performance_table(f, router_stats) # 3. Route Analysis (New Section) self._write_route_analysis(f, route_analysis) @@ -137,6 +141,8 @@ class NetworkReporter: config.append(issue) elif "Topology" in issue or "Density" in issue or "hops away" in issue: topology.append(issue) + elif "Efficiency" in issue or "Route Quality" in issue: + pass # Handled in separate sections else: other.append(issue) @@ -166,12 +172,35 @@ class NetworkReporter: f.write("### Router Efficiency Analysis\n") f.write("Analysis of router placement, congestion, and relay performance.\n\n") for i in efficiency: - # Format: Efficiency: Router 'Name' is Issue. Details. - # Let's make it a bit cleaner clean_msg = i.replace("Efficiency: ", "") f.write(f"- {clean_msg}\n") f.write("\n") + # Separate section for Route Quality + quality = [i for i in analysis_issues if "Route Quality" in i] + if quality: + f.write("### Route Quality Analysis\n") + f.write("Analysis of path efficiency and stability.\n\n") + for i in quality: + clean_msg = i.replace("Route Quality: ", "") + f.write(f"- {clean_msg}\n") + f.write("\n") + + def _write_router_performance_table(self, f, router_stats): + f.write("### Router Performance Table\n") + if not router_stats: + f.write("No routers found.\n\n") + return + + f.write("| Name | Role | Neighbors (2km) | Routers (2km) | ChUtil | Relayed | Status |\n") + f.write("|---|---|---|---|---|---|---|\n") + + for s in router_stats: + f.write(f"| {s['name']} | {s['role']} | {s['neighbors_2km']} | {s['routers_2km']} | {s['ch_util']:.1f}% | {s['relay_count']} | {s['status']} |\n") + f.write("\n") + + + def _write_traceroute_results(self, f, test_results, nodes, local_node=None): f.write("## 3. Traceroute Results\n") if not test_results: diff --git a/tests/test_route_quality.py b/tests/test_route_quality.py new file mode 100644 index 0000000..b97efc9 --- /dev/null +++ b/tests/test_route_quality.py @@ -0,0 +1,47 @@ +import unittest +from mesh_monitor.analyzer import NetworkHealthAnalyzer + +class TestRouteQuality(unittest.TestCase): + def setUp(self): + self.analyzer = NetworkHealthAnalyzer() + + def test_long_path(self): + nodes = { + '!11111111': {'user': {'longName': 'FarNode'}} + } + test_results = [ + {'node_id': '!11111111', 'hops_to': 4, 'route': []} + ] + + issues = self.analyzer.check_route_quality(nodes, test_results) + print("\nLong Path Issues:", issues) + self.assertTrue(any("Long path" in i for i in issues)) + + def test_favorite_router_usage(self): + nodes = { + '!22222222': {'user': {'longName': 'TargetNode'}}, + '!33333333': {'user': {'longName': 'FavRouter'}, 'is_favorite': True} + } + # Route uses FavRouter (ID !33333333 -> 0x33333333 = 858993459) + test_results = [ + {'node_id': '!22222222', 'hops_to': 2, 'route': [858993459]} + ] + + issues = self.analyzer.check_route_quality(nodes, test_results) + print("\nFavorite Router Issues:", issues) + self.assertTrue(any("Favorite Router" in i for i in issues)) + + def test_weak_signal(self): + nodes = { + '!44444444': {'user': {'longName': 'WeakNode'}} + } + test_results = [ + {'node_id': '!44444444', 'snr': -15} + ] + + issues = self.analyzer.check_route_quality(nodes, test_results) + print("\nWeak Signal Issues:", issues) + self.assertTrue(any("Weak signal" in i for i in issues)) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_router_efficiency.py b/tests/test_router_efficiency.py index 7b5bcdd..46e353f 100644 --- a/tests/test_router_efficiency.py +++ b/tests/test_router_efficiency.py @@ -99,5 +99,40 @@ class TestRouterEfficiency(unittest.TestCase): print("\nEffective Issues (Should be empty):", issues) self.assertFalse(any("GoodRouter" in i for i in issues)) + def test_get_router_stats(self): + nodes = { + '!77777777': { + 'user': {'id': '!77777777', 'longName': 'StatsRouter', 'role': 'ROUTER'}, + 'position': {'latitude': 44.0, 'longitude': -78.0}, + 'deviceMetrics': {'channelUtilization': 25} + } + } + # Add 3 router neighbors + for i in range(3): + nodes[f'!r_neighbor{i}'] = { + 'user': {'id': f'!r_neighbor{i}', 'role': 'ROUTER'}, + 'position': {'latitude': 44.001, 'longitude': -78.001} + } + + """Test that get_router_stats returns correct structure and calculations.""" + # Create a mock route where StatsRouter (!77777777) is used as a relay + # 0x77777777 = 2004318071 + test_results = [ + {'route': [2004318071], 'status': 'success'} + ] + + stats = self.analyzer.get_router_stats(nodes, test_results) + print("\nRouter Stats:", stats) + + self.assertEqual(len(stats), 4) # StatsRouter + 3 neighbors + + target = next(s for s in stats if s['id'] == '!77777777') + self.assertEqual(target['neighbors_2km'], 3) + self.assertEqual(target['routers_2km'], 3) + self.assertEqual(target['ch_util'], 25.0) + self.assertEqual(target['relay_count'], 1) # Should be 1 now + self.assertIn('Redundant', target['status']) + self.assertIn('Congested', target['status']) + if __name__ == '__main__': unittest.main()