diff --git a/app/device_manager.py b/app/device_manager.py index 4432348..94271f4 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -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: diff --git a/app/routes/api.py b/app/routes/api.py index bafb72a..aa13581 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -5866,6 +5866,29 @@ def repeater_status(public_key): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/repeaters//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//clock', methods=['GET']) def repeater_clock(public_key): """Current clock of a repeater (unix seconds + formatted).""" diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 7702891..c20af04 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -233,6 +233,10 @@ function openToolPane(tool) { renderStatusPane(body); return; } + if (tool.key === 'telemetry') { + renderTelemetryPane(body); + return; + } body.innerHTML = `
@@ -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 = ` +
+ + +
+
+ `; + 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 = ` +
+
+

Requesting telemetry from the repeater…
+ Multi-hop paths can take up to a minute.

+
+ `; + + 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 = ` +
+ +

${esc((data && data.error) || 'Failed to get telemetry')}

+ +
+ `; + 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 = '
No telemetry data reported.
'; + 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 = '
'; + 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 ` + + ${label} + ${value}${disp.unit ? ' ' + disp.unit : ''} + + `; + }).join(''); + // Channel 1 carries the repeater's own vitals (battery, MCU temp) + const chLabel = ch === 1 ? `Channel ${ch} · device` : `Channel ${ch}`; + html += ` +
+
+
${chLabel}
+ ${rows}
+
+
+ `; + }); + html += '
'; + container.innerHTML = html; +} + async function loadClock() { const el = document.getElementById('statusClock'); if (!el) return;