From 707fd2042679d6b1aff2eee960ea223b45ee711b Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sat, 18 Jul 2026 08:16:58 +0200 Subject: [PATCH] feat(repeaters): Status tool (stage 3) First management tool. The Status tile auto-fetches on open and shows a compact three-section table (not MO's oversized tiles): - System: battery %/V (linear 3.3-4.2 V estimate), uptime, repeater clock, queue length, debug/error events - Radio: last RSSI/SNR, noise floor, TX/RX airtime - Packets: sent + received (flood/direct split), duplicates, RX errors, channel utilization computed client-side as (tx_air+rx_air)/uptime (matches the firmware's own 10.09% reading) Refresh button + "updated Ns ago"; errors render inline with retry. Backend: GET /api/repeaters//status and /clock, both gated on an existing login session (401 need_login) to fail fast instead of a 2-min timeout. Clock loads as a follow-up request so the table appears immediately, then the clock row fills in. Co-Authored-By: Claude Fable 5 --- app/routes/api.py | 60 ++++++++++ app/static/js/repeater-manage.js | 183 +++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/app/routes/api.py b/app/routes/api.py index 712694c..bafb72a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -5831,6 +5831,66 @@ def logout_my_repeater(public_key): return jsonify({'success': False, 'error': str(e)}), 500 +def _require_repeater_login(dm, pk): + """Return (None) if a session exists, else an (json, status) error tuple. + + A remote request only succeeds if the repeater has us in its ACL, which + requires a prior login. Failing fast here avoids a pointless ~2 min + timeout when the panel is opened without a live session. + """ + if not dm.get_repeater_session(pk): + return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401 + return None + + +@api_bp.route('/repeaters//status', methods=['GET']) +def repeater_status(public_key): + """Binary status request to a repeater (battery, radio and packet stats).""" + 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_status(pk) + if result.get('success'): + return jsonify({'success': True, 'data': result['data']}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'No status response')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error getting repeater status: {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).""" + 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_clock(pk) + if not result.get('success'): + return jsonify({'success': False, + 'error': result.get('error', 'No clock response')}), _repeater_result_status(result) + hex_data = (result['data'] or {}).get('data', '') + timestamp = int.from_bytes(bytes.fromhex(hex_data[0:8]), byteorder='little', signed=False) + return jsonify({'success': True, 'timestamp': timestamp}), 200 + except Exception as e: + logger.error(f"Error getting repeater clock: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/repeaters', methods=['POST']) def add_my_repeater(): """Add a device repeater contact to the My Repeaters list.""" diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 694eecf..7702891 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -229,6 +229,10 @@ function openToolPane(tool) { document.getElementById('paneTitle').textContent = tool.title; const body = document.getElementById('paneBody'); + if (tool.key === 'status') { + renderStatusPane(body); + return; + } body.innerHTML = `
@@ -237,6 +241,185 @@ function openToolPane(tool) { `; } +// ================================================================ +// Formatting helpers +// ================================================================ + +function fmtDuration(seconds) { + if (seconds == null || isNaN(seconds)) return '—'; + seconds = Math.floor(seconds); + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const parts = []; + if (d) parts.push(`${d}d`); + if (h || d) parts.push(`${h}h`); + parts.push(`${m}m`); + return parts.join(' '); +} + +function fmtInt(n) { + if (n == null || isNaN(n)) return '—'; + return Number(n).toLocaleString('en-US'); +} + +function batteryPercent(mv) { + if (mv == null || isNaN(mv)) return null; + // Simple linear LiPo estimate over 3.3–4.2 V. + const pct = ((mv / 1000) - 3.3) / (4.2 - 3.3) * 100; + return Math.max(0, Math.min(100, Math.round(pct))); +} + +// ================================================================ +// Status tool +// ================================================================ + +let _statusUpdatedAt = null; +let _statusTimer = null; + +function renderStatusPane(body) { + body.innerHTML = ` +
+ + +
+
+ `; + body.querySelector('#statusRefreshBtn').addEventListener('click', loadStatus); + loadStatus(); +} + +function setStatusUpdatedLabel() { + const el = document.getElementById('statusUpdated'); + if (!el) return; + if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; } + if (!_statusUpdatedAt) { el.textContent = ''; return; } + const tick = () => { + const label = document.getElementById('statusUpdated'); + if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; } + const secs = Math.floor((Date.now() - _statusUpdatedAt) / 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(); + _statusTimer = setInterval(tick, 5000); +} + +async function loadStatus() { + const container = document.getElementById('statusContainer'); + const btn = document.getElementById('statusRefreshBtn'); + if (!container) return; + if (btn) btn.disabled = true; + container.innerHTML = ` +
+
+

Requesting status from the repeater…

+
+ `; + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/status`); + 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 status')}

+ +
+ `; + const retry = document.getElementById('statusRetryBtn'); + if (retry) retry.addEventListener('click', loadStatus); + return; + } + + renderStatusTable(container, data.data); + _statusUpdatedAt = Date.now(); + setStatusUpdatedLabel(); + loadClock(); +} + +function statusSection(title, rows) { + const body = rows.map(([label, value]) => + `${esc(label)}${value}` + ).join(''); + return ` +
${esc(title)}
+ ${body}
+ `; +} + +function renderStatusTable(container, s) { + const bat = s.bat; + const pct = batteryPercent(bat); + const batStr = bat != null + ? `${pct != null ? pct + '% / ' : ''}${(bat / 1000).toFixed(2)} V` + : '—'; + const util = (s.airtime != null && s.rx_airtime != null && s.uptime) + ? (((s.airtime + s.rx_airtime) / s.uptime) * 100).toFixed(2) + '%' + : '—'; + + const system = statusSection('System Information', [ + ['Battery', batStr], + ['Uptime', fmtDuration(s.uptime)], + ['Clock', ''], + ['Queue length', fmtInt(s.tx_queue_len)], + ['Debug / error events', fmtInt(s.full_evts)], + ]); + const radio = statusSection('Radio Statistics', [ + ['Last RSSI', s.last_rssi != null ? `${s.last_rssi} dBm` : '—'], + ['Last SNR', s.last_snr != null ? `${s.last_snr} dB` : '—'], + ['Noise floor', s.noise_floor != null ? `${s.noise_floor} dBm` : '—'], + ['TX airtime', fmtDuration(s.airtime)], + ['RX airtime', fmtDuration(s.rx_airtime)], + ]); + const packets = statusSection('Packet Statistics', [ + ['Sent', `${fmtInt(s.nb_sent)} (flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})`], + ['Received', `${fmtInt(s.nb_recv)} (flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})`], + ['Duplicates', `flood ${fmtInt(s.flood_dups)} · direct ${fmtInt(s.direct_dups)}`], + ...(s.recv_errors != null ? [['RX errors', fmtInt(s.recv_errors)]] : []), + ['Channel utilization', util], + ]); + + container.innerHTML = system + radio + packets; +} + +async function loadClock() { + const el = document.getElementById('statusClock'); + if (!el) return; + el.innerHTML = ''; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/clock`); + const data = await resp.json(); + if (data.success && data.timestamp) { + const d = new Date(data.timestamp * 1000); + el.classList.remove('text-muted'); + el.textContent = d.toLocaleString(); + } else { + el.className = ''; + el.innerHTML = ``; + const b = document.getElementById('clockRetryBtn'); + if (b) b.addEventListener('click', loadClock); + } + } catch (e) { + el.className = ''; + el.innerHTML = ``; + const b = document.getElementById('clockRetryBtn'); + if (b) b.addEventListener('click', loadClock); + } +} + // ================================================================ // Login flow // ================================================================