diff --git a/README.md b/README.md index ef2cb60..e35224f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index 1b984e9..d817d5b 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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) # ============================================================================= diff --git a/app/routes/api.py b/app/routes/api.py index 236d2a2..462a69b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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}") diff --git a/app/static/js/app.js b/app/static/js/app.js index 70ba4d6..5967751 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 = ` +