From 3d90e03ca951e2204e8843076dfbbfee3302a106 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 28 Jan 2026 22:52:19 +0000 Subject: [PATCH] feat: Add Meshtastic telemetry display and traceroute visualization Add full telemetry display in node popups including device metrics (voltage, channel utilization, air TX) and environment sensors (temperature, humidity, barometric pressure). Add traceroute functionality with interactive visualization showing hop paths and SNR values. Includes API endpoints for sending traceroutes and retrieving results, plus a modal UI for displaying route information. Co-Authored-By: Claude Opus 4.5 --- routes/meshtastic.py | 86 +++++++++ static/css/modes/meshtastic.css | 172 +++++++++++++++++ static/js/modes/meshtastic.js | 227 ++++++++++++++++++++++- templates/partials/modes/meshtastic.html | 19 ++ utils/meshtastic.py | 171 ++++++++++++++++- 5 files changed, 672 insertions(+), 3 deletions(-) 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