feat(repeaters): Telemetry tool (stage 4)

All Cayenne LPP channels shown at once (no channel dropdown like the
standard app): one card per channel with typed rows — icon, name,
value and unit (voltage V, temperature °C, current A, power W,
humidity %, GPS lat/lon+alt, etc.). Channel 1 is labeled 'device'
(repeater's own vitals). Refresh + updated-ago label; timeout renders
inline with a retry button (exercised for real on a 2-hop repeater —
first attempt timed out, retry succeeded with 2 channels).

Backend: repeater_req_telemetry in device_manager (serialized under
the repeater lock; the old name-only request_telemetry stays for
sensor nodes) + login-gated GET /api/repeaters/<pk>/telemetry.
GPS values arrive as {latitude, longitude, altitude} objects from
meshcore 2.3.7 — formatter handles both object and array forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-18 08:36:02 +02:00
parent 707fd20426
commit 995cb49f4c
3 changed files with 215 additions and 0 deletions
+18
View File
@@ -3291,6 +3291,24 @@ class DeviceManager:
logger.error(f"req_mma failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_telemetry(self, name_or_key: str) -> Dict:
"""Request telemetry (Cayenne LPP) from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
return self._locked_repeater_execute(
self.mc.commands.req_telemetry_sync(contact, contact_timeout, min_timeout=15),
timeout=120,
error_label='telemetry',
)
except Exception as e:
logger.error(f"req_telemetry failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_neighbours(self, name_or_key: str) -> Dict:
"""Request neighbours from a repeater."""
if not self.is_connected:
+23
View File
@@ -5866,6 +5866,29 @@ def repeater_status(public_key):
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/telemetry', methods=['GET'])
def repeater_telemetry(public_key):
"""Telemetry request to a repeater (Cayenne LPP entries, all channels)."""
dm = _get_dm()
if not dm:
return jsonify({'success': False, 'error': 'Device not connected'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
login_error = _require_repeater_login(dm, pk)
if login_error:
return login_error
try:
result = dm.repeater_req_telemetry(pk)
if result.get('success'):
return jsonify({'success': True, 'lpp': result['data']}), 200
return jsonify({'success': False,
'error': result.get('error', 'No telemetry response')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error getting repeater telemetry: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/clock', methods=['GET'])
def repeater_clock(public_key):
"""Current clock of a repeater (unix seconds + formatted)."""
+174
View File
@@ -233,6 +233,10 @@ function openToolPane(tool) {
renderStatusPane(body);
return;
}
if (tool.key === 'telemetry') {
renderTelemetryPane(body);
return;
}
body.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
@@ -395,6 +399,176 @@ function renderStatusTable(container, s) {
container.innerHTML = system + radio + packets;
}
// ================================================================
// Telemetry tool
// ================================================================
// Cayenne LPP type name -> {unit, decimals, icon}
const LPP_DISPLAY = {
'voltage': { unit: 'V', icon: 'bi-battery-half' },
'current': { unit: 'A', icon: 'bi-lightning-charge' },
'power': { unit: 'W', icon: 'bi-plug' },
'energy': { unit: 'kWh', icon: 'bi-plug-fill' },
'temperature': { unit: '°C', icon: 'bi-thermometer-half' },
'humidity': { unit: '%', icon: 'bi-droplet' },
'percentage': { unit: '%', icon: 'bi-percent' },
'barometer': { unit: 'hPa', icon: 'bi-speedometer2' },
'illuminance': { unit: 'lx', icon: 'bi-sun' },
'altitude': { unit: 'm', icon: 'bi-arrow-up-right' },
'distance': { unit: 'm', icon: 'bi-rulers' },
'frequency': { unit: 'Hz', icon: 'bi-soundwave' },
'concentration': { unit: 'ppm', icon: 'bi-cloud-haze' },
'load': { unit: 'kg', icon: 'bi-box' },
'direction': { unit: '°', icon: 'bi-compass' },
'gps': { unit: '', icon: 'bi-geo-alt' },
'digital input': { unit: '', icon: 'bi-toggle-on' },
'digital output': { unit: '', icon: 'bi-toggle-off' },
'analog input': { unit: '', icon: 'bi-sliders' },
'analog output': { unit: '', icon: 'bi-sliders' },
'generic sensor': { unit: '', icon: 'bi-cpu' },
'presence': { unit: '', icon: 'bi-person-check' },
'switch': { unit: '', icon: 'bi-toggle2-on' },
'time': { unit: '', icon: 'bi-clock' },
};
function fmtLppValue(type, value) {
if (value == null) return '—';
if (type === 'gps' && typeof value === 'object') {
// lib returns {latitude, longitude, altitude}; tolerate array form too
const lat = value.latitude ?? value[0];
const lon = value.longitude ?? value[1];
const alt = value.altitude ?? value[2];
if (lat == null || lon == null) return esc(JSON.stringify(value));
let s = `${Number(lat).toFixed(5)}, ${Number(lon).toFixed(5)}`;
if (alt != null) s += ` (${Number(alt).toFixed(0)} m)`;
return esc(s);
}
if (Array.isArray(value)) return esc(value.join(', '));
if (typeof value === 'object') return esc(JSON.stringify(value));
if (typeof value === 'number' && !Number.isInteger(value)) {
// Trim float noise, keep up to 3 decimals
return esc(String(Math.round(value * 1000) / 1000));
}
return esc(String(value));
}
let _telemetryUpdatedAt = null;
let _telemetryTimer = null;
function renderTelemetryPane(body) {
body.innerHTML = `
<div class="d-flex align-items-center mb-2">
<span class="text-muted small flex-grow-1" id="telemetryUpdated"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="telemetryRefreshBtn" title="Refresh telemetry">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
<div id="telemetryContainer"></div>
`;
body.querySelector('#telemetryRefreshBtn').addEventListener('click', loadTelemetry);
loadTelemetry();
}
function setTelemetryUpdatedLabel() {
if (_telemetryTimer) { clearInterval(_telemetryTimer); _telemetryTimer = null; }
if (!_telemetryUpdatedAt) return;
const tick = () => {
const label = document.getElementById('telemetryUpdated');
if (!label) { clearInterval(_telemetryTimer); _telemetryTimer = null; return; }
const secs = Math.floor((Date.now() - _telemetryUpdatedAt) / 1000);
if (secs < 2) label.textContent = 'Updated just now';
else if (secs < 60) label.textContent = `Updated ${secs}s ago`;
else label.textContent = `Updated ${fmtDuration(secs)} ago`;
};
tick();
_telemetryTimer = setInterval(tick, 5000);
}
async function loadTelemetry() {
const container = document.getElementById('telemetryContainer');
const btn = document.getElementById('telemetryRefreshBtn');
if (!container) return;
if (btn) btn.disabled = true;
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">Requesting telemetry from the repeater<br>
<span class="small">Multi-hop paths can take up to a minute.</span></p>
</div>
`;
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/telemetry`);
data = await resp.json();
} catch (e) {
data = { success: false, error: 'Request failed' };
}
if (btn) btn.disabled = false;
if (!data || !data.success) {
container.innerHTML = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get telemetry')}</p>
<button type="button" class="btn btn-sm btn-primary" id="telemetryRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
</div>
`;
const retry = document.getElementById('telemetryRetryBtn');
if (retry) retry.addEventListener('click', loadTelemetry);
return;
}
renderTelemetryCards(container, data.lpp || []);
_telemetryUpdatedAt = Date.now();
setTelemetryUpdatedLabel();
}
function renderTelemetryCards(container, lpp) {
if (!lpp.length) {
container.innerHTML = '<div class="text-muted text-center py-4">No telemetry data reported.</div>';
return;
}
// Group entries by channel, keep entry order inside a channel
const byChannel = new Map();
lpp.forEach(entry => {
const ch = entry.channel ?? 0;
if (!byChannel.has(ch)) byChannel.set(ch, []);
byChannel.get(ch).push(entry);
});
const channels = [...byChannel.keys()].sort((a, b) => a - b);
let html = '<div class="row g-3">';
channels.forEach(ch => {
const rows = byChannel.get(ch).map(entry => {
const disp = LPP_DISPLAY[entry.type] || { unit: '', icon: 'bi-activity' };
const label = esc(entry.type.charAt(0).toUpperCase() + entry.type.slice(1));
const value = fmtLppValue(entry.type, entry.value);
return `
<tr>
<td class="text-muted"><i class="bi ${disp.icon} me-1"></i>${label}</td>
<td class="text-end fw-medium">${value}${disp.unit ? ' ' + disp.unit : ''}</td>
</tr>
`;
}).join('');
// Channel 1 carries the repeater's own vitals (battery, MCU temp)
const chLabel = ch === 1 ? `Channel ${ch} <span class="text-muted fw-normal">· device</span>` : `Channel ${ch}`;
html += `
<div class="col-12 col-md-6 col-xl-4">
<div class="border rounded p-2 h-100">
<div class="small fw-bold mb-1">${chLabel}</div>
<table class="table table-sm mb-0"><tbody>${rows}</tbody></table>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
async function loadClock() {
const el = document.getElementById('statusClock');
if (!el) return;