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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-28 22:52:19 +00:00
parent 069e87f9ba
commit 3d90e03ca9
5 changed files with 672 additions and 3 deletions

View File

@@ -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)
})

View File

@@ -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);
}
}

View File

@@ -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 += '<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-color);">';
telemetryHtml += '<span style="color: var(--text-dim); font-size: 9px; text-transform: uppercase;">Device Telemetry</span><br>';
if (node.voltage !== null) {
telemetryHtml += `<span style="color: var(--text-dim);">Voltage:</span> ${node.voltage.toFixed(2)}V<br>`;
}
if (node.channel_utilization !== null) {
telemetryHtml += `<span style="color: var(--text-dim);">Ch Util:</span> ${node.channel_utilization.toFixed(1)}%<br>`;
}
if (node.air_util_tx !== null) {
telemetryHtml += `<span style="color: var(--text-dim);">Air TX:</span> ${node.air_util_tx.toFixed(1)}%<br>`;
}
telemetryHtml += '</div>';
}
// Build environment section
let envHtml = '';
if (node.temperature !== null || node.humidity !== null || node.barometric_pressure !== null) {
envHtml += '<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-color);">';
envHtml += '<span style="color: var(--text-dim); font-size: 9px; text-transform: uppercase;">Environment</span><br>';
if (node.temperature !== null) {
telemetryHtml += `<span style="color: var(--text-dim);">Temp:</span> ${node.temperature.toFixed(1)}°C<br>`;
}
if (node.humidity !== null) {
envHtml += `<span style="color: var(--text-dim);">Humidity:</span> ${node.humidity.toFixed(1)}%<br>`;
}
if (node.barometric_pressure !== null) {
envHtml += `<span style="color: var(--text-dim);">Pressure:</span> ${node.barometric_pressure.toFixed(1)} hPa<br>`;
}
envHtml += '</div>';
}
// Build popup content
const popupContent = `
<div style="min-width: 150px;">
@@ -599,7 +633,10 @@ const Meshtastic = (function() {
${node.altitude ? `<span style="color: var(--text-dim);">Altitude:</span> ${node.altitude}m<br>` : ''}
${node.battery_level !== null ? `<span style="color: var(--text-dim);">Battery:</span> ${node.battery_level}%<br>` : ''}
${node.snr !== null ? `<span style="color: var(--text-dim);">SNR:</span> ${node.snr.toFixed(1)} dB<br>` : ''}
${node.last_heard ? `<span style="color: var(--text-dim);">Last heard:</span> ${new Date(node.last_heard).toLocaleTimeString()}` : ''}
${node.last_heard ? `<span style="color: var(--text-dim);">Last heard:</span> ${new Date(node.last_heard).toLocaleTimeString()}<br>` : ''}
${telemetryHtml}
${envHtml}
${!isLocal ? `<button class="mesh-traceroute-btn" onclick="Meshtastic.sendTraceroute('${nodeId}')">Traceroute</button>` : ''}
</div>
`;
@@ -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 = `
<div class="mesh-traceroute-loading">
<div class="mesh-traceroute-spinner"></div>
<p>Waiting for traceroute response...</p>
</div>
`;
} else if (result && result.error) {
contentEl.innerHTML = `
<div class="mesh-traceroute-error">
<p>Error: ${escapeHtml(result.error)}</p>
</div>
`;
} 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 '<p style="color: var(--text-dim);">Direct connection (no intermediate hops)</p>';
}
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 += `<div class="mesh-traceroute-timestamp">Completed: ${new Date(result.timestamp).toLocaleString()}</div>`;
}
return html;
}
/**
* Render a single route path
*/
function renderRoutePath(label, route, snrValues) {
let html = `<div class="mesh-traceroute-section">
<div class="mesh-traceroute-label">${label}</div>
<div class="mesh-traceroute-path">`;
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 += `<div class="mesh-traceroute-hop">
<div class="mesh-traceroute-hop-node">${escapeHtml(nodeName)}</div>
<div class="mesh-traceroute-hop-id">${nodeId}</div>
${snr !== null ? `<div class="mesh-traceroute-snr ${snrClass}">${snr.toFixed(1)} dB</div>` : ''}
</div>`;
// Add arrow between hops
if (index < route.length - 1) {
html += '<div class="mesh-traceroute-arrow">→</div>';
}
});
html += '</div></div>';
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
};
/**

View File

@@ -100,3 +100,22 @@
</div>
</div>
</div>
<!-- Traceroute Modal -->
<div id="meshTracerouteModal" class="signal-details-modal">
<div class="signal-details-modal-backdrop" onclick="Meshtastic.closeTracerouteModal()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>Traceroute to <span id="meshTracerouteDest">--</span></h3>
<button class="signal-details-modal-close" onclick="Meshtastic.closeTracerouteModal()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div id="meshTracerouteContent" class="mesh-traceroute-content">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="signal-details-modal-footer">
<button class="preset-btn" onclick="Meshtastic.closeTracerouteModal()" style="width: 100%;">Close</button>
</div>
</div>
</div>

View File

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