diff --git a/routes/meshtastic.py b/routes/meshtastic.py index 0f78b69..7db8b23 100644 --- a/routes/meshtastic.py +++ b/routes/meshtastic.py @@ -521,3 +521,89 @@ def get_nodes(): 'count': len(nodes_list), 'with_position_count': sum(1 for n in nodes_list if n.get('has_position')) }) + + +@meshtastic_bp.route('/traceroute', methods=['POST']) +def send_traceroute(): + """ + Send a traceroute request to a mesh node. + + JSON body: + { + "destination": "!a1b2c3d4", // Required: target node ID + "hop_limit": 7 // Optional: max hops (1-7, default 7) + } + + Returns: + JSON with traceroute 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 {} + destination = data.get('destination') + + if not destination: + return jsonify({ + 'status': 'error', + 'message': 'Destination node ID is required' + }), 400 + + hop_limit = data.get('hop_limit', 7) + if not isinstance(hop_limit, int) or not 1 <= hop_limit <= 7: + hop_limit = 7 + + success, error = client.send_traceroute(destination, hop_limit=hop_limit) + + if success: + return jsonify({ + 'status': 'sent', + 'destination': destination, + 'hop_limit': hop_limit + }) + else: + return jsonify({ + 'status': 'error', + 'message': error or 'Failed to send traceroute' + }), 500 + + +@meshtastic_bp.route('/traceroute/results') +def get_traceroute_results(): + """ + Get recent traceroute results. + + Query parameters: + limit: Maximum number of results to return (default: 10) + + Returns: + JSON with list of traceroute results. + """ + client = get_meshtastic_client() + + if not client or not client.is_running: + return jsonify({ + 'status': 'error', + 'message': 'Not connected to Meshtastic device', + 'results': [] + }), 400 + + limit = request.args.get('limit', 10, type=int) + results = client.get_traceroute_results(limit=limit) + + return jsonify({ + 'status': 'ok', + 'results': [r.to_dict() for r in results], + 'count': len(results) + }) diff --git a/static/css/modes/meshtastic.css b/static/css/modes/meshtastic.css index 112c1a6..06e94cd 100644 --- a/static/css/modes/meshtastic.css +++ b/static/css/modes/meshtastic.css @@ -1180,3 +1180,175 @@ min-height: 44px; } } + +/* ============================================ + TRACEROUTE BUTTON IN POPUP + ============================================ */ +.mesh-traceroute-btn { + display: block; + width: 100%; + margin-top: 10px; + padding: 8px 12px; + background: var(--accent-cyan); + border: none; + border-radius: 4px; + color: #000; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: all 0.15s ease; +} + +.mesh-traceroute-btn:hover { + background: var(--accent-green); + transform: scale(1.02); +} + +/* ============================================ + TRACEROUTE MODAL CONTENT + ============================================ */ +.mesh-traceroute-content { + min-height: 100px; +} + +.mesh-traceroute-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-secondary); +} + +.mesh-traceroute-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color); + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: mesh-spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes mesh-spin { + to { transform: rotate(360deg); } +} + +.mesh-traceroute-error { + padding: 16px; + background: rgba(255, 51, 102, 0.1); + border: 1px solid var(--accent-red, #ff3366); + border-radius: 6px; + color: var(--accent-red, #ff3366); + font-size: 12px; +} + +.mesh-traceroute-section { + margin-bottom: 16px; +} + +.mesh-traceroute-label { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.mesh-traceroute-path { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; +} + +.mesh-traceroute-hop { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 14px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + min-width: 70px; +} + +.mesh-traceroute-hop-node { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: 4px; +} + +.mesh-traceroute-hop-id { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim); + margin-bottom: 6px; +} + +.mesh-traceroute-snr { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; +} + +.mesh-traceroute-snr.snr-good { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); +} + +.mesh-traceroute-snr.snr-ok { + background: rgba(74, 158, 255, 0.15); + color: var(--accent-cyan); +} + +.mesh-traceroute-snr.snr-poor { + background: rgba(255, 193, 7, 0.15); + color: var(--accent-orange); +} + +.mesh-traceroute-snr.snr-bad { + background: rgba(255, 51, 102, 0.15); + color: var(--accent-red, #ff3366); +} + +.mesh-traceroute-arrow { + font-size: 18px; + color: var(--text-dim); + font-weight: bold; +} + +.mesh-traceroute-timestamp { + margin-top: 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim); + text-align: right; +} + +/* Responsive traceroute path */ +@media (max-width: 600px) { + .mesh-traceroute-path { + flex-direction: column; + } + + .mesh-traceroute-hop { + width: 100%; + } + + .mesh-traceroute-arrow { + transform: rotate(90deg); + } +} diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index 2080f04..e199a77 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -589,6 +589,40 @@ const Meshtastic = (function() { popupAnchor: [0, -14] }); + // Build telemetry section + let telemetryHtml = ''; + if (node.voltage !== null || node.channel_utilization !== null || node.air_util_tx !== null) { + telemetryHtml += '
'; + telemetryHtml += 'Device Telemetry
'; + if (node.voltage !== null) { + telemetryHtml += `Voltage: ${node.voltage.toFixed(2)}V
`; + } + if (node.channel_utilization !== null) { + telemetryHtml += `Ch Util: ${node.channel_utilization.toFixed(1)}%
`; + } + if (node.air_util_tx !== null) { + telemetryHtml += `Air TX: ${node.air_util_tx.toFixed(1)}%
`; + } + telemetryHtml += '
'; + } + + // Build environment section + let envHtml = ''; + if (node.temperature !== null || node.humidity !== null || node.barometric_pressure !== null) { + envHtml += '
'; + envHtml += 'Environment
'; + if (node.temperature !== null) { + telemetryHtml += `Temp: ${node.temperature.toFixed(1)}°C
`; + } + if (node.humidity !== null) { + envHtml += `Humidity: ${node.humidity.toFixed(1)}%
`; + } + if (node.barometric_pressure !== null) { + envHtml += `Pressure: ${node.barometric_pressure.toFixed(1)} hPa
`; + } + envHtml += '
'; + } + // Build popup content const popupContent = `
@@ -599,7 +633,10 @@ const Meshtastic = (function() { ${node.altitude ? `Altitude: ${node.altitude}m
` : ''} ${node.battery_level !== null ? `Battery: ${node.battery_level}%
` : ''} ${node.snr !== null ? `SNR: ${node.snr.toFixed(1)} dB
` : ''} - ${node.last_heard ? `Last heard: ${new Date(node.last_heard).toLocaleTimeString()}` : ''} + ${node.last_heard ? `Last heard: ${new Date(node.last_heard).toLocaleTimeString()}
` : ''} + ${telemetryHtml} + ${envHtml} + ${!isLocal ? `` : ''}
`; @@ -1208,6 +1245,190 @@ const Meshtastic = (function() { } } + /** + * Send traceroute to a node + */ + async function sendTraceroute(destination) { + if (!destination) return; + + // Show traceroute modal with loading state + showTracerouteModal(destination, null, true); + + try { + const response = await fetch('/meshtastic/traceroute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destination, hop_limit: 7 }) + }); + + const data = await response.json(); + + if (data.status === 'sent') { + // Start polling for results + pollTracerouteResults(destination); + } else { + showTracerouteModal(destination, { error: data.message || 'Failed to send traceroute' }, false); + } + } catch (err) { + console.error('Traceroute error:', err); + showTracerouteModal(destination, { error: err.message }, false); + } + } + + /** + * Poll for traceroute results + */ + async function pollTracerouteResults(destination, attempts = 0) { + const maxAttempts = 30; // 30 seconds timeout + const pollInterval = 1000; + + if (attempts >= maxAttempts) { + showTracerouteModal(destination, { error: 'Traceroute timeout - no response received' }, false); + return; + } + + try { + const response = await fetch('/meshtastic/traceroute/results?limit=5'); + const data = await response.json(); + + if (data.status === 'ok' && data.results) { + // Find result matching our destination + const result = data.results.find(r => r.destination_id === destination); + if (result) { + showTracerouteModal(destination, result, false); + return; + } + } + + // Continue polling + setTimeout(() => pollTracerouteResults(destination, attempts + 1), pollInterval); + } catch (err) { + console.error('Error polling traceroute:', err); + setTimeout(() => pollTracerouteResults(destination, attempts + 1), pollInterval); + } + } + + /** + * Show traceroute modal + */ + function showTracerouteModal(destination, result, loading) { + let modal = document.getElementById('meshTracerouteModal'); + if (!modal) return; + + const destEl = document.getElementById('meshTracerouteDest'); + const contentEl = document.getElementById('meshTracerouteContent'); + + if (destEl) destEl.textContent = destination; + + if (loading) { + contentEl.innerHTML = ` +
+
+

Waiting for traceroute response...

+
+ `; + } else if (result && result.error) { + contentEl.innerHTML = ` +
+

Error: ${escapeHtml(result.error)}

+
+ `; + } else if (result) { + contentEl.innerHTML = renderTracerouteVisualization(result); + } + + modal.classList.add('show'); + } + + /** + * Close traceroute modal + */ + function closeTracerouteModal() { + const modal = document.getElementById('meshTracerouteModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Render traceroute visualization + */ + function renderTracerouteVisualization(result) { + if (!result.route || result.route.length === 0) { + if (result.route_back && result.route_back.length > 0) { + // Only have return path - show it + return renderRoutePath('Return Path', result.route_back, result.snr_back); + } + return '

Direct connection (no intermediate hops)

'; + } + + let html = ''; + + // Forward route + if (result.route && result.route.length > 0) { + html += renderRoutePath('Forward Path', result.route, result.snr_towards); + } + + // Return route + if (result.route_back && result.route_back.length > 0) { + html += renderRoutePath('Return Path', result.route_back, result.snr_back); + } + + // Timestamp + if (result.timestamp) { + html += `
Completed: ${new Date(result.timestamp).toLocaleString()}
`; + } + + return html; + } + + /** + * Render a single route path + */ + function renderRoutePath(label, route, snrValues) { + let html = `
+
${label}
+
`; + + route.forEach((nodeId, index) => { + // Look up node name if available + const nodeName = lookupNodeName(nodeId) || nodeId.slice(-4); + const snr = snrValues && snrValues[index] !== undefined ? snrValues[index] : null; + const snrClass = snr !== null ? getSnrClass(snr) : ''; + + html += `
+
${escapeHtml(nodeName)}
+
${nodeId}
+ ${snr !== null ? `
${snr.toFixed(1)} dB
` : ''} +
`; + + // Add arrow between hops + if (index < route.length - 1) { + html += '
'; + } + }); + + html += '
'; + return html; + } + + /** + * Get SNR quality class + */ + function getSnrClass(snr) { + if (snr >= 10) return 'snr-good'; + if (snr >= 0) return 'snr-ok'; + if (snr >= -10) return 'snr-poor'; + return 'snr-bad'; + } + + /** + * Look up node name from our tracked nodes + */ + function lookupNodeName(nodeId) { + // This would ideally look up from our cached nodes + // For now, return null to use ID + return null; + } + return { init, start, @@ -1226,7 +1447,9 @@ const Meshtastic = (function() { invalidateMap, handleComposeKeydown, toggleSidebar, - toggleOptionsPanel + toggleOptionsPanel, + sendTraceroute, + closeTracerouteModal }; /** diff --git a/templates/partials/modes/meshtastic.html b/templates/partials/modes/meshtastic.html index 8950e2d..fcf3f9a 100644 --- a/templates/partials/modes/meshtastic.html +++ b/templates/partials/modes/meshtastic.html @@ -100,3 +100,22 @@ + + +
+
+
+
+

Traceroute to --

+ +
+
+
+ +
+
+ +
+
diff --git a/utils/meshtastic.py b/utils/meshtastic.py index 63814b5..f221b5c 100644 --- a/utils/meshtastic.py +++ b/utils/meshtastic.py @@ -118,6 +118,14 @@ class MeshNode: battery_level: int | None = None snr: float | None = None last_heard: datetime | None = None + # Device telemetry + voltage: float | None = None + channel_utilization: float | None = None + air_util_tx: float | None = None + # Environment telemetry + temperature: float | None = None + humidity: float | None = None + barometric_pressure: float | None = None def to_dict(self) -> dict: return { @@ -133,6 +141,14 @@ class MeshNode: 'snr': self.snr, 'last_heard': self.last_heard.isoformat() if self.last_heard else None, 'has_position': self.latitude is not None and self.longitude is not None, + # Device telemetry + 'voltage': self.voltage, + 'channel_utilization': self.channel_utilization, + 'air_util_tx': self.air_util_tx, + # Environment telemetry + 'temperature': self.temperature, + 'humidity': self.humidity, + 'barometric_pressure': self.barometric_pressure, } @@ -163,6 +179,29 @@ class NodeInfo: } +@dataclass +class TracerouteResult: + """Result of a traceroute to a mesh node.""" + destination_id: str + route: list[str] # Node IDs in forward path + route_back: list[str] # Return path + snr_towards: list[float] # SNR per hop (forward) + snr_back: list[float] # SNR per hop (return) + timestamp: datetime + success: bool + + def to_dict(self) -> dict: + return { + 'destination_id': self.destination_id, + 'route': self.route, + 'route_back': self.route_back, + 'snr_towards': self.snr_towards, + 'snr_back': self.snr_back, + 'timestamp': self.timestamp.isoformat(), + 'success': self.success, + } + + class MeshtasticClient: """Client for connecting to Meshtastic devices.""" @@ -174,6 +213,8 @@ class MeshtasticClient: self._nodes: dict[int, MeshNode] = {} # num -> MeshNode self._device_path: str | None = None self._error: str | None = None + self._traceroute_results: list[TracerouteResult] = [] + self._max_traceroute_results = 50 @property def is_running(self) -> bool: @@ -312,6 +353,10 @@ class MeshtasticClient: # Track node from packet (always, even for filtered messages) self._track_node_from_packet(packet, decoded, portnum) + # Parse traceroute responses + if portnum == 'TRACEROUTE_APP': + self._handle_traceroute_response(packet, decoded) + # Skip callback if none set if not self._callback: return @@ -421,14 +466,38 @@ class MeshtasticClient: node.longitude = lon node.altitude = position.get('altitude', node.altitude) - # Parse TELEMETRY_APP for battery + # Parse TELEMETRY_APP for battery and other metrics elif portnum == 'TELEMETRY_APP': telemetry = decoded.get('telemetry', {}) + + # Device metrics device_metrics = telemetry.get('deviceMetrics', {}) if device_metrics: battery = device_metrics.get('batteryLevel') if battery is not None: node.battery_level = battery + voltage = device_metrics.get('voltage') + if voltage is not None: + node.voltage = voltage + channel_util = device_metrics.get('channelUtilization') + if channel_util is not None: + node.channel_utilization = channel_util + air_util = device_metrics.get('airUtilTx') + if air_util is not None: + node.air_util_tx = air_util + + # Environment metrics + env_metrics = telemetry.get('environmentMetrics', {}) + if env_metrics: + temp = env_metrics.get('temperature') + if temp is not None: + node.temperature = temp + humidity = env_metrics.get('relativeHumidity') + if humidity is not None: + node.humidity = humidity + pressure = env_metrics.get('barometricPressure') + if pressure is not None: + node.barometric_pressure = pressure def _lookup_node_name(self, node_num: int) -> str | None: """Look up a node's name by its number.""" @@ -752,6 +821,106 @@ class MeshtasticClient: return None + def send_traceroute(self, destination: str | int, hop_limit: int = 7) -> tuple[bool, str]: + """ + Send a traceroute request to a destination node. + + Args: + destination: Target node ID (string like "!a1b2c3d4" or int) + hop_limit: Maximum number of hops (1-7, default 7) + + 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" + + # Validate hop limit + hop_limit = max(1, min(7, hop_limit)) + + 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 traceroute to broadcast address" + + # Use the SDK's sendTraceRoute method + logger.info(f"Sending traceroute to {self._format_node_id(dest_id)} with hop_limit={hop_limit}") + self._interface.sendTraceRoute(dest_id, hopLimit=hop_limit) + + return True, None + + except Exception as e: + logger.error(f"Error sending traceroute: {e}") + return False, str(e) + + def _handle_traceroute_response(self, packet: dict, decoded: dict) -> None: + """Handle incoming traceroute response.""" + try: + from_num = packet.get('from', 0) + route_discovery = decoded.get('routeDiscovery', {}) + + # Extract route information + route = route_discovery.get('route', []) + route_back = route_discovery.get('routeBack', []) + snr_towards = route_discovery.get('snrTowards', []) + snr_back = route_discovery.get('snrBack', []) + + # Convert node numbers to IDs + route_ids = [self._format_node_id(n) for n in route] + route_back_ids = [self._format_node_id(n) for n in route_back] + + # Convert SNR values (stored as int8, need to convert) + snr_towards_float = [float(s) / 4.0 if isinstance(s, int) else float(s) for s in snr_towards] + snr_back_float = [float(s) / 4.0 if isinstance(s, int) else float(s) for s in snr_back] + + result = TracerouteResult( + destination_id=self._format_node_id(from_num), + route=route_ids, + route_back=route_back_ids, + snr_towards=snr_towards_float, + snr_back=snr_back_float, + timestamp=datetime.now(timezone.utc), + success=len(route) > 0 or len(route_back) > 0, + ) + + # Store result + self._traceroute_results.append(result) + if len(self._traceroute_results) > self._max_traceroute_results: + self._traceroute_results.pop(0) + + logger.info(f"Traceroute response from {result.destination_id}: route={route_ids}, route_back={route_back_ids}") + + except Exception as e: + logger.error(f"Error handling traceroute response: {e}") + + def get_traceroute_results(self, limit: int | None = None) -> list[TracerouteResult]: + """ + Get recent traceroute results. + + Args: + limit: Maximum number of results to return (None for all) + + Returns: + List of TracerouteResult objects, most recent first + """ + results = list(reversed(self._traceroute_results)) + if limit: + results = results[:limit] + return results + # Global client instance _client: MeshtasticClient | None = None