mirror of
https://github.com/eddieoz/LoRa-Mesh-Analyzer.git
synced 2026-08-07 01:03:12 +02:00
feat: Add configurable network analysis thresholds, network size warnings, and enhanced router density detection.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user