feat: Add configurable network analysis thresholds, network size warnings, and enhanced router density detection.

This commit is contained in:
eddieoz
2025-11-28 10:41:13 +02:00
parent 800c839bf7
commit 9ebe4c7509
6 changed files with 203 additions and 8 deletions
+11 -1
View File
@@ -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.
+77 -4
View File
@@ -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).
+2 -1
View File
@@ -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
+4 -1
View File
@@ -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.")
+18 -1
View File
@@ -4,7 +4,6 @@
# Format: "!<NodeID>"
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
+91
View File
@@ -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()