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 @@
+
+
+
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