From 2e1f06e6ff730c485c0a26f1f9c59a50b227502e Mon Sep 17 00:00:00 2001 From: eddieoz Date: Fri, 28 Nov 2025 18:41:39 +0200 Subject: [PATCH] Enhance report recommendations and persistence - Improvements to Ineffective Router detection: - Treat undefined roles as CLIENTs to catch more cases - Mark all ineffective routers as CRITICAL priority - Detect mesh-clogging routers (high ChUtil + low relays) - Report Persistence: - Persist Test Location and Timestamp in JSON - Use original metadata when regenerating reports - Apply manual positions from config during regeneration - Safety: - Prevent overwriting JSON file during report regeneration - Fixes: - Fixed IndentationError in monitor.py - Fixed local_node dictionary handling in reporter.py --- mesh_monitor/analyzer.py | 236 +++++++++++++++++++++------ mesh_monitor/monitor.py | 5 +- mesh_monitor/reporter.py | 333 ++++++++++++++++++++++++++------------- report_generate.py | 53 ++++++- 4 files changed, 462 insertions(+), 165 deletions(-) diff --git a/mesh_monitor/analyzer.py b/mesh_monitor/analyzer.py index 8743ce3..aed482b 100644 --- a/mesh_monitor/analyzer.py +++ b/mesh_monitor/analyzer.py @@ -16,6 +16,10 @@ class NetworkHealthAnalyzer: 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) + + # Data storage for detailed analysis + self.cluster_data = [] # Router cluster details with distances + self.ch_util_data = {} # Channel utilization analysis def analyze(self, nodes, packet_history=None, my_node=None, test_results=None): """ @@ -80,13 +84,19 @@ class NetworkHealthAnalyzer: issues.extend(self.check_hop_counts(packet_history, nodes)) # --- Geospatial Analysis --- - issues.extend(self.check_router_density(nodes, test_results)) + density_issues, self.cluster_data = self.check_router_density(nodes, test_results) + issues.extend(density_issues) issues.extend(self.check_network_size_and_preset(nodes)) if my_node: issues.extend(self.check_signal_vs_distance(nodes, my_node)) + + # --- Advanced Analysis --- + self.analyze_channel_utilization(nodes) # Stores data in self.ch_util_data + issues.extend(self.check_client_relaying_over_router(nodes, test_results)) return issues + def get_router_stats(self, nodes, test_results=None): """ Calculates detailed statistics for each router. @@ -210,6 +220,156 @@ class NetworkHealthAnalyzer: return issues + def analyze_channel_utilization(self, nodes): + """ + Analyzes channel utilization across the network. + Determines if congestion is mesh-wide or isolated to specific nodes. + Returns detailed data for reporting. + """ + high_util_nodes = [] + active_node_count = 0 + current_time = time.time() + + for node_id, node in nodes.items(): + # Check if node is active + last_heard = get_val(node, 'lastHeard', 0) + if current_time - last_heard < self.active_threshold_seconds: + active_node_count += 1 + else: + continue # Skip inactive nodes + + # Check channel utilization + metrics = get_val(node, 'deviceMetrics', {}) + ch_util = get_val(metrics, 'channelUtilization', 0) + + if ch_util > self.ch_util_threshold: + node_name = get_node_name(node, node_id) + high_util_nodes.append({ + 'id': node_id, + 'name': node_name, + 'util_pct': ch_util + }) + + # Determine if widespread or isolated + if not high_util_nodes: + self.ch_util_data = {'type': 'none', 'nodes': []} + return + + # If >30% of active nodes have high util, it's mesh-wide + is_widespread = len(high_util_nodes) / active_node_count > 0.30 if active_node_count > 0 else False + + self.ch_util_data = { + 'type': 'widespread' if is_widespread else 'isolated', + 'nodes': high_util_nodes, + 'active_count': active_node_count, + 'affected_count': len(high_util_nodes) + } + + def check_client_relaying_over_router(self, nodes, test_results): + """ + Detects ineffective routers by checking if nearby CLIENT nodes + are relaying more frequently than the router itself. + Uses router_density_threshold as the radius to check. + """ + issues = [] + + if not test_results: + return issues + + from mesh_monitor.route_analyzer import RouteAnalyzer + route_analyzer = RouteAnalyzer(nodes) + relay_usage = route_analyzer._analyze_relay_usage( + [r for r in test_results if r.get('status') == 'success'] + ) + + # Build relay count lookup + relay_counts = {item['id']: item['count'] for item in relay_usage} + + # Find all routers + routers = [] + 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') + metrics = get_val(node, 'deviceMetrics', {}) + ch_util = get_val(metrics, 'channelUtilization', 0) + + if 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, + 'relay_count': relay_counts.get(node_id, 0), + 'ch_util': ch_util + }) + + # For each router, check nearby clients + for router in routers: + router_relays = router['relay_count'] + router_ch_util = router['ch_util'] + nearby_clients = [] + + for node_id, node in nodes.items(): + if node_id == router['id']: + continue + + user = get_val(node, 'user', {}) + role = get_val(user, 'role') + + # Check if it's a client + is_client = False + if role is None: + is_client = True # Assume client if role is unknown + elif isinstance(role, int): + if role in [0, 1, 8]: # CLIENT, CLIENT_MUTE, etc + is_client = True + elif role in ['CLIENT', 'CLIENT_MUTE', 'TRACKER', 'SENSOR']: + is_client = True + + if not is_client: + continue + + # Check distance + pos = get_val(node, 'position', {}) + lat = get_val(pos, 'latitude') + lon = get_val(pos, 'longitude') + + if lat is not None and lon is not None: + dist = haversine(router['lat'], router['lon'], lat, lon) + + if dist <= self.router_density_threshold: + client_relays = relay_counts.get(node_id, 0) + if client_relays >= router_relays * 2 and client_relays > 0: + nearby_clients.append({ + 'name': get_node_name(node, node_id), + 'relay_count': client_relays, + 'distance_km': dist / 1000 + }) + + # Report if clients are relaying more than router + if nearby_clients: + for client in nearby_clients: + msg = f"Efficiency: Router '{router['name']}' has {router_relays} relays, " + msg += f"but nearby client '{client['name']}' ({client['distance_km']:.2f}km away) has {client['relay_count']} relays. " + msg += f"Router ChUtil: {router_ch_util:.1f}%. " + msg += f"Router may be ineffective - check antenna, placement, or configuration." + issues.append(msg) + + return issues + + def check_route_quality(self, nodes, test_results): """ Analyzes the quality of routes found in traceroute tests. @@ -287,48 +447,7 @@ class NetworkHealthAnalyzer: issues.append(f"Topology: Node '{node_name}' is {hops_away} hops away. (Ideally <= 3)") return list(set(issues)) - def check_router_density(self, nodes): - """ - Checks if ROUTER nodes are too close to each other (< 500m). - """ - issues = [] - routers = [] - - # 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]: # ROUTER, ROUTER_CLIENT, REPEATER - is_router = True - elif role in ['ROUTER', 'REPEATER', 'ROUTER_CLIENT']: - 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 - }) - - # Compare every pair - 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 > 0 and dist < 500: # 500 meters threshold - issues.append(f"Topology: High Density! Routers '{r1['name']}' and '{r2['name']}' are only {dist:.0f}m apart. Consider changing one to CLIENT.") - - return issues + def check_network_size_and_preset(self, nodes): """ @@ -361,8 +480,10 @@ class NetworkHealthAnalyzer: Checks for high density of routers. Identifies clusters of routers within 'router_density_threshold'. Recommends keeping the most effective router (highest relay count) and demoting others. + Returns: (issues, cluster_data) """ issues = [] + cluster_data = [] # New: detailed cluster information # 1. Get Router Stats (includes relay counts) stats = self.get_router_stats(nodes, test_results) @@ -376,7 +497,7 @@ class NetworkHealthAnalyzer: routers.append(s) if not routers: - return issues + return issues, cluster_data # 2. Build Clusters # Adjacency list: index -> list of neighbor indices @@ -422,14 +543,37 @@ class NetworkHealthAnalyzer: other_names = [o['name'] for o in others] - # Construct message + # Calculate distances between all router pairs in this cluster + distances = [] + for i in range(len(cluster)): + for j in range(i + 1, len(cluster)): + r1 = cluster[i] + r2 = cluster[j] + dist_m = haversine(r1['lat'], r1['lon'], r2['lat'], r2['lon']) + distances.append({ + 'router1': r1['name'], + 'router2': r2['name'], + 'distance_m': dist_m + }) + + # Store cluster data + cluster_data.append({ + 'size': len(cluster), + 'best_router': best_router['name'], + 'best_router_relays': best_router['relay_count'], + 'other_routers': other_names, + 'distances': distances + }) + + # Construct message (kept for backward compatibility) 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 + return issues, cluster_data + def check_signal_vs_distance(self, nodes, my_node): """ diff --git a/mesh_monitor/monitor.py b/mesh_monitor/monitor.py index 4e2a8e6..7c9b031 100644 --- a/mesh_monitor/monitor.py +++ b/mesh_monitor/monitor.py @@ -355,6 +355,7 @@ class MeshMonitor: # logger.warning(f"Found {len(issues)} potential issues:") # for issue in issues: # logger.warning(f" - {issue}") + pass else: logger.debug("No critical issues found in current scan.") @@ -374,7 +375,7 @@ class MeshMonitor: # 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) + self.reporter.generate_report(nodes, self.active_tester.test_results, issues if 'issues' in locals() else [], local_node=local_node, router_stats=router_stats, analyzer=self.analyzer) # Reset cycle count and results self.active_tester.completed_cycles = 0 @@ -405,7 +406,7 @@ class MeshMonitor: 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.reporter.generate_report(nodes, results, issues if 'issues' in locals() else [], local_node=local_node, router_stats=router_stats, analyzer=self.analyzer) self.stop() break diff --git a/mesh_monitor/reporter.py b/mesh_monitor/reporter.py index 9374944..adcdbc1 100644 --- a/mesh_monitor/reporter.py +++ b/mesh_monitor/reporter.py @@ -17,12 +17,22 @@ class NetworkReporter: # Ensure report directory exists os.makedirs(self.report_dir, exist_ok=True) - def generate_report(self, nodes, test_results, analysis_issues, local_node=None, router_stats=None): + def generate_report(self, nodes, test_results, analysis_issues, local_node=None, router_stats=None, analyzer=None, override_timestamp=None, override_location=None, save_json=True): """ Generates a Markdown report based on collected data. Also persists all raw data to JSON format. + analyzer: NetworkHealthAnalyzer instance with cluster_data and ch_util_data + override_timestamp: Optional timestamp string to use (for regeneration) + override_location: Optional location string to use (for regeneration) + save_json: Whether to save the raw data to JSON (default: True) """ - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + if override_timestamp: + timestamp = override_timestamp + report_date = datetime.strptime(timestamp, "%Y%m%d-%H%M%S").strftime('%Y-%m-%d %H:%M:%S') + else: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + report_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + filename = f"report-{timestamp}.md" json_filename = f"report-{timestamp}.json" filepath = os.path.join(self.report_dir, filename) @@ -39,13 +49,19 @@ class NetworkReporter: 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") + f.write(f"**Date:** {report_date}\n\n") + + # Calculate location if not overridden + if override_location: + test_location = override_location + else: + test_location = self._get_location_string(nodes, local_node) # 1. Executive Summary - self._write_executive_summary(f, nodes, test_results, analysis_issues, local_node) + self._write_executive_summary(f, nodes, test_results, analysis_issues, test_location) # 2. Network Health (Analysis Findings) - self._write_network_health(f, analysis_issues) + self._write_network_health(f, analysis_issues, analyzer) # 2.1 Router Performance Table (New) if router_stats: @@ -58,25 +74,27 @@ class NetworkReporter: self._write_traceroute_results(f, test_results, nodes, local_node) # 5. Recommendations - self._write_recommendations(f, analysis_issues, test_results) + self._write_recommendations(f, analysis_issues, test_results, analyzer) logger.info(f"Report generated successfully: {filepath}") # --- Persist Raw Data to JSON --- - try: - self._save_json_data( - json_filepath, - timestamp, - nodes, - test_results, - analysis_issues, - local_node, - router_stats, - route_analysis - ) - logger.info(f"Raw data saved to: {json_filepath}") - except Exception as json_e: - logger.error(f"Failed to save JSON data: {json_e}") + if save_json: + try: + self._save_json_data( + json_filepath, + timestamp, + nodes, + test_results, + analysis_issues, + local_node, + router_stats, + route_analysis, + test_location + ) + logger.info(f"Raw data saved to: {json_filepath}") + except Exception as json_e: + logger.error(f"Failed to save JSON data: {json_e}") return filepath except Exception as e: @@ -123,7 +141,7 @@ class NetworkReporter: visited.discard(obj_id) def _save_json_data(self, filepath, timestamp, nodes, test_results, analysis_issues, - local_node, router_stats, route_analysis): + local_node, router_stats, route_analysis, test_location): """ Saves all raw data to JSON file with session metadata. """ @@ -142,6 +160,7 @@ class NetworkReporter: "session": { "timestamp": timestamp, "generated_at": datetime.now().isoformat(), + "test_location": test_location, "config": self._serialize_object(self.config) }, "data": { @@ -158,7 +177,59 @@ class NetworkReporter: with open(filepath, 'w') as f: json.dump(data, f, indent=2, default=str) - def _write_executive_summary(self, f, nodes, test_results, analysis_issues, local_node=None): + def _get_location_string(self, nodes, local_node): + """ + Determines the location string for the report. + """ + local_pos_str = "Unknown" + if local_node: + # Try to get ID to look up in nodes dict (which has the most up-to-date position including manual overrides) + local_id = None + if hasattr(local_node, 'nodeNum'): + local_id = f"!{local_node.nodeNum:08x}" + elif isinstance(local_node, dict): + # Try to find ID in dict + if 'nodeNum' in local_node: + try: + local_id = f"!{int(local_node['nodeNum']):08x}" + except: + pass + + if not local_id: + user = local_node.get('user', {}) + if 'id' in user: + local_id = user['id'] + + if local_id and local_id in nodes: + pos = get_val(nodes[local_id], 'position', {}) + lat = get_val(pos, 'latitude') + lon = get_val(pos, 'longitude') + if lat is not None and lon is not None: + local_pos_str = f"{lat:.4f}, {lon:.4f}" + + # Fallback to local_node object if not found in dict or no ID + if local_pos_str == "Unknown": + if isinstance(local_node, dict): + pos = local_node.get('position', {}) + lat = pos.get('latitude') + lon = pos.get('longitude') + if lat is not None and lon is not None: + local_pos_str = f"{lat:.4f}, {lon:.4f}" + elif hasattr(local_node, 'position'): + # Check if it's a dict or object + pos = local_node.position + if isinstance(pos, dict): + lat = pos.get('latitude') + lon = pos.get('longitude') + else: + lat = getattr(pos, 'latitude', None) + lon = getattr(pos, 'longitude', None) + + if lat is not None and lon is not None: + local_pos_str = f"{lat:.4f}, {lon:.4f}" + return local_pos_str + + def _write_executive_summary(self, f, nodes, test_results, analysis_issues, test_location="Unknown"): f.write("## 1. Executive Summary\n") total_nodes = len(nodes) @@ -171,42 +242,7 @@ class NetworkReporter: # Get unique nodes from test results (selected online nodes) unique_tested_nodes = len(set([r.get('node_id') for r in test_results])) - # Get Local Position - local_pos_str = "Unknown" - if local_node: - # Try to get ID to look up in nodes dict (which has the most up-to-date position including manual overrides) - local_id = None - if hasattr(local_node, 'nodeNum'): - local_id = f"!{local_node.nodeNum:08x}" - elif isinstance(local_node, dict): - # Try to find ID in dict - user = local_node.get('user', {}) - if 'id' in user: - local_id = user['id'] - - if local_id and local_id in nodes: - pos = get_val(nodes[local_id], 'position', {}) - lat = get_val(pos, 'latitude') - lon = get_val(pos, 'longitude') - if lat is not None and lon is not None: - local_pos_str = f"{lat:.4f}, {lon:.4f}" - - # Fallback to local_node object if not found in dict or no ID - if local_pos_str == "Unknown": - if hasattr(local_node, 'position'): - # Check if it's a dict or object - pos = local_node.position - if isinstance(pos, dict): - lat = pos.get('latitude') - lon = pos.get('longitude') - else: - lat = getattr(pos, 'latitude', None) - lon = getattr(pos, 'longitude', None) - - if lat is not None and lon is not None: - local_pos_str = f"{lat:.4f}, {lon:.4f}" - - f.write(f"- **Test Location:** {local_pos_str}\n") + f.write(f"- **Test Location:** {test_location}\n") f.write(f"- **Total Nodes Visible:** {total_nodes}\n") f.write(f"- **Selected Online Nodes:** {unique_tested_nodes}\n") f.write(f"- **Total Tests Performed:** {total_tests}\n") @@ -259,7 +295,7 @@ class NetworkReporter: else: f.write("No path data available.\n\n") - def _write_network_health(self, f, analysis_issues): + def _write_network_health(self, f, analysis_issues, analyzer=None): f.write("## 2. Network Health Analysis\n") if not analysis_issues: f.write("No significant network issues detected.\n\n") @@ -317,6 +353,18 @@ class NetworkReporter: if topology: f.write("### Topology & Placement\n") for i in topology: f.write(f"- {i}\n") + + # Add detailed cluster distance information + if analyzer and hasattr(analyzer, 'cluster_data') and analyzer.cluster_data: + f.write("\n**Router Cluster Details:**\n\n") + for cluster in analyzer.cluster_data: + f.write(f"**Cluster of {cluster['size']} routers:**\n") + f.write(f" - Best positioned: {cluster['best_router']} ({cluster['best_router_relays']} relays)\n") + f.write(f" - Distances:\n") + for dist_info in cluster['distances']: + f.write(f" - {dist_info['router1']} ↔ {dist_info['router2']}: {dist_info['distance_m']/1000:.2f}km\n") + f.write("\n") + f.write("\n") if other: @@ -451,74 +499,139 @@ class NetworkReporter: f.write(f"| {node_id} | {name} | {status_icon} {status} | {distance} | {rtt} | {hops} | {snr} |\n") f.write("\n") - def _write_recommendations(self, f, analysis_issues, test_results): + def _write_recommendations(self, f, analysis_issues, test_results, analyzer=None): f.write("## 4. Recommendations\n") - recs = [] + recs = [] # Format: (priority, emoji, text) - # 1. Topology & Placement (Consolidated from Analysis) - # Extract "Best positioned..." recommendations - topology_recs = [] + # === CRITICAL PRIORITY === + + # 1. Router Clusters <500m for issue in analysis_issues: if "Topology: High Router Density!" in issue: # Extract the recommendation part - if "Best positioned seems to be" in issue: - # Find where the recommendation starts - start_idx = issue.find("Best positioned seems to be") - if start_idx != -1: - topology_recs.append(f"- **Optimize Cluster:** {issue[start_idx:]}") + if analyzer and hasattr(analyzer, 'cluster_data') and analyzer.cluster_data: + # Get threshold from config (default to 2000m if not available) + threshold = self.config.get('thresholds', {}).get('router_density_threshold', 2000) + + for cluster in analyzer.cluster_data: + # Check if any distance is less than threshold (CRITICAL) + min_distance = min((d['distance_m'] for d in cluster['distances']), default=threshold) + has_close_routers = min_distance < threshold + + if has_close_routers: + rec = f"**Router Cluster:** {cluster['size']} routers within {threshold/1000:.1f}km threshold (closest: {min_distance/1000:.2f}km). " + rec += f"Best positioned: '{cluster['best_router']}' ({cluster['best_router_relays']} relays). " + rec += f"Consider changing others ({', '.join(cluster['other_routers'])}) to CLIENT role." + recs.append((1, "🔴", rec)) + else: + rec = f"**Router Cluster:** {cluster['size']} routers detected. " + rec += f"Best positioned: '{cluster['best_router']}' ({cluster['best_router_relays']} relays). " + rec += f"Review if all routers are needed: {', '.join(cluster['other_routers'])}." + recs.append((2, "🟡", rec)) else: - # Fallback if format is different - topology_recs.append(f"- **Optimize Placement:** {issue}") - elif "Topology: Node" in issue and "hops away" in issue: - # "Topology: Node 'X' is 4 hops away." - topology_recs.append(f"- **Improve Coverage:** {issue.replace('Topology: ', '')}") - - if topology_recs: - recs.extend(topology_recs) - elif any("High Density" in i for i in analysis_issues): - # Fallback for generic density issue if not caught above - recs.append("- **Optimize Placement:** Routers are too close together. Convert redundant routers to clients.") - - # 2. Efficiency (Router Performance) - if any("Ineffective" in i for i in analysis_issues): - recs.append("- **Review Ineffective Routers:** Some routers have neighbors but aren't relaying packets. Consider repositioning them or checking their antenna/LOS.") + # Fallback if no analyzer data + rec = "**Optimize Placement:** Routers are too close together. Convert redundant routers to clients." + recs.append((1, "🔴", rec)) + # 2. Channel Utilization (Mesh-Wide or Isolated) + if analyzer and hasattr(analyzer, 'ch_util_data') and analyzer.ch_util_data['type'] != 'none': + ch_data = analyzer.ch_util_data + if ch_data['type'] == 'widespread': + # CRITICAL: Mesh-wide congestion + rec = f"**Mesh-Wide Congestion:** {ch_data['affected_count']} out of {ch_data['active_count']} active nodes have high channel utilization (>{self.config.get('thresholds', {}).get('channel_utilization', 25)}%). " + rec += "Consider switching to a faster Meshtastic preset (e.g., LONG_FAST → MEDIUM_FAST or SHORT_FAST). " + rec += "Note: Faster presets increase throughput but reduce range. Choose based on your deployment area." + recs.append((1, "🔴", rec)) + else: + # WARNING: Isolated congestion + rec = "**High Channel Utilization** on specific nodes:\n" + for node in ch_data['nodes'][:5]: # Top 5 + rec += f"\n - {node['name']}: {node['util_pct']:.1f}%" + rec += "\n\nCheck these nodes for message spamming or reduce their broadcast frequency." + recs.append((2, "🟡", rec)) + elif any("Congestion" in i or "Congested" in i for i in analysis_issues): + # Fallback if no analyzer data + recs.append((2, "🟡", "**Reduce Traffic:** High channel utilization detected. Identify spamming nodes, reduce broadcast frequency, or optimize network preset.")) + + # 3. Ineffective Routers (clients relaying more than routers) + ineffective_issues = [i for i in analysis_issues if "Router may be ineffective" in i] + if ineffective_issues: + for issue in ineffective_issues: + # Parse the issue to extract router name and ChUtil + import re + router_match = re.search(r"Router '([^']+)' has (\d+) relays", issue) + ch_util_match = re.search(r"Router ChUtil: ([\d.]+)%", issue) + client_match = re.search(r"client '([^']+)' \([^)]+\) has (\d+) relays", issue) + + if router_match and ch_util_match and client_match: + router_name = router_match.group(1) + router_relays = int(router_match.group(2)) + router_ch_util = float(ch_util_match.group(1)) + client_name = client_match.group(1) + client_relays = int(client_match.group(2)) + + # Detect mesh-clogging scenario: low relays + high ChUtil + ch_util_threshold = self.config.get('thresholds', {}).get('channel_utilization', 25.0) + is_mesh_clogger = (router_relays < client_relays / 2) and (router_ch_util > ch_util_threshold) + + if is_mesh_clogger: + # CRITICAL: Router is clogging the mesh + rec = f"**Mesh-Clogger Router:** '{router_name}' has high channel utilization ({router_ch_util:.1f}%) but low relay activity ({router_relays} relays), " + rec += f"while nearby client '{client_name}' is doing more work ({client_relays} relays). " + rec += f"This router is likely clogging the mesh. **Strongly recommend changing '{router_name}' to CLIENT role**." + recs.append((1, "🔴", rec)) + else: + # WARNING: Ineffective but not clogging -> Now CRITICAL as per user request + rec = f"**Ineffective Router:** '{router_name}' has {router_relays} relays (ChUtil: {router_ch_util:.1f}%), " + rec += f"but nearby client '{client_name}' has {client_relays} relays. " + rec += f"Consider changing '{router_name}' to CLIENT role or check its antenna/placement." + recs.append((1, "🔴", rec)) + else: + # Fallback if parsing fails + rec = issue.replace("Efficiency: ", "").replace("Router may be ineffective - check antenna, placement, or configuration.", "Consider changing the router to CLIENT role or check its antenna/placement/configuration.") + recs.append((1, "🔴", rec)) + + # === WARNING PRIORITY === + + # 4. Long Paths + if any("Long path" in i for i in analysis_issues): + recs.append((2, "🟡", "**Optimize Paths:** Long paths (>3 hops) detected. Consider adding a strategically placed relay to shorten the path.")) + + # 5. Redundant Routers (not close <500m but still redundant) if any("Redundant" in i for i in analysis_issues): - # This might overlap with Topology, but good to have specific advice - recs.append("- **Reduce Redundancy:** Routers marked as 'Redundant' have too many other routers nearby. Change their role to CLIENT to save airtime.") - - # 3. Congestion - if any("Congestion" in i or "Congested" in i for i in analysis_issues): - recs.append("- **Reduce Traffic:** High channel utilization detected. Identify spamming nodes, reduce broadcast frequency, or increase channel speed (if possible).") - - # 4. Configuration + recs.append((2, "🟡", "**Reduce Redundancy:** Some routers have too many other routers nearby. Evaluate if all are necessary and consider changing some to CLIENT role to save airtime.")) + + # === INFO PRIORITY === + + # 6. Configuration 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`.") + recs.append((3, "🟢", "**Fix Roles:** Deprecated `ROUTER_CLIENT` role detected. Change these nodes to `CLIENT` or `CLIENT_MUTE`.")) if any("Network Size" in i for i in analysis_issues): - recs.append("- **Adjust Presets:** Network size exceeds recommendations for the current estimated preset. Consider switching to a faster preset (e.g. LONG_MODERATE or SHORT_FAST).") - - # 5. Route Quality / Signal + recs.append((3, "🟢", "**Adjust Presets:** Network size exceeds recommendations for LONG_FAST preset. Consider switching to a faster preset (e.g., LONG_MODERATE or SHORT_FAST) to reduce collision probability.")) + + # 7. Signal Quality if any("poor SNR" in i or "Weak signal" in i for i in analysis_issues): - recs.append("- **Check Hardware/LOS:** Nodes with poor SNR or weak signals may have antenna issues, bad placement, or obstructions.") - - if any("Long path" in i for i in analysis_issues): - recs.append("- **Optimize Paths:** Long paths (>3 hops) detected. Consider adding a strategically placed relay to shorten the path.") - + recs.append((3, "🟢", "**Check Hardware/LOS:** Nodes with poor SNR or weak signals may have antenna issues, bad placement, or obstructions.")) + if any("Favorite Router" in i for i in analysis_issues): - recs.append("- **Check Favorites:** Routes are using 'Favorite Router' nodes. Ensure this is intentional, as it forces specific paths.") - - # 6. Connectivity (Traceroute Failures) + recs.append((3, "🟢", "**Check Favorites:** Routes are using 'Favorite Router' nodes. Ensure this is intentional, as it forces specific paths.")) + + # 8. Connectivity 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.") - + recs.append((3, "🟢", f"**Investigate Connectivity:** {len(failures)} nodes failed traceroute tests. Check if they are online or if the path is broken.")) + + # Sort by priority (1=CRITICAL first) + recs.sort(key=lambda x: x[0]) + if not recs: f.write("Network looks healthy! Keep up the good work.\n") else: - # Deduplicate recommendations - unique_recs = sorted(list(set(recs))) - for r in unique_recs: - f.write(f"{r}\n") + for priority, emoji, rec_text in recs: + f.write(f"{emoji} {rec_text}\n\n") + f.write("\n") + + diff --git a/report_generate.py b/report_generate.py index 25ef034..75c3760 100755 --- a/report_generate.py +++ b/report_generate.py @@ -84,12 +84,42 @@ def generate_report_from_json(json_filepath, output_path=None): reporter = NetworkReporter(report_dir=report_dir, config=config) + # Apply manual positions from config to nodes + # This ensures that even if the JSON data lacks positions, we use the latest config + manual_positions = config.get('manual_positions', {}) + if manual_positions: + print(f"Applying {len(manual_positions)} manual positions from config...") + for node_id, pos in manual_positions.items(): + if node_id in nodes: + node = nodes[node_id] + if 'position' not in node: + node['position'] = {} + + if 'lat' in pos and 'lon' in pos: + node['position']['latitude'] = pos['lat'] + node['position']['longitude'] = pos['lon'] + + # Recreate analyzer and re-run analysis to populate cluster_data and ch_util_data + from mesh_monitor.analyzer import NetworkHealthAnalyzer + analyzer = NetworkHealthAnalyzer(config=config) + + # Re-run analysis to populate analyzer data structures AND get new issues + new_issues = analyzer.analyze(nodes, packet_history=[], my_node=local_node, test_results=test_results) + + # Run additional checks + if test_results: + new_issues.extend(analyzer.check_router_efficiency(nodes, test_results=test_results)) + new_issues.extend(analyzer.check_route_quality(nodes, test_results=test_results)) + + # Use new issues for the report + analysis_issues = new_issues + # We need to temporarily override the filename generation if custom output is specified if output_path: # Monkey-patch the generate_report to use custom filename original_generate = reporter.generate_report - def custom_generate(nodes, test_results, analysis_issues, local_node=None, router_stats=None): + def custom_generate(nodes, test_results, analysis_issues, local_node=None, router_stats=None, analyzer=None): # Temporarily change the method to use custom filename timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") custom_filename = os.path.basename(output_path) @@ -101,19 +131,19 @@ def generate_report_from_json(json_filepath, output_path=None): try: with open(filepath, "w") as f: - f.write(f"# Meshtastic Network Report\\n") - f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\\n") - f.write(f"**Regenerated from:** {os.path.basename(json_filepath)}\\n\\n") + f.write(f"# Meshtastic Network Report\n") + f.write(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write(f"**Regenerated from:** {os.path.basename(json_filepath)}\n\n") reporter._write_executive_summary(f, nodes, test_results, analysis_issues, local_node) - reporter._write_network_health(f, analysis_issues) + reporter._write_network_health(f, analysis_issues, analyzer) if router_stats: reporter._write_router_performance_table(f, router_stats) reporter._write_route_analysis(f, route_analysis_local) reporter._write_traceroute_results(f, test_results, nodes, local_node) - reporter._write_recommendations(f, analysis_issues, test_results) + reporter._write_recommendations(f, analysis_issues, test_results, analyzer) print(f"✅ Report regenerated successfully: {filepath}") return filepath @@ -123,13 +153,22 @@ def generate_report_from_json(json_filepath, output_path=None): reporter.generate_report = custom_generate + # Extract session metadata + # Use the 'session' variable already extracted from 'full_data' + original_timestamp = session.get('timestamp') + test_location = session.get('test_location') + # Generate the report result = reporter.generate_report( nodes=nodes, test_results=test_results, analysis_issues=analysis_issues, local_node=local_node, - router_stats=router_stats + router_stats=router_stats, + analyzer=analyzer, # Pass analyzer parameter + override_timestamp=original_timestamp, + override_location=test_location, + save_json=False # Do not overwrite JSON when regenerating ) return result