mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user