feat: Add node discovery feature and improve advert notification

Implemented new "Discover Nodes" feature in Network Commands menu:
- Added .node_discover command to meshcli wrapper (cli.py)
- Created interactive modal with sortable table showing nearby repeaters
- Displays SNR, RSSI, path length, and signal quality indicators
- Added refresh functionality to rescan for nodes

Fixed advert notification to show clean "Advert sent" message
instead of full meshcli output.

Technical changes:
- app/meshcore/cli.py: Added node_discover() function with JSON parsing
- app/routes/api.py: Updated SPECIAL_COMMANDS and execute_special_command()
  to handle node_discover return type and clean advert message
- app/templates/base.html: Added "Discover Nodes" menu button and modal
- app/static/js/app.js: Added discoverNodes() and displayNodeDiscoveryResults()
- README.md: Added documentation for Discover Nodes feature

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-02 14:34:02 +01:00
parent 797096b551
commit 75ec789fba
5 changed files with 282 additions and 13 deletions
+18
View File
@@ -505,6 +505,24 @@ Sends a single advertisement frame to announce your node's presence in the mesh
2. Click "Send Advert" under Network Commands
3. Wait for confirmation toast
#### Discover Nodes
Scans the mesh network to find nearby repeaters and displays their signal quality. Useful for network diagnostics and finding the best repeater connections.
1. Click the menu icon (☰) in the navbar
2. Click "Discover Nodes" under Network Commands
3. A modal window opens showing nearby repeaters with:
- **Node ID**: Public key prefix and tag
- **SNR (Signal-to-Noise Ratio)**: Higher is better (>10dB = excellent, 5-10dB = good, <5dB = poor)
- **RSSI**: Received signal strength in dBm
- **SNR In**: Inbound signal quality
- **Hops**: Number of hops to reach the node
4. Click "Refresh" to rescan for nodes
Results are sorted by signal strength (strongest first) and color-coded:
- 🟢 **Green**: SNR ≥ 10dB (excellent connection)
- 🟡 **Yellow**: SNR 5-10dB (good connection)
- 🔴 **Red**: SNR < 5dB (poor connection)
#### Flood Advert (Use Sparingly!)
Sends advertisement in flooding mode, forcing all nodes to retransmit. **Use only when:**
- Starting a completely new network
+41
View File
@@ -368,6 +368,47 @@ def floodadv() -> Tuple[bool, str]:
return success, stdout or stderr
def node_discover() -> Tuple[bool, List[Dict]]:
"""
Discover nearby mesh nodes (repeaters).
Uses .node_discover command which returns JSON array of nearby repeaters
with SNR, RSSI, and other metadata.
Returns:
Tuple of (success, nodes_list)
Each node dict contains:
{
'SNR': float,
'RSSI': int,
'path_len': int,
'node_type': int (2=REP),
'SNR_in': float,
'tag': str (hex),
'pubkey': str (hex)
}
"""
success, stdout, stderr = _run_command(['.node_discover'])
if not success:
logger.error(f"node_discover failed: {stderr}")
return False, []
try:
# Parse JSON array from stdout
nodes = json.loads(stdout)
if not isinstance(nodes, list):
logger.error(f"node_discover returned non-array: {stdout}")
return False, []
logger.info(f"Discovered {len(nodes)} nearby nodes")
return True, nodes
except json.JSONDecodeError as e:
logger.error(f"Failed to parse node_discover JSON: {e}, output: {stdout}")
return False, []
# =============================================================================
# Direct Messages (DM)
# =============================================================================
+54 -13
View File
@@ -577,6 +577,10 @@ SPECIAL_COMMANDS = {
'function': cli.floodadv,
'description': 'Flood advertisement (use sparingly!)',
},
'node_discover': {
'function': cli.node_discover,
'description': 'Discover nearby mesh nodes (repeaters)',
},
}
@@ -586,7 +590,7 @@ def execute_special_command():
Execute a special device command.
JSON body:
command (str): Command name (required) - one of: advert, floodadv
command (str): Command name (required) - one of: advert, floodadv, node_discover
Returns:
JSON with command result
@@ -610,20 +614,57 @@ def execute_special_command():
# Execute the command
cmd_info = SPECIAL_COMMANDS[command]
success, message = cmd_info['function']()
result = cmd_info['function']()
if success:
return jsonify({
'success': True,
'command': command,
'message': message or f'{command} executed successfully'
}), 200
# Handle different return types
if command == 'node_discover':
# node_discover returns (success, nodes_list)
success, nodes = result
if success:
return jsonify({
'success': True,
'command': command,
'nodes': nodes,
'count': len(nodes)
}), 200
else:
return jsonify({
'success': False,
'command': command,
'error': 'Failed to discover nodes'
}), 500
elif command == 'advert':
# advert returns (success, message) - parse to show only "Advert sent"
success, message = result
if success:
# Extract clean message - just use "Advert sent" instead of full output
clean_message = "Advert sent"
return jsonify({
'success': True,
'command': command,
'message': clean_message
}), 200
else:
return jsonify({
'success': False,
'command': command,
'error': message
}), 500
else:
return jsonify({
'success': False,
'command': command,
'error': message
}), 500
# Other commands (floodadv) return (success, message)
success, message = result
if success:
return jsonify({
'success': True,
'command': command,
'message': message or f'{command} executed successfully'
}), 200
else:
return jsonify({
'success': False,
'command': command,
'error': message
}), 500
except Exception as e:
logger.error(f"Error executing special command: {e}")
+110
View File
@@ -303,6 +303,17 @@ function setupEventListeners() {
}
await executeSpecialCommand('floodadv');
});
// Node Discovery Modal: Load nodes when opened
const nodeDiscoveryModal = document.getElementById('nodeDiscoveryModal');
nodeDiscoveryModal.addEventListener('show.bs.modal', function() {
discoverNodes();
});
// Node Discovery: Refresh button
document.getElementById('refreshDiscoveryBtn').addEventListener('click', function() {
discoverNodes();
});
}
/**
@@ -1253,6 +1264,105 @@ async function copyChannelKey() {
}
/**
* Discover nearby mesh nodes (repeaters)
*/
async function discoverNodes() {
// Show loading state
document.getElementById('nodeDiscoveryStatus').style.display = 'block';
document.getElementById('nodeDiscoveryResults').style.display = 'none';
document.getElementById('nodeDiscoveryError').style.display = 'none';
document.getElementById('nodeDiscoveryEmpty').style.display = 'none';
// Disable refresh button during discovery
const refreshBtn = document.getElementById('refreshDiscoveryBtn');
refreshBtn.disabled = true;
try {
const response = await fetch('/api/device/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ command: 'node_discover' })
});
const data = await response.json();
if (data.success && data.nodes) {
displayNodeDiscoveryResults(data.nodes);
} else {
// Show error
document.getElementById('nodeDiscoveryStatus').style.display = 'none';
document.getElementById('nodeDiscoveryError').style.display = 'block';
document.getElementById('nodeDiscoveryErrorMessage').textContent = data.error || 'Failed to discover nodes';
}
} catch (error) {
console.error('Error discovering nodes:', error);
// Show error
document.getElementById('nodeDiscoveryStatus').style.display = 'none';
document.getElementById('nodeDiscoveryError').style.display = 'block';
document.getElementById('nodeDiscoveryErrorMessage').textContent = 'Network error: ' + error.message;
} finally {
refreshBtn.disabled = false;
}
}
/**
* Display node discovery results in table
*/
function displayNodeDiscoveryResults(nodes) {
const tableBody = document.getElementById('nodeDiscoveryTableBody');
// Hide loading state
document.getElementById('nodeDiscoveryStatus').style.display = 'none';
// Check if empty
if (nodes.length === 0) {
document.getElementById('nodeDiscoveryEmpty').style.display = 'block';
return;
}
// Show results table
document.getElementById('nodeDiscoveryResults').style.display = 'block';
// Clear previous results
tableBody.innerHTML = '';
// Sort nodes by SNR (descending - strongest signal first)
nodes.sort((a, b) => (b.SNR || 0) - (a.SNR || 0));
// Populate table
nodes.forEach(node => {
const row = document.createElement('tr');
// Determine signal quality class
let signalClass = '';
const snr = node.SNR || 0;
if (snr >= 10) {
signalClass = 'text-success fw-bold';
} else if (snr >= 5) {
signalClass = 'text-warning';
} else {
signalClass = 'text-danger';
}
row.innerHTML = `
<td>
<span class="font-monospace">${escapeHtml(node.pubkey?.substring(0, 12) || 'unknown')}</span>
${node.tag ? `<br><small class="text-muted">Tag: ${escapeHtml(node.tag)}</small>` : ''}
</td>
<td class="${signalClass}">${snr.toFixed(2)}</td>
<td>${node.RSSI !== undefined ? node.RSSI : 'N/A'}</td>
<td>${node.SNR_in !== undefined ? node.SNR_in.toFixed(2) : 'N/A'}</td>
<td>${node.path_len !== undefined ? node.path_len : 'N/A'}</td>
`;
tableBody.appendChild(row);
});
}
// =============================================================================
// Direct Messages (DM) Functions
// =============================================================================
+59
View File
@@ -85,6 +85,13 @@
<small class="d-block text-muted">Announce presence (normal)</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="discoverNodesBtn" title="Discover nearby mesh repeaters" data-bs-toggle="modal" data-bs-target="#nodeDiscoveryModal" data-bs-dismiss="offcanvas">
<i class="bi bi-radar" style="font-size: 1.5rem;"></i>
<div>
<span>Discover Nodes</span>
<small class="d-block text-muted">Find nearby repeaters</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3 text-warning" id="floodadvBtn" title="Flood advertisement - use sparingly! High airtime usage.">
<i class="bi bi-broadcast" style="font-size: 1.5rem;"></i>
<div>
@@ -242,6 +249,58 @@
</div>
</div>
<!-- Node Discovery Modal -->
<div class="modal fade" id="nodeDiscoveryModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-radar"></i> Nearby Mesh Nodes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="nodeDiscoveryStatus" class="text-center mb-3">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Discovering nodes...</span>
</div>
<p class="mt-2 text-muted">Scanning for nearby repeaters...</p>
</div>
<div id="nodeDiscoveryResults" style="display: none;">
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Node</th>
<th>SNR (dB)</th>
<th>RSSI (dBm)</th>
<th>SNR In (dB)</th>
<th>Hops</th>
</tr>
</thead>
<tbody id="nodeDiscoveryTableBody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<div id="nodeDiscoveryError" class="alert alert-warning" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<span id="nodeDiscoveryErrorMessage">Failed to discover nodes</span>
</div>
<div id="nodeDiscoveryEmpty" class="text-center text-muted py-3" style="display: none;">
<i class="bi bi-info-circle" style="font-size: 2rem;"></i>
<p class="mt-2">No nearby repeaters found</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="refreshDiscoveryBtn">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">