From 9ebe4c7509a325a3fca8621ef12c2b107794b601 Mon Sep 17 00:00:00 2001 From: eddieoz Date: Fri, 28 Nov 2025 10:41:13 +0200 Subject: [PATCH] feat: Add configurable network analysis thresholds, network size warnings, and enhanced router density detection. --- README.md | 12 +++- mesh_monitor/analyzer.py | 81 +++++++++++++++++++++++-- mesh_monitor/monitor.py | 3 +- mesh_monitor/reporter.py | 5 +- sample-config.yaml | 19 +++++- tests/test_analyzer_enhancements.py | 91 +++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 tests/test_analyzer_enhancements.py diff --git a/README.md b/README.md index 2699d6c..bdf2f95 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ The monitor runs a continuous loop (every 60 seconds) and performs the following * **Placement Verification**: Flags `ROUTER` or `REPEATER` nodes that do not have a valid GPS position. * **Placement Verification**: Flags `ROUTER` or `REPEATER` nodes that do not have a valid GPS position. - * **Router Density**: Flags `ROUTER` nodes that are physically too close (< 500m) to each other, indicating redundancy. + * **Router Density**: Flags `ROUTER` nodes that are physically too close (default < 2km) to each other, indicating redundancy. +* **Network Size**: Warns if the network size exceeds the recommendation for the current preset (e.g. > 60 nodes for LONG_FAST). ### 2. Auto-Discovery of Targets If `priority_nodes` is empty in `config.yaml`, the monitor will automatically select targets based on: @@ -93,6 +94,15 @@ traceroute_timeout: 90 # Minimum interval between tests (in seconds) active_test_interval: 30 + +# Thresholds for Analysis +thresholds: + channel_utilization: 25.0 # Percent + air_util_tx: 7.0 # Percent + router_density_threshold: 2000 # Meters (Minimum distance between routers) + +# Network Size Settings +max_nodes_for_long_fast: 60 ``` The monitor will cycle through these nodes and send traceroute requests to them. diff --git a/mesh_monitor/analyzer.py b/mesh_monitor/analyzer.py index 90c4b59..805384f 100644 --- a/mesh_monitor/analyzer.py +++ b/mesh_monitor/analyzer.py @@ -5,10 +5,16 @@ from .utils import get_val, haversine, get_node_name logger = logging.getLogger(__name__) class NetworkHealthAnalyzer: - def __init__(self, ignore_no_position=False): - self.ch_util_threshold = 25.0 - self.air_util_threshold = 10.0 + def __init__(self, config=None, ignore_no_position=False): + self.config = config or {} self.ignore_no_position = ignore_no_position + + # Load thresholds from config or use defaults + thresholds = self.config.get('thresholds', {}) + 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.max_nodes_long_fast = self.config.get('max_nodes_for_long_fast', 60) def analyze(self, nodes, packet_history=None, my_node=None): """ @@ -34,7 +40,7 @@ class NetworkHealthAnalyzer: # 2. Check Airtime Usage air_util = get_val(metrics, 'airUtilTx', 0) if air_util > self.air_util_threshold: - issues.append(f"Spam: Node '{node_name}' AirUtilTx {air_util:.1f}% (Threshold: {self.air_util_threshold}%)") + issues.append(f"Congestion: Node '{node_name}' AirUtilTx {air_util:.1f}% (Threshold: {self.air_util_threshold}%)") # 3. Check Roles role = get_val(user, 'role', 'CLIENT') @@ -74,6 +80,7 @@ class NetworkHealthAnalyzer: # --- Geospatial Analysis --- issues.extend(self.check_router_density(nodes)) + issues.extend(self.check_network_size_and_preset(nodes)) if my_node: issues.extend(self.check_signal_vs_distance(nodes, my_node)) @@ -312,6 +319,72 @@ class NetworkHealthAnalyzer: return issues + def check_network_size_and_preset(self, nodes): + """ + Checks if network size exceeds recommendations for the current preset. + Note: We can't easily know the *current* preset of the network just from node DB, + but we can warn based on size. + """ + issues = [] + total_nodes = len(nodes) + + 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.") + + return issues + + def check_router_density(self, nodes): + """ + Checks for high density of routers. + New Logic: Check for > 2 routers within 2km radius of each other. + """ + 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, 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 + }) + + # 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 + + dist = haversine(r1['lat'], r1['lon'], r2['lat'], r2['lon']) + if dist < self.router_density_threshold: + nearby_routers.append(r2) + + 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.") + + return issues + def check_signal_vs_distance(self, nodes, my_node): """ Checks for nodes that are close but have poor SNR (indicating obstruction or antenna issues). diff --git a/mesh_monitor/monitor.py b/mesh_monitor/monitor.py index ed14155..722860e 100644 --- a/mesh_monitor/monitor.py +++ b/mesh_monitor/monitor.py @@ -24,7 +24,8 @@ class MeshMonitor: self.interface = None self.interface_type = interface_type self.hostname = hostname - self.analyzer = NetworkHealthAnalyzer(ignore_no_position=ignore_no_position) + self.config = self.load_config(config_file) + self.analyzer = NetworkHealthAnalyzer(config=self.config, ignore_no_position=ignore_no_position) self.reporter = NetworkReporter() self.active_tester = None self.running = False diff --git a/mesh_monitor/reporter.py b/mesh_monitor/reporter.py index 400fca3..45f1a8e 100644 --- a/mesh_monitor/reporter.py +++ b/mesh_monitor/reporter.py @@ -295,7 +295,10 @@ class NetworkReporter: recs.append("- **Fix Roles:** Deprecated `ROUTER_CLIENT` role detected. Change these nodes to `CLIENT` or `CLIENT_MUTE`.") if any("High Density" in i for i in analysis_issues): - recs.append("- **Optimize Placement:** Routers are too close together. Convert redundant routers to clients to reduce noise.") + recs.append("- **Optimize Placement:** Routers are too close together (exceeding configured density threshold). Convert redundant routers to clients to reduce noise.") + + 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).") if any("poor SNR" in i for i in analysis_issues): recs.append("- **Check Hardware:** Nodes with poor SNR at close range may have antenna issues or bad placement.") diff --git a/sample-config.yaml b/sample-config.yaml index 65c800c..9bb9dc7 100644 --- a/sample-config.yaml +++ b/sample-config.yaml @@ -4,7 +4,6 @@ # Format: "!" priority_nodes: # - "!12345678" - - "!d75ae2a0" # Logging Level [warn|info|debug] log_level: info @@ -30,3 +29,21 @@ traceroute_timeout: 90 # Minimum interval between tests (in seconds) active_test_interval: 30 + +# Manual Geolocation Overrides +# Useful for nodes that don't report position +# Format: "!nodeid": {lat: 0.0, lon: 0.0} +manual_positions: +# # Example: +# "!12345678": # Node ID +# lat: 00.00000 +# lon: 00.00000 + +# Thresholds for Analysis +thresholds: + channel_utilization: 25.0 # Percent + air_util_tx: 7.0 # Percent + router_density_threshold: 2000 # Meters (Minimum distance between routers) + +# Network Size Settings +max_nodes_for_long_fast: 60 diff --git a/tests/test_analyzer_enhancements.py b/tests/test_analyzer_enhancements.py new file mode 100644 index 0000000..af8b315 --- /dev/null +++ b/tests/test_analyzer_enhancements.py @@ -0,0 +1,91 @@ +import unittest +from mesh_monitor.analyzer import NetworkHealthAnalyzer + +class TestAnalyzerEnhancements(unittest.TestCase): + def setUp(self): + self.config = { + 'thresholds': { + 'channel_utilization': 25.0, + 'air_util_tx': 7.0 + }, + 'max_nodes_for_long_fast': 60 + } + self.analyzer = NetworkHealthAnalyzer(config=self.config) + + def test_channel_utilization(self): + nodes = { + '!1': { + 'user': {'id': '!1', 'role': 'CLIENT'}, + 'deviceMetrics': {'channelUtilization': 26.0}, + 'position': {} + } + } + issues = self.analyzer.analyze(nodes) + self.assertTrue(any("Congestion" in i and "ChUtil" in i for i in issues)) + + def test_air_util_tx(self): + nodes = { + '!1': { + 'user': {'id': '!1', 'role': 'CLIENT'}, + 'deviceMetrics': {'airUtilTx': 8.0}, + 'position': {} + } + } + issues = self.analyzer.analyze(nodes) + self.assertTrue(any("Congestion" in i and "AirUtilTx" in i for i in issues)) + + def test_network_size(self): + nodes = {} + for i in range(61): + nodes[f'!{i}'] = {'user': {'id': f'!{i}'}} + + issues = self.analyzer.analyze(nodes) + self.assertTrue(any("Network Size" in i for i in issues)) + + def test_router_density(self): + # 3 Routers close to each other + nodes = { + '!1': { + 'user': {'id': '!1', 'role': 'ROUTER'}, + 'position': {'latitude': 40.0, 'longitude': -74.0} + }, + '!2': { + 'user': {'id': '!2', 'role': 'ROUTER'}, + 'position': {'latitude': 40.001, 'longitude': -74.001} # Very close + }, + '!3': { + 'user': {'id': '!3', 'role': 'ROUTER'}, + 'position': {'latitude': 40.002, 'longitude': -74.002} # Very close + } + } + issues = self.analyzer.analyze(nodes) + self.assertTrue(any("High Router Density" in i for i in issues)) + + def test_configurable_router_density(self): + # Set threshold to 500m + config = { + 'thresholds': {'router_density_threshold': 500} + } + analyzer = NetworkHealthAnalyzer(config=config) + + # Routers 1km apart (should NOT trigger warning with 500m threshold) + nodes = { + '!1': { + 'user': {'id': '!1', 'role': 'ROUTER'}, + 'position': {'latitude': 40.0, 'longitude': -74.0} + }, + '!2': { + 'user': {'id': '!2', 'role': 'ROUTER'}, + 'position': {'latitude': 40.01, 'longitude': -74.01} # Approx 1.4km apart + } + } + issues = analyzer.analyze(nodes) + self.assertFalse(any("High Router Density" in i for i in issues)) + + # Routers 200m apart (should trigger warning) + nodes['!2']['position'] = {'latitude': 40.001, 'longitude': -74.001} # Very close + issues = analyzer.analyze(nodes) + self.assertTrue(any("High Router Density" in i for i in issues)) + +if __name__ == '__main__': + unittest.main()