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
This commit is contained in:
eddieoz
2025-11-28 18:41:39 +02:00
parent 33d365b2db
commit 2e1f06e6ff
4 changed files with 462 additions and 165 deletions
+190 -46
View File
@@ -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):
"""
+3 -2
View File
@@ -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
+223 -110
View File
@@ -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")
+46 -7
View File
@@ -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