mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-06-26 04:51:43 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user