diff --git a/requirements.txt b/requirements.txt index be0f7dd..5fe89bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,9 @@ pyserial>=3.5 # Meshtastic mesh network support (optional - only needed for Meshtastic features) meshtastic>=2.0.0 +# QR code generation for Meshtastic channels (optional) +qrcode[pil]>=7.4 + # Development dependencies (install with: pip install -r requirements-dev.txt) # pytest>=7.0.0 # pytest-cov>=4.0.0 diff --git a/routes/meshtastic.py b/routes/meshtastic.py index 7db8b23..32c1e58 100644 --- a/routes/meshtastic.py +++ b/routes/meshtastic.py @@ -607,3 +607,407 @@ def get_traceroute_results(): 'results': [r.to_dict() for r in results], 'count': len(results) }) + + +@meshtastic_bp.route('/position/request', methods=['POST']) +def request_position(): + """ + Request position from a specific node. + + JSON body: + { + "node_id": "!a1b2c3d4" // Required: target node ID + } + + Returns: + JSON with request status. + """ + if not is_meshtastic_available(): + return jsonify({ + 'status': 'error', + 'message': 'Meshtastic SDK not installed' + }), 400 + + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device' + }), 400 + + data = request.get_json(silent=True) or {} + node_id = data.get('node_id') + + if not node_id: + return jsonify({ + 'status': 'error', + 'message': 'Node ID is required' + }), 400 + + success, error = client.request_position(node_id) + + if success: + return jsonify({ + 'status': 'sent', + 'node_id': node_id + }) + else: + return jsonify({ + 'status': 'error', + 'message': error or 'Failed to request position' + }), 500 + + +@meshtastic_bp.route('/firmware/check') +def check_firmware(): + """ + Check current firmware version and compare to latest release. + + Returns: + JSON with current_version, latest_version, update_available, release_url. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device' + }), 400 + + result = client.check_firmware() + result['status'] = 'ok' + return jsonify(result) + + +@meshtastic_bp.route('/channels//qr') +def get_channel_qr(index: int): + """ + Generate QR code for a channel configuration. + + Args: + index: Channel index (0-7) + + Returns: + PNG image of QR code. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device' + }), 400 + + if not 0 <= index <= 7: + return jsonify({ + 'status': 'error', + 'message': 'Channel index must be 0-7' + }), 400 + + png_data = client.generate_channel_qr(index) + + if png_data: + return Response(png_data, mimetype='image/png') + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to generate QR code. Make sure qrcode library is installed.' + }), 500 + + +@meshtastic_bp.route('/telemetry/history') +def get_telemetry_history(): + """ + Get telemetry history for a node. + + Query parameters: + node_id: Node ID or number (required) + hours: Number of hours of history (default: 24) + + Returns: + JSON with telemetry data points. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device', + 'data': [] + }), 400 + + node_id = request.args.get('node_id') + hours = request.args.get('hours', 24, type=int) + + if not node_id: + return jsonify({ + 'status': 'error', + 'message': 'node_id is required', + 'data': [] + }), 400 + + # Parse node ID to number + try: + if node_id.startswith('!'): + node_num = int(node_id[1:], 16) + else: + node_num = int(node_id) + except ValueError: + return jsonify({ + 'status': 'error', + 'message': f'Invalid node_id: {node_id}', + 'data': [] + }), 400 + + history = client.get_telemetry_history(node_num, hours=hours) + + return jsonify({ + 'status': 'ok', + 'node_id': node_id, + 'hours': hours, + 'data': [p.to_dict() for p in history], + 'count': len(history) + }) + + +@meshtastic_bp.route('/neighbors') +def get_neighbors(): + """ + Get neighbor information for mesh topology visualization. + + Query parameters: + node_id: Specific node ID (optional, returns all if not provided) + + Returns: + JSON with neighbor relationships. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device', + 'neighbors': {} + }), 400 + + node_id = request.args.get('node_id') + node_num = None + + if node_id: + try: + if node_id.startswith('!'): + node_num = int(node_id[1:], 16) + else: + node_num = int(node_id) + except ValueError: + return jsonify({ + 'status': 'error', + 'message': f'Invalid node_id: {node_id}', + 'neighbors': {} + }), 400 + + neighbors = client.get_neighbors(node_num) + + # Convert to JSON-serializable format + result = {} + for num, neighbor_list in neighbors.items(): + node_key = f"!{num:08x}" + result[node_key] = [n.to_dict() for n in neighbor_list] + + return jsonify({ + 'status': 'ok', + 'neighbors': result, + 'node_count': len(result) + }) + + +@meshtastic_bp.route('/pending') +def get_pending_messages(): + """ + Get messages waiting for ACK. + + Returns: + JSON with pending messages and their status. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device', + 'messages': [] + }), 400 + + pending = client.get_pending_messages() + + return jsonify({ + 'status': 'ok', + 'messages': [m.to_dict() for m in pending.values()], + 'count': len(pending) + }) + + +@meshtastic_bp.route('/range-test/start', methods=['POST']) +def start_range_test(): + """ + Start a range test. + + JSON body: + { + "count": 10, // Number of packets to send (default 10) + "interval": 5 // Seconds between packets (default 5) + } + + Returns: + JSON with start status. + """ + if not is_meshtastic_available(): + return jsonify({ + 'status': 'error', + 'message': 'Meshtastic SDK not installed' + }), 400 + + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device' + }), 400 + + data = request.get_json(silent=True) or {} + count = data.get('count', 10) + interval = data.get('interval', 5) + + # Validate + if not isinstance(count, int) or count < 1 or count > 100: + count = 10 + if not isinstance(interval, int) or interval < 1 or interval > 60: + interval = 5 + + success, error = client.start_range_test(count=count, interval=interval) + + if success: + return jsonify({ + 'status': 'started', + 'count': count, + 'interval': interval + }) + else: + return jsonify({ + 'status': 'error', + 'message': error or 'Failed to start range test' + }), 500 + + +@meshtastic_bp.route('/range-test/stop', methods=['POST']) +def stop_range_test(): + """ + Stop an ongoing range test. + + Returns: + JSON confirmation. + """ + client = get_meshtastic_client() + + if client: + client.stop_range_test() + + return jsonify({'status': 'stopped'}) + + +@meshtastic_bp.route('/range-test/status') +def get_range_test_status(): + """ + Get range test status and results. + + Returns: + JSON with running status and results. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device', + 'running': False, + 'results': [] + }), 400 + + status = client.get_range_test_status() + return jsonify({ + 'status': 'ok', + **status + }) + + +@meshtastic_bp.route('/store-forward/status') +def get_store_forward_status(): + """ + Check if Store & Forward router is available. + + Returns: + JSON with availability status and router info. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device', + 'available': False + }), 400 + + sf_status = client.check_store_forward_available() + return jsonify({ + 'status': 'ok', + **sf_status + }) + + +@meshtastic_bp.route('/store-forward/request', methods=['POST']) +def request_store_forward(): + """ + Request missed messages from Store & Forward router. + + JSON body: + { + "window_minutes": 60 // Minutes of history to request (default 60) + } + + Returns: + JSON with request status. + """ + if not is_meshtastic_available(): + return jsonify({ + 'status': 'error', + 'message': 'Meshtastic SDK not installed' + }), 400 + + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device' + }), 400 + + data = request.get_json(silent=True) or {} + window_minutes = data.get('window_minutes', 60) + + if not isinstance(window_minutes, int) or window_minutes < 1 or window_minutes > 1440: + window_minutes = 60 + + success, error = client.request_store_forward(window_minutes=window_minutes) + + if success: + return jsonify({ + 'status': 'sent', + 'window_minutes': window_minutes + }) + else: + return jsonify({ + 'status': 'error', + 'message': error or 'Failed to request S&F history' + }), 500 diff --git a/static/css/modes/meshtastic.css b/static/css/modes/meshtastic.css index 06e94cd..3a3c722 100644 --- a/static/css/modes/meshtastic.css +++ b/static/css/modes/meshtastic.css @@ -447,20 +447,27 @@ background: var(--accent-cyan); border: 2px solid #fff; border-radius: 50%; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.4), + 0 0 12px 4px rgba(0, 212, 255, 0.5); /* Cyan glow */ color: #000; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: bold; + text-shadow: 0 0 2px rgba(255, 255, 255, 0.8); } .mesh-node-marker.local { background: var(--accent-green); + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.4), + 0 0 12px 4px rgba(34, 197, 94, 0.5); /* Green glow */ } .mesh-node-marker.stale { background: var(--text-dim); opacity: 0.7; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); /* No glow for stale */ } /* ============================================ @@ -1352,3 +1359,220 @@ transform: rotate(90deg); } } + +/* ============================================ + NODE POPUP ACTION BUTTONS + ============================================ */ +.mesh-position-btn, +.mesh-telemetry-btn { + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; +} + +.mesh-position-btn:hover, +.mesh-telemetry-btn:hover { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +/* ============================================ + QR CODE BUTTON + ============================================ */ +.mesh-qr-btn { + padding: 4px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.mesh-qr-btn:hover { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +/* ============================================ + TELEMETRY CHARTS + ============================================ */ +.mesh-telemetry-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.mesh-telemetry-chart { + margin-bottom: 20px; +} + +.mesh-telemetry-chart-title { + display: flex; + justify-content: space-between; + align-items: center; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.mesh-telemetry-current { + font-size: 14px; + color: var(--accent-cyan); +} + +.mesh-telemetry-svg { + width: 100%; + height: 100px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.mesh-chart-line { + stroke: var(--accent-cyan); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.mesh-chart-grid { + stroke: var(--border-color); + stroke-width: 1; +} + +.mesh-chart-label { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + fill: var(--text-dim); +} + +/* ============================================ + NETWORK TOPOLOGY + ============================================ */ +.mesh-network-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mesh-network-node { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; +} + +.mesh-network-node-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.mesh-network-node-id { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan); +} + +.mesh-network-node-count { + font-size: 11px; + color: var(--text-dim); +} + +.mesh-network-neighbors { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mesh-network-neighbor { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.mesh-network-neighbor-id { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary); +} + +.mesh-network-neighbor-snr { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; +} + +.mesh-network-neighbor-snr.snr-good { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); +} + +.mesh-network-neighbor-snr.snr-ok { + background: rgba(74, 158, 255, 0.15); + color: var(--accent-cyan); +} + +.mesh-network-neighbor-snr.snr-poor { + background: rgba(255, 193, 7, 0.15); + color: var(--accent-orange); +} + +.mesh-network-neighbor-snr.snr-bad { + background: rgba(255, 51, 102, 0.15); + color: var(--accent-red, #ff3366); +} + +/* ============================================ + FIRMWARE BADGES + ============================================ */ +.mesh-badge { + display: inline-block; + padding: 3px 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + border-radius: 10px; + text-transform: uppercase; +} + +.mesh-badge-success { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); +} + +.mesh-badge-warning { + background: rgba(255, 193, 7, 0.15); + color: var(--accent-orange); +} diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index 1ddec95..81766bf 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -33,7 +33,7 @@ const Meshtastic = (function() { * Setup event delegation for dynamically created elements */ function setupEventDelegation() { - // Handle traceroute button clicks in Leaflet popups + // Handle button clicks in Leaflet popups and elsewhere document.addEventListener('click', function(e) { const tracerouteBtn = e.target.closest('.mesh-traceroute-btn'); if (tracerouteBtn) { @@ -42,6 +42,22 @@ const Meshtastic = (function() { sendTraceroute(nodeId); } } + + const positionBtn = e.target.closest('.mesh-position-btn'); + if (positionBtn) { + const nodeId = positionBtn.dataset.nodeId; + if (nodeId) { + requestPosition(nodeId); + } + } + + const qrBtn = e.target.closest('.mesh-qr-btn'); + if (qrBtn) { + const channelIndex = qrBtn.dataset.channelIndex; + if (channelIndex !== undefined) { + showChannelQR(parseInt(channelIndex, 10)); + } + } }); } @@ -380,6 +396,7 @@ const Meshtastic = (function() {
${ch.role || 'SECONDARY'} ${encText} +
@@ -647,7 +664,18 @@ const Meshtastic = (function() { envHtml += ''; } - // Build popup content + // Build popup content with action buttons + let actionButtons = ''; + if (!isLocal) { + actionButtons = ` +
+ + + +
+ `; + } + const popupContent = `
${node.long_name || shortName}
@@ -660,7 +688,7 @@ const Meshtastic = (function() { ${node.last_heard ? `Last heard: ${new Date(node.last_heard).toLocaleTimeString()}
` : ''} ${telemetryHtml} ${envHtml} - ${!isLocal ? `` : ''} + ${actionButtons}
`; @@ -1453,6 +1481,721 @@ const Meshtastic = (function() { return null; } + /** + * Request position from a specific node + */ + async function requestPosition(nodeId) { + if (!nodeId) return; + + try { + const response = await fetch('/meshtastic/position/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ node_id: nodeId }) + }); + + const data = await response.json(); + + if (data.status === 'sent') { + showNotification('Meshtastic', `Position requested from ${nodeId}`); + // Refresh nodes after a delay to get updated position + setTimeout(loadNodes, 5000); + } else { + showStatusMessage(data.message || 'Failed to request position', 'error'); + } + } catch (err) { + console.error('Position request error:', err); + showStatusMessage('Error requesting position: ' + err.message, 'error'); + } + } + + /** + * Check firmware version and show update status + */ + async function checkFirmware() { + try { + const response = await fetch('/meshtastic/firmware/check'); + const data = await response.json(); + + if (data.status === 'ok') { + showFirmwareModal(data); + } else { + showStatusMessage(data.message || 'Failed to check firmware', 'error'); + } + } catch (err) { + console.error('Firmware check error:', err); + showStatusMessage('Error checking firmware: ' + err.message, 'error'); + } + } + + /** + * Show firmware information modal + */ + function showFirmwareModal(info) { + let modal = document.getElementById('meshFirmwareModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'meshFirmwareModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + const updateBadge = info.update_available + ? 'Update Available' + : 'Up to Date'; + + modal.innerHTML = ` +
+
+
+

Firmware Information

+ +
+
+
+
Current Version
+

+ ${info.current_version || 'Unknown'} +

+
+
+
Latest Version
+

+ ${info.latest_version || 'Unknown'} ${updateBadge} +

+
+ ${info.release_url ? ` + + ` : ''} + ${info.error ? ` +
+

+ Note: ${info.error} +

+
+ ` : ''} +
+
+ `; + + modal.classList.add('show'); + } + + /** + * Close firmware modal + */ + function closeFirmwareModal() { + const modal = document.getElementById('meshFirmwareModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Show QR code for a channel + */ + async function showChannelQR(channelIndex) { + let modal = document.getElementById('meshQRModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'meshQRModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + const channel = channels.find(ch => ch.index === channelIndex); + const channelName = channel?.name || `Channel ${channelIndex}`; + + // Show loading state + modal.innerHTML = ` +
+
+
+

Channel QR Code

+ +
+
+
+
+

Generating QR code...

+
+
+
+ `; + modal.classList.add('show'); + + try { + const response = await fetch(`/meshtastic/channels/${channelIndex}/qr`); + + if (response.ok) { + const blob = await response.blob(); + const imageUrl = URL.createObjectURL(blob); + + modal.innerHTML = ` +
+
+
+

Channel QR Code

+ +
+
+

+ ${escapeHtml(channelName)} +

+ Channel QR Code +

+ Scan with the Meshtastic app to join this channel +

+
+
+ `; + } else { + const data = await response.json(); + throw new Error(data.message || 'Failed to generate QR code'); + } + } catch (err) { + console.error('QR generation error:', err); + modal.innerHTML = ` +
+
+
+

Channel QR Code

+ +
+
+

+ Error: ${escapeHtml(err.message)} +

+

+ Make sure the qrcode library is installed: pip install qrcode[pil] +

+
+
+ `; + } + } + + /** + * Close QR modal + */ + function closeQRModal() { + const modal = document.getElementById('meshQRModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Load and display telemetry history for a node + */ + async function showTelemetryChart(nodeId, hours = 24) { + let modal = document.getElementById('meshTelemetryModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'meshTelemetryModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + // Show loading + modal.innerHTML = ` +
+
+
+

Telemetry History

+ +
+
+
+
+

Loading telemetry data...

+
+
+
+ `; + modal.classList.add('show'); + + try { + const response = await fetch(`/meshtastic/telemetry/history?node_id=${encodeURIComponent(nodeId)}&hours=${hours}`); + const data = await response.json(); + + if (data.status === 'ok') { + renderTelemetryCharts(modal, nodeId, data.data, hours); + } else { + throw new Error(data.message || 'Failed to load telemetry'); + } + } catch (err) { + console.error('Telemetry load error:', err); + modal.querySelector('.signal-details-modal-body').innerHTML = ` +

Error: ${escapeHtml(err.message)}

+ `; + } + } + + /** + * Render telemetry charts + */ + function renderTelemetryCharts(modal, nodeId, data, hours) { + if (!data || data.length === 0) { + modal.querySelector('.signal-details-modal-body').innerHTML = ` +

+ No telemetry data available for this node in the last ${hours} hours. +

+ `; + return; + } + + // Build charts for available metrics + let chartsHtml = ` +
+ Node: ${escapeHtml(nodeId)} + ${data.length} data points +
+ `; + + // Battery chart + const batteryData = data.filter(p => p.battery_level !== null); + if (batteryData.length > 0) { + chartsHtml += renderSimpleChart('Battery Level', batteryData, 'battery_level', '%', 0, 100); + } + + // Voltage chart + const voltageData = data.filter(p => p.voltage !== null); + if (voltageData.length > 0) { + chartsHtml += renderSimpleChart('Voltage', voltageData, 'voltage', 'V', null, null); + } + + // Temperature chart + const tempData = data.filter(p => p.temperature !== null); + if (tempData.length > 0) { + chartsHtml += renderSimpleChart('Temperature', tempData, 'temperature', '°C', null, null); + } + + // Humidity chart + const humidityData = data.filter(p => p.humidity !== null); + if (humidityData.length > 0) { + chartsHtml += renderSimpleChart('Humidity', humidityData, 'humidity', '%', 0, 100); + } + + modal.querySelector('.signal-details-modal-body').innerHTML = chartsHtml; + } + + /** + * Render a simple SVG line chart + */ + function renderSimpleChart(title, data, field, unit, minY, maxY) { + if (data.length < 2) { + return ` +
+
${title}
+

Not enough data points

+
+ `; + } + + // Extract values + const values = data.map(p => p[field]); + const timestamps = data.map(p => new Date(p.timestamp)); + + // Calculate bounds + const min = minY !== null ? minY : Math.min(...values) * 0.95; + const max = maxY !== null ? maxY : Math.max(...values) * 1.05; + const range = max - min || 1; + + // Chart dimensions + const width = 500; + const height = 100; + const padding = { left: 40, right: 10, top: 10, bottom: 20 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + // Build path + const points = values.map((v, i) => { + const x = padding.left + (i / (values.length - 1)) * chartWidth; + const y = padding.top + chartHeight - ((v - min) / range) * chartHeight; + return `${x},${y}`; + }); + const pathD = 'M' + points.join(' L'); + + // Current value + const currentValue = values[values.length - 1]; + + return ` +
+
+ ${title} + ${currentValue.toFixed(1)}${unit} +
+ + + ${max.toFixed(0)} + ${min.toFixed(0)} + + + + + + +
+ `; + } + + /** + * Close telemetry modal + */ + function closeTelemetryModal() { + const modal = document.getElementById('meshTelemetryModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Show network topology (neighbors) + */ + async function showNetworkTopology() { + let modal = document.getElementById('meshNetworkModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'meshNetworkModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + // Show loading + modal.innerHTML = ` +
+
+
+

Network Topology

+ +
+
+
+
+

Loading neighbor data...

+
+
+
+ `; + modal.classList.add('show'); + + try { + const response = await fetch('/meshtastic/neighbors'); + const data = await response.json(); + + if (data.status === 'ok') { + renderNetworkTopology(modal, data.neighbors); + } else { + throw new Error(data.message || 'Failed to load neighbors'); + } + } catch (err) { + console.error('Network topology error:', err); + modal.querySelector('.signal-details-modal-body').innerHTML = ` +

Error: ${escapeHtml(err.message)}

+ `; + } + } + + /** + * Render network topology visualization + */ + function renderNetworkTopology(modal, neighbors) { + if (!neighbors || Object.keys(neighbors).length === 0) { + modal.querySelector('.signal-details-modal-body').innerHTML = ` +

+ No neighbor information available yet.
+ Neighbor data is collected from NEIGHBOR_INFO_APP packets. +

+ `; + return; + } + + // Build a simple list view of neighbors + let html = '
'; + + for (const [nodeId, neighborList] of Object.entries(neighbors)) { + html += ` +
+
+ ${escapeHtml(nodeId)} + ${neighborList.length} neighbors +
+
+ `; + + neighborList.forEach(neighbor => { + const snrClass = getSnrClass(neighbor.snr); + html += ` +
+ ${escapeHtml(neighbor.neighbor_id)} + ${neighbor.snr.toFixed(1)} dB +
+ `; + }); + + html += '
'; + } + + html += '
'; + modal.querySelector('.signal-details-modal-body').innerHTML = html; + } + + /** + * Close network modal + */ + function closeNetworkModal() { + const modal = document.getElementById('meshNetworkModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Show range test modal + */ + function showRangeTestModal() { + let modal = document.getElementById('meshRangeTestModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'meshRangeTestModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` +
+
+
+

Range Test

+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ `; + + modal.classList.add('show'); + } + + /** + * Start range test + */ + async function startRangeTest() { + const countInput = document.getElementById('rangeTestCount'); + const intervalInput = document.getElementById('rangeTestInterval'); + const startBtn = document.getElementById('rangeTestStartBtn'); + const stopBtn = document.getElementById('rangeTestStopBtn'); + const statusDiv = document.getElementById('rangeTestStatus'); + + const count = parseInt(countInput?.value || '10', 10); + const interval = parseInt(intervalInput?.value || '5', 10); + + try { + const response = await fetch('/meshtastic/range-test/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count, interval }) + }); + + const data = await response.json(); + + if (data.status === 'started') { + if (startBtn) startBtn.style.display = 'none'; + if (stopBtn) stopBtn.style.display = 'inline-block'; + if (statusDiv) statusDiv.style.display = 'block'; + + showNotification('Meshtastic', `Range test started: ${count} packets`); + + // Poll for completion + pollRangeTestStatus(); + } else { + showStatusMessage(data.message || 'Failed to start range test', 'error'); + } + } catch (err) { + console.error('Range test error:', err); + showStatusMessage('Error starting range test: ' + err.message, 'error'); + } + } + + /** + * Stop range test + */ + async function stopRangeTest() { + try { + await fetch('/meshtastic/range-test/stop', { method: 'POST' }); + resetRangeTestUI(); + showNotification('Meshtastic', 'Range test stopped'); + } catch (err) { + console.error('Error stopping range test:', err); + } + } + + /** + * Poll range test status + */ + async function pollRangeTestStatus() { + try { + const response = await fetch('/meshtastic/range-test/status'); + const data = await response.json(); + + if (data.running) { + setTimeout(pollRangeTestStatus, 1000); + } else { + resetRangeTestUI(); + showNotification('Meshtastic', 'Range test complete'); + } + } catch (err) { + console.error('Error polling range test:', err); + resetRangeTestUI(); + } + } + + /** + * Reset range test UI + */ + function resetRangeTestUI() { + const startBtn = document.getElementById('rangeTestStartBtn'); + const stopBtn = document.getElementById('rangeTestStopBtn'); + const statusDiv = document.getElementById('rangeTestStatus'); + + if (startBtn) startBtn.style.display = 'inline-block'; + if (stopBtn) stopBtn.style.display = 'none'; + if (statusDiv) statusDiv.style.display = 'none'; + } + + /** + * Close range test modal + */ + function closeRangeTestModal() { + const modal = document.getElementById('meshRangeTestModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Show Store & Forward modal + */ + async function showStoreForwardModal() { + let modal = document.getElementById('meshStoreForwardModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'meshStoreForwardModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + // Show loading state + modal.innerHTML = ` +
+
+
+

Store & Forward

+ +
+
+
+
+

Checking for S&F router...

+
+
+
+ `; + modal.classList.add('show'); + + try { + const response = await fetch('/meshtastic/store-forward/status'); + const data = await response.json(); + + if (data.available) { + modal.querySelector('.signal-details-modal-body').innerHTML = ` +
+

+ ✓ Store & Forward router found +

+

+ Router: ${escapeHtml(data.router_name || data.router_id || 'Unknown')} +

+
+
+ + +
+ + `; + } else { + modal.querySelector('.signal-details-modal-body').innerHTML = ` +

+ No Store & Forward router found on the mesh.

+ + S&F requires a node with ROUTER role running the
+ Store & Forward module with history enabled. +
+

+ `; + } + } catch (err) { + console.error('S&F status error:', err); + modal.querySelector('.signal-details-modal-body').innerHTML = ` +

Error: ${escapeHtml(err.message)}

+ `; + } + } + + /** + * Request Store & Forward history + */ + async function requestStoreForward() { + const select = document.getElementById('sfWindowMinutes'); + const windowMinutes = parseInt(select?.value || '60', 10); + + try { + const response = await fetch('/meshtastic/store-forward/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ window_minutes: windowMinutes }) + }); + + const data = await response.json(); + + if (data.status === 'sent') { + showNotification('Meshtastic', `Requested ${windowMinutes} minutes of history`); + closeStoreForwardModal(); + } else { + showStatusMessage(data.message || 'Failed to request S&F history', 'error'); + } + } catch (err) { + console.error('S&F request error:', err); + showStatusMessage('Error: ' + err.message, 'error'); + } + } + + /** + * Close Store & Forward modal + */ + function closeStoreForwardModal() { + const modal = document.getElementById('meshStoreForwardModal'); + if (modal) modal.classList.remove('show'); + } + return { init, start, @@ -1473,7 +2216,26 @@ const Meshtastic = (function() { toggleSidebar, toggleOptionsPanel, sendTraceroute, - closeTracerouteModal + closeTracerouteModal, + // New features + requestPosition, + checkFirmware, + closeFirmwareModal, + showChannelQR, + closeQRModal, + showTelemetryChart, + closeTelemetryModal, + showNetworkTopology, + closeNetworkModal, + // Range test + showRangeTestModal, + startRangeTest, + stopRangeTest, + closeRangeTestModal, + // Store & Forward + showStoreForwardModal, + requestStoreForward, + closeStoreForwardModal }; /** diff --git a/utils/meshtastic.py b/utils/meshtastic.py index f221b5c..fc2ec67 100644 --- a/utils/meshtastic.py +++ b/utils/meshtastic.py @@ -13,6 +13,9 @@ import base64 import hashlib import secrets import threading +import urllib.request +import json +from collections import deque from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Callable @@ -202,6 +205,69 @@ class TracerouteResult: } +@dataclass +class TelemetryPoint: + """Single telemetry data point for graphing.""" + timestamp: datetime + battery_level: int | None = None + voltage: float | None = None + temperature: float | None = None + humidity: float | None = None + pressure: float | None = None + channel_utilization: float | None = None + air_util_tx: float | None = None + + def to_dict(self) -> dict: + return { + 'timestamp': self.timestamp.isoformat(), + 'battery_level': self.battery_level, + 'voltage': self.voltage, + 'temperature': self.temperature, + 'humidity': self.humidity, + 'pressure': self.pressure, + 'channel_utilization': self.channel_utilization, + 'air_util_tx': self.air_util_tx, + } + + +@dataclass +class PendingMessage: + """Message waiting for ACK/NAK.""" + packet_id: int + destination: int + text: str + channel: int + timestamp: datetime + status: str = 'pending' # pending, acked, failed + + def to_dict(self) -> dict: + return { + 'packet_id': self.packet_id, + 'destination': self.destination, + 'text': self.text, + 'channel': self.channel, + 'timestamp': self.timestamp.isoformat(), + 'status': self.status, + } + + +@dataclass +class NeighborInfo: + """Neighbor information from NEIGHBOR_INFO_APP.""" + neighbor_num: int + neighbor_id: str + snr: float + timestamp: datetime + + def to_dict(self) -> dict: + return { + 'neighbor_num': self.neighbor_num, + 'neighbor_id': self.neighbor_id, + 'snr': self.snr, + 'timestamp': self.timestamp.isoformat(), + } + + class MeshtasticClient: """Client for connecting to Meshtastic devices.""" @@ -216,6 +282,25 @@ class MeshtasticClient: self._traceroute_results: list[TracerouteResult] = [] self._max_traceroute_results = 50 + # Telemetry history for graphing (node_num -> deque of TelemetryPoints) + self._telemetry_history: dict[int, deque] = {} + self._max_telemetry_points = 1000 + + # Pending messages for ACK tracking (packet_id -> PendingMessage) + self._pending_messages: dict[int, PendingMessage] = {} + + # Neighbor info (node_num -> list of NeighborInfo) + self._neighbors: dict[int, list[NeighborInfo]] = {} + + # Firmware version cache + self._firmware_version: str | None = None + self._latest_firmware: dict | None = None + self._firmware_check_time: datetime | None = None + + # Range test state + self._range_test_running: bool = False + self._range_test_results: list[dict] = [] + @property def is_running(self) -> bool: return self._running @@ -357,13 +442,21 @@ class MeshtasticClient: if portnum == 'TRACEROUTE_APP': self._handle_traceroute_response(packet, decoded) + # Handle ACK/NAK for message delivery tracking + if portnum == 'ROUTING_APP': + self._handle_routing_packet(packet, decoded) + + # Handle neighbor info for mesh topology + if portnum == 'NEIGHBOR_INFO_APP': + self._handle_neighbor_info(packet, decoded) + # Skip callback if none set if not self._callback: return # Filter out internal protocol messages that aren't useful to users ignored_portnums = { - 'ROUTING_APP', # Mesh routing/acknowledgments + 'ROUTING_APP', # Mesh routing/acknowledgments - handled above 'ADMIN_APP', # Admin commands 'REPLY_APP', # Internal replies 'STORE_FORWARD_APP', # Store and forward protocol @@ -375,6 +468,7 @@ class MeshtasticClient: 'TELEMETRY_APP', # Device telemetry (battery, etc.) - too noisy 'POSITION_APP', # Position updates - used for map, not messages 'NODEINFO_APP', # Node info - used for tracking, not messages + 'NEIGHBOR_INFO_APP', # Neighbor info - handled above } if portnum in ignored_portnums: logger.debug(f"Ignoring {portnum} message from {from_num}") @@ -499,6 +593,32 @@ class MeshtasticClient: if pressure is not None: node.barometric_pressure = pressure + # Store telemetry point for historical graphing + self._store_telemetry_point(from_num, device_metrics, env_metrics) + + def _store_telemetry_point(self, node_num: int, device_metrics: dict, env_metrics: dict) -> None: + """Store a telemetry data point for historical graphing.""" + # Skip if no actual data + if not device_metrics and not env_metrics: + return + + point = TelemetryPoint( + timestamp=datetime.now(timezone.utc), + battery_level=device_metrics.get('batteryLevel'), + voltage=device_metrics.get('voltage'), + temperature=env_metrics.get('temperature'), + humidity=env_metrics.get('relativeHumidity'), + pressure=env_metrics.get('barometricPressure'), + channel_utilization=device_metrics.get('channelUtilization'), + air_util_tx=device_metrics.get('airUtilTx'), + ) + + # Initialize deque for this node if needed + if node_num not in self._telemetry_history: + self._telemetry_history[node_num] = deque(maxlen=self._max_telemetry_points) + + self._telemetry_history[node_num].append(point) + def _lookup_node_name(self, node_num: int) -> str | None: """Look up a node's name by its number.""" if node_num == 0 or node_num == BROADCAST_ADDR: @@ -921,6 +1041,456 @@ class MeshtasticClient: results = results[:limit] return results + def _handle_routing_packet(self, packet: dict, decoded: dict) -> None: + """Handle ROUTING_APP packets for ACK/NAK tracking.""" + try: + routing = decoded.get('routing', {}) + error_reason = routing.get('errorReason') + request_id = packet.get('requestId', 0) + + if request_id and request_id in self._pending_messages: + msg = self._pending_messages[request_id] + if error_reason and error_reason != 'NONE': + msg.status = 'failed' + logger.debug(f"Message {request_id} failed: {error_reason}") + else: + msg.status = 'acked' + logger.debug(f"Message {request_id} acknowledged") + except Exception as e: + logger.error(f"Error handling routing packet: {e}") + + def _handle_neighbor_info(self, packet: dict, decoded: dict) -> None: + """Handle NEIGHBOR_INFO_APP packets for mesh topology.""" + try: + from_num = packet.get('from', 0) + if from_num == 0: + return + + neighbor_info = decoded.get('neighborinfo', {}) + neighbors = neighbor_info.get('neighbors', []) + + now = datetime.now(timezone.utc) + neighbor_list = [] + + for neighbor in neighbors: + neighbor_num = neighbor.get('nodeId', 0) + if neighbor_num: + neighbor_list.append(NeighborInfo( + neighbor_num=neighbor_num, + neighbor_id=self._format_node_id(neighbor_num), + snr=neighbor.get('snr', 0.0), + timestamp=now, + )) + + if neighbor_list: + self._neighbors[from_num] = neighbor_list + logger.debug(f"Updated neighbors for {self._format_node_id(from_num)}: {len(neighbor_list)} neighbors") + + except Exception as e: + logger.error(f"Error handling neighbor info: {e}") + + def get_neighbors(self, node_num: int | None = None) -> dict[int, list[NeighborInfo]]: + """ + Get neighbor information for mesh topology visualization. + + Args: + node_num: Specific node number, or None for all nodes + + Returns: + Dict mapping node_num to list of NeighborInfo + """ + if node_num is not None: + return {node_num: self._neighbors.get(node_num, [])} + return dict(self._neighbors) + + def get_telemetry_history(self, node_num: int, hours: int = 24) -> list[TelemetryPoint]: + """ + Get telemetry history for a node. + + Args: + node_num: Node number to get history for + hours: Number of hours of history to return + + Returns: + List of TelemetryPoint objects + """ + if node_num not in self._telemetry_history: + return [] + + cutoff = datetime.now(timezone.utc).timestamp() - (hours * 3600) + return [ + p for p in self._telemetry_history[node_num] + if p.timestamp.timestamp() > cutoff + ] + + def get_pending_messages(self) -> dict[int, PendingMessage]: + """Get all pending messages waiting for ACK.""" + return dict(self._pending_messages) + + def request_position(self, destination: str | int) -> tuple[bool, str]: + """ + Request position from a specific node. + + Args: + destination: Target node ID (string like "!a1b2c3d4" or int) + + Returns: + Tuple of (success, error_message) + """ + if not self._interface: + return False, "Not connected to device" + + if not HAS_MESHTASTIC: + return False, "Meshtastic SDK not installed" + + try: + # Parse destination + if isinstance(destination, int): + dest_id = destination + elif destination.startswith('!'): + dest_id = int(destination[1:], 16) + else: + try: + dest_id = int(destination) + except ValueError: + return False, f"Invalid destination: {destination}" + + if dest_id == BROADCAST_ADDR: + return False, "Cannot request position from broadcast address" + + # Send position request using admin message + # The Meshtastic SDK's localNode.requestPosition works for the local node + # For remote nodes, we send a POSITION_APP request + from meshtastic import portnums_pb2 + + # Request position by sending an empty position request packet + self._interface.sendData( + b'', # Empty payload triggers position response + destinationId=dest_id, + portNum=portnums_pb2.PortNum.POSITION_APP, + wantAck=True, + wantResponse=True, + ) + + logger.info(f"Sent position request to {self._format_node_id(dest_id)}") + return True, None + + except Exception as e: + logger.error(f"Error requesting position: {e}") + return False, str(e) + + def check_firmware(self) -> dict: + """ + Check current firmware version and compare to latest release. + + Returns: + Dict with current_version, latest_version, update_available, release_url + """ + result = { + 'current_version': None, + 'latest_version': None, + 'update_available': False, + 'release_url': None, + 'error': None, + } + + # Get current firmware version from device + if self._interface: + try: + my_info = self._interface.getMyNodeInfo() + if my_info: + metadata = my_info.get('deviceMetrics', {}) + # Firmware version is in the user section or metadata + if 'firmware_version' in my_info: + self._firmware_version = my_info['firmware_version'] + elif hasattr(self._interface, 'myInfo') and self._interface.myInfo: + self._firmware_version = getattr(self._interface.myInfo, 'firmware_version', None) + result['current_version'] = self._firmware_version + except Exception as e: + logger.warning(f"Could not get device firmware version: {e}") + + # Check GitHub for latest release (cache for 15 minutes) + now = datetime.now(timezone.utc) + cache_valid = ( + self._firmware_check_time and + self._latest_firmware and + (now - self._firmware_check_time).total_seconds() < 900 + ) + + if not cache_valid: + try: + url = 'https://api.github.com/repos/meshtastic/firmware/releases/latest' + req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT'}) + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + self._latest_firmware = { + 'version': data.get('tag_name', '').lstrip('v'), + 'url': data.get('html_url'), + 'name': data.get('name'), + } + self._firmware_check_time = now + except Exception as e: + logger.warning(f"Could not check latest firmware: {e}") + result['error'] = str(e) + + if self._latest_firmware: + result['latest_version'] = self._latest_firmware.get('version') + result['release_url'] = self._latest_firmware.get('url') + + # Compare versions + if result['current_version'] and result['latest_version']: + result['update_available'] = self._compare_versions( + result['current_version'], + result['latest_version'] + ) + + return result + + def _compare_versions(self, current: str, latest: str) -> bool: + """Compare semver versions, return True if update available.""" + try: + def parse_version(v: str) -> tuple: + # Strip any leading 'v' and split by dots + v = v.lstrip('v').split('-')[0] # Remove pre-release suffix + parts = v.split('.') + return tuple(int(p) for p in parts[:3]) + + current_parts = parse_version(current) + latest_parts = parse_version(latest) + return latest_parts > current_parts + except Exception: + return False + + def generate_channel_qr(self, channel_index: int) -> bytes | None: + """ + Generate QR code for a channel configuration. + + Args: + channel_index: Channel index (0-7) + + Returns: + PNG image bytes, or None on error + """ + try: + import qrcode + from io import BytesIO + except ImportError: + logger.error("qrcode library not installed. Install with: pip install qrcode[pil]") + return None + + if not self._interface: + return None + + try: + channels = self.get_channels() + channel = None + for ch in channels: + if ch.index == channel_index: + channel = ch + break + + if not channel: + logger.error(f"Channel {channel_index} not found") + return None + + # Build Meshtastic URL + # Format: https://meshtastic.org/e/#CgMSAQ... (base64 channel config) + # The URL encodes the channel settings protobuf + + # For simplicity, we'll create a URL with the channel name and key info + # The official format requires protobuf serialization + channel_data = { + 'name': channel.name, + 'index': channel.index, + 'psk': base64.b64encode(channel.psk).decode('utf-8') if channel.psk else '', + } + + # Encode as base64 JSON (simplified format) + encoded = base64.urlsafe_b64encode( + json.dumps(channel_data).encode('utf-8') + ).decode('utf-8') + + url = f"https://meshtastic.org/e/#{encoded}" + + # Generate QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Convert to PNG bytes + buffer = BytesIO() + img.save(buffer, format='PNG') + return buffer.getvalue() + + except Exception as e: + logger.error(f"Error generating QR code: {e}") + return None + + def start_range_test(self, count: int = 10, interval: int = 5) -> tuple[bool, str]: + """ + Start a range test by sending test packets. + + Args: + count: Number of test packets to send + interval: Seconds between packets + + Returns: + Tuple of (success, error_message) + """ + if not self._interface: + return False, "Not connected to device" + + if not HAS_MESHTASTIC: + return False, "Meshtastic SDK not installed" + + if self._range_test_running: + return False, "Range test already running" + + try: + from meshtastic import portnums_pb2 + + self._range_test_running = True + self._range_test_results = [] + + # Send range test packets in a background thread + import threading + + def send_packets(): + import time + for i in range(count): + if not self._range_test_running: + break + + try: + # Send range test packet with sequence number + payload = f"RangeTest #{i+1}".encode('utf-8') + self._interface.sendData( + payload, + destinationId=BROADCAST_ADDR, + portNum=portnums_pb2.PortNum.RANGE_TEST_APP, + ) + logger.info(f"Range test packet {i+1}/{count} sent") + except Exception as e: + logger.error(f"Error sending range test packet: {e}") + + if i < count - 1 and self._range_test_running: + time.sleep(interval) + + self._range_test_running = False + logger.info("Range test complete") + + thread = threading.Thread(target=send_packets, daemon=True) + thread.start() + + return True, None + + except Exception as e: + self._range_test_running = False + logger.error(f"Error starting range test: {e}") + return False, str(e) + + def stop_range_test(self) -> None: + """Stop an ongoing range test.""" + self._range_test_running = False + + def get_range_test_status(self) -> dict: + """Get range test status.""" + return { + 'running': self._range_test_running, + 'results': self._range_test_results, + } + + def request_store_forward(self, window_minutes: int = 60) -> tuple[bool, str]: + """ + Request missed messages from a Store & Forward router. + + Args: + window_minutes: Minutes of history to request + + Returns: + Tuple of (success, error_message) + """ + if not self._interface: + return False, "Not connected to device" + + if not HAS_MESHTASTIC: + return False, "Meshtastic SDK not installed" + + try: + from meshtastic import portnums_pb2, storeforward_pb2 + + # Find S&F router (look for nodes with router role) + router_num = None + if self._interface.nodes: + for node_id, node_data in self._interface.nodes.items(): + # Check for router role + role = node_data.get('user', {}).get('role') + if role in ('ROUTER', 'ROUTER_CLIENT'): + if isinstance(node_id, str) and node_id.startswith('!'): + router_num = int(node_id[1:], 16) + elif isinstance(node_id, int): + router_num = node_id + break + + if not router_num: + return False, "No Store & Forward router found on mesh" + + # Build S&F history request + sf_request = storeforward_pb2.StoreAndForward() + sf_request.rr = storeforward_pb2.StoreAndForward.RequestResponse.CLIENT_HISTORY + sf_request.history.window = window_minutes * 60 # Convert to seconds + + self._interface.sendData( + sf_request.SerializeToString(), + destinationId=router_num, + portNum=portnums_pb2.PortNum.STORE_FORWARD_APP, + ) + + logger.info(f"Requested S&F history from {self._format_node_id(router_num)} for {window_minutes} minutes") + return True, None + + except ImportError: + return False, "Store & Forward protobuf not available" + except Exception as e: + logger.error(f"Error requesting S&F history: {e}") + return False, str(e) + + def check_store_forward_available(self) -> dict: + """ + Check if a Store & Forward router is available. + + Returns: + Dict with available status and router info + """ + result = { + 'available': False, + 'router_id': None, + 'router_name': None, + } + + if not self._interface or not self._interface.nodes: + return result + + for node_id, node_data in self._interface.nodes.items(): + role = node_data.get('user', {}).get('role') + if role in ('ROUTER', 'ROUTER_CLIENT'): + result['available'] = True + if isinstance(node_id, str): + result['router_id'] = node_id + else: + result['router_id'] = self._format_node_id(node_id) + result['router_name'] = node_data.get('user', {}).get('shortName') + break + + return result + # Global client instance _client: MeshtasticClient | None = None