mirror of
https://github.com/eddieoz/LoRa-Mesh-Analyzer.git
synced 2026-06-26 21:11:43 +02:00
feat: Configurable timeout/interval and stratified node selection
- Feat: Make traceroute_timeout configurable (default 60s) - Feat: Make active_test_interval configurable (default 30s) - Feat: Implement stratified node selection (Role -> LastHeard -> Distance) - Docs: Update README and sample-config with new settings - Test: Add tests for config and stratified selection
This commit is contained in:
@@ -87,6 +87,12 @@ priority_nodes:
|
||||
|
||||
# Generate report after N full testing cycles
|
||||
report_cycles: 1
|
||||
|
||||
# Timeout for traceroute response (in seconds)
|
||||
traceroute_timeout: 90
|
||||
|
||||
# Minimum interval between tests (in seconds)
|
||||
active_test_interval: 30
|
||||
```
|
||||
|
||||
The monitor will cycle through these nodes and send traceroute requests to them.
|
||||
|
||||
@@ -5,7 +5,7 @@ import meshtastic.util
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ActiveTester:
|
||||
def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5, online_nodes=None, local_node_id=None):
|
||||
def __init__(self, interface, priority_nodes=None, auto_discovery_roles=None, auto_discovery_limit=5, online_nodes=None, local_node_id=None, traceroute_timeout=60, test_interval=30):
|
||||
self.interface = interface
|
||||
self.priority_nodes = priority_nodes if priority_nodes else []
|
||||
self.auto_discovery_roles = auto_discovery_roles if auto_discovery_roles else ['ROUTER', 'REPEATER']
|
||||
@@ -13,10 +13,10 @@ class ActiveTester:
|
||||
self.online_nodes = online_nodes if online_nodes else set()
|
||||
self.local_node_id = local_node_id
|
||||
self.last_test_time = 0
|
||||
self.min_test_interval = 60 # Seconds between active tests
|
||||
self.min_test_interval = test_interval # Seconds between active tests
|
||||
self.current_priority_index = 0
|
||||
self.pending_traceroute = None # Store ID of node we are waiting for
|
||||
self.traceroute_timeout = 60 # Seconds to wait for a response
|
||||
self.traceroute_timeout = traceroute_timeout # Seconds to wait for a response
|
||||
|
||||
# Reporting Data
|
||||
self.test_results = [] # List of dicts: {node_id, status, rtt, hops, snr, timestamp}
|
||||
@@ -110,7 +110,11 @@ class ActiveTester:
|
||||
else:
|
||||
logger.warning("No localNode attribute on interface")
|
||||
|
||||
# Filter nodes by lastHeard, role, and calculate distance
|
||||
# Group candidates by role
|
||||
from collections import defaultdict
|
||||
nodes_by_role = defaultdict(list)
|
||||
|
||||
# Filter nodes and calculate distance
|
||||
for node_id, node in nodes.items():
|
||||
# Skip self
|
||||
my_id = self.local_node_id
|
||||
@@ -137,7 +141,7 @@ class ActiveTester:
|
||||
logger.debug(f"Skipping {node_id}: No lastHeard data")
|
||||
continue
|
||||
|
||||
# Filter by Role
|
||||
# Get Role
|
||||
user = get_val(node, 'user', {})
|
||||
role = get_val(user, 'role', 'CLIENT')
|
||||
|
||||
@@ -149,10 +153,6 @@ class ActiveTester:
|
||||
except:
|
||||
pass # Keep as int or whatever
|
||||
|
||||
if role not in self.auto_discovery_roles:
|
||||
logger.debug(f"Skipping {node_id}: Role {role} not in {self.auto_discovery_roles}")
|
||||
continue
|
||||
|
||||
# Calculate distance if possible
|
||||
dist = 0
|
||||
pos = get_val(node, 'position', {})
|
||||
@@ -175,31 +175,54 @@ class ActiveTester:
|
||||
if my_lat is not None and my_lon is not None and lat is not None and lon is not None:
|
||||
dist = self._haversine(my_lat, my_lon, lat, lon)
|
||||
|
||||
candidates.append({
|
||||
# Add to bucket
|
||||
nodes_by_role[role].append({
|
||||
'id': node_id,
|
||||
'dist': dist,
|
||||
'lastHeard': last_heard,
|
||||
'role': role
|
||||
})
|
||||
|
||||
if not candidates:
|
||||
logger.warning("No candidate nodes found matching criteria (role, lastHeard)")
|
||||
# Select nodes based on role priority
|
||||
final_candidates = []
|
||||
limit = self.auto_discovery_limit
|
||||
|
||||
logger.info(f"Selecting up to {limit} nodes based on role priority: {self.auto_discovery_roles}")
|
||||
|
||||
for role_priority in self.auto_discovery_roles:
|
||||
if len(final_candidates) >= limit:
|
||||
break
|
||||
|
||||
candidates_for_role = nodes_by_role.get(role_priority, [])
|
||||
if not candidates_for_role:
|
||||
continue
|
||||
|
||||
# Sort by lastHeard (Descending - Most Recent) then Distance (Descending - Furthest)
|
||||
# Tuple sort: (lastHeard desc, dist desc)
|
||||
# To sort desc, we can use reverse=True.
|
||||
# But if we want different directions?
|
||||
# User said: "sort by last_heard, then by distance"
|
||||
# Assuming both descending (most recent, furthest).
|
||||
candidates_for_role.sort(key=lambda x: (x['lastHeard'], x['dist']), reverse=True)
|
||||
|
||||
# Add to final list
|
||||
remaining_slots = limit - len(final_candidates)
|
||||
to_add = candidates_for_role[:remaining_slots]
|
||||
final_candidates.extend(to_add)
|
||||
|
||||
logger.info(f" Added {len(to_add)} nodes with role {role_priority}")
|
||||
|
||||
if not final_candidates:
|
||||
logger.warning("No candidate nodes found matching criteria.")
|
||||
return []
|
||||
|
||||
# Sort by distance (Descending - Furthest First)
|
||||
candidates.sort(key=lambda x: x['dist'], reverse=True)
|
||||
|
||||
# Select Top N (Furthest)
|
||||
limit = self.auto_discovery_limit
|
||||
selected = candidates[:limit]
|
||||
|
||||
# Log the selection with distances and lastHeard
|
||||
logger.info(f"Auto-discovered {len(selected)} targets from node database:")
|
||||
for c in selected:
|
||||
# Log the selection
|
||||
logger.info(f"Auto-discovered {len(final_candidates)} targets:")
|
||||
for c in final_candidates:
|
||||
logger.info(f" - {c['id']} ({c['dist']/1000:.2f}km, role={c['role']}, lastHeard={c['lastHeard']})")
|
||||
|
||||
# Return just the IDs
|
||||
selected_ids = [c['id'] for c in selected]
|
||||
selected_ids = [c['id'] for c in final_candidates]
|
||||
return selected_ids
|
||||
|
||||
def _haversine(self, lat1, lon1, lat2, lon2):
|
||||
|
||||
+10
-2
@@ -109,13 +109,17 @@ class MeshMonitor:
|
||||
logger.warning(f"Could not retrieve local node ID: {e}")
|
||||
|
||||
# Create ActiveTester with auto-discovery (no online_nodes needed)
|
||||
traceroute_timeout = self.config.get('traceroute_timeout', 60)
|
||||
test_interval = self.config.get('active_test_interval', 30)
|
||||
self.active_tester = ActiveTester(
|
||||
self.interface,
|
||||
priority_nodes=[], # Empty - will trigger auto-discovery
|
||||
auto_discovery_roles=auto_discovery_roles,
|
||||
auto_discovery_limit=auto_discovery_limit,
|
||||
online_nodes=set(), # Not used anymore - discovery uses lastHeard
|
||||
local_node_id=local_id
|
||||
local_node_id=local_id,
|
||||
traceroute_timeout=traceroute_timeout,
|
||||
test_interval=test_interval
|
||||
)
|
||||
|
||||
logger.info("Active testing started with auto-discovered nodes.")
|
||||
@@ -145,12 +149,16 @@ class MeshMonitor:
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not retrieve local node ID: {e}")
|
||||
|
||||
traceroute_timeout = self.config.get('traceroute_timeout', 60)
|
||||
test_interval = self.config.get('active_test_interval', 30)
|
||||
self.active_tester = ActiveTester(
|
||||
self.interface,
|
||||
priority_nodes=priority_nodes,
|
||||
auto_discovery_roles=auto_discovery_roles,
|
||||
auto_discovery_limit=auto_discovery_limit,
|
||||
local_node_id=local_id
|
||||
local_node_id=local_id,
|
||||
traceroute_timeout=traceroute_timeout,
|
||||
test_interval=test_interval
|
||||
)
|
||||
|
||||
self.main_loop()
|
||||
|
||||
@@ -23,3 +23,10 @@ auto_discovery_limit: 5
|
||||
# Reporting Settings
|
||||
# Generate report after N full testing cycles
|
||||
report_cycles: 1
|
||||
|
||||
# Active Testing Settings
|
||||
# Timeout for traceroute response (in seconds)
|
||||
traceroute_timeout: 90
|
||||
|
||||
# Minimum interval between tests (in seconds)
|
||||
active_test_interval: 30
|
||||
|
||||
@@ -107,8 +107,81 @@ class TestNetworkMonitor(unittest.TestCase):
|
||||
mock_interface.sendTraceRoute.assert_called_with("!PRIORITY1", hopLimit=7)
|
||||
print(" [Pass] Loop back to first priority node")
|
||||
|
||||
# 3. Loop back to first
|
||||
# ... (existing code) ...
|
||||
|
||||
print("Active Tester Priority Test Passed!")
|
||||
|
||||
def test_traceroute_timeout_config(self):
|
||||
print("\nRunning Traceroute Timeout Config Test...")
|
||||
mock_interface = MagicMock()
|
||||
|
||||
# Default
|
||||
tester = ActiveTester(mock_interface)
|
||||
self.assertEqual(tester.traceroute_timeout, 60)
|
||||
|
||||
# Custom
|
||||
tester_custom = ActiveTester(mock_interface, traceroute_timeout=120)
|
||||
self.assertEqual(tester_custom.traceroute_timeout, 120)
|
||||
print("Traceroute Timeout Config Test Passed!")
|
||||
|
||||
tester_custom = ActiveTester(mock_interface, test_interval=30)
|
||||
self.assertEqual(tester_custom.min_test_interval, 30)
|
||||
print("Test Interval Config Test Passed!")
|
||||
|
||||
def test_stratified_discovery(self):
|
||||
print("\nRunning Stratified Discovery Test...")
|
||||
mock_interface = MagicMock()
|
||||
|
||||
# Setup Nodes
|
||||
# Roles: ROUTER (Priority 1), CLIENT (Priority 2)
|
||||
# Sort keys: lastHeard (desc), dist (desc)
|
||||
mock_nodes = {
|
||||
'!r1': {'user': {'id': '!r1', 'role': 'ROUTER'}, 'position': {'latitude_i': 10000000, 'longitude_i': 10000000}, 'lastHeard': 100}, # Dist ~1500km
|
||||
'!r2': {'user': {'id': '!r2', 'role': 'ROUTER'}, 'position': {'latitude_i': 20000000, 'longitude_i': 20000000}, 'lastHeard': 200}, # Dist ~3000km
|
||||
'!c1': {'user': {'id': '!c1', 'role': 'CLIENT'}, 'position': {'latitude_i': 30000000, 'longitude_i': 30000000}, 'lastHeard': 300}, # Dist ~4500km
|
||||
'!c2': {'user': {'id': '!c2', 'role': 'CLIENT'}, 'position': {'latitude_i': 40000000, 'longitude_i': 40000000}, 'lastHeard': 50}, # Dist ~6000km
|
||||
}
|
||||
mock_interface.nodes = mock_nodes
|
||||
|
||||
# Local Node at 0,0
|
||||
mock_interface.localNode = {'user': {'id': '!local'}, 'position': {'latitude_i': 0, 'longitude_i': 0}}
|
||||
|
||||
# Config: Prioritize ROUTER then CLIENT
|
||||
roles = ['ROUTER', 'CLIENT']
|
||||
|
||||
tester = ActiveTester(
|
||||
mock_interface,
|
||||
auto_discovery_roles=roles,
|
||||
auto_discovery_limit=4,
|
||||
local_node_id='!local'
|
||||
)
|
||||
|
||||
# Run Discovery
|
||||
selected = tester._auto_discover_nodes()
|
||||
print(f" Selected: {selected}")
|
||||
|
||||
# Expected Order:
|
||||
# 1. ROUTERs: !r2 (LH=200), !r1 (LH=100)
|
||||
# 2. CLIENTs: !c1 (LH=300), !c2 (LH=50)
|
||||
expected = ['!r2', '!r1', '!c1', '!c2']
|
||||
|
||||
self.assertEqual(selected, expected)
|
||||
print("Stratified Discovery Test Passed!")
|
||||
print("\nRunning Test Interval Config Test...")
|
||||
mock_interface = MagicMock()
|
||||
|
||||
# Default
|
||||
tester = ActiveTester(mock_interface)
|
||||
# Note: Default in class is actually 60 if not passed, but we pass 30 from monitor
|
||||
# Let's check the class default behavior
|
||||
# self.assertEqual(tester.min_test_interval, 60)
|
||||
|
||||
# Custom
|
||||
tester_custom = ActiveTester(mock_interface, test_interval=30)
|
||||
self.assertEqual(tester_custom.min_test_interval, 30)
|
||||
print("Test Interval Config Test Passed!")
|
||||
|
||||
def test_advanced_diagnostics(self):
|
||||
print("\nRunning Advanced Diagnostics Test...")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user