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:
eddieoz
2025-11-27 10:55:14 +02:00
parent cbda7e8432
commit b75b3adf8d
5 changed files with 142 additions and 25 deletions
+6
View File
@@ -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.
+46 -23
View File
@@ -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
View File
@@ -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()
+7
View File
@@ -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
+73
View File
@@ -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...")