From 3e80ea0c859379138c8e3aa4e122d7f6fe882a6a Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sat, 18 Jul 2026 13:45:51 +0200 Subject: [PATCH] feat(repeaters): CLI tool (stage 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote text console for the managed repeater. Core mechanism: repeater_cmd_wait() sends the command and synchronously waits for the reply — CLI replies arrive as CONTACT_MSG_RECV txt_type=1 with no protocol-level correlation, so correlation = repeater lock (single command in flight) + sender-prefix match. A single-slot waiter is checked at the top of _on_dm_received: matched CLI replies are consumed there and never stored as chat DMs; unmatched ones (e.g. console fire-and-forget cmd) keep the legacy DM behavior. Wait time derives from the device-suggested timeout (10-45 s clamp). POST /api/repeaters//cli is admin-gated (403 for guest sessions: firmware silently drops guest text commands, which would look like a timeout). Pane: dark terminal styled after the Console module, quick- command chips, Enter to send, per-repeater arrow-key history in localStorage, elapsed-time line, inline timeout errors (lost replies happen over radio — a manual retry typically succeeds). Settings (stage 7) and Actions (stage 8) will reuse repeater_cmd_wait. Co-Authored-By: Claude Fable 5 --- app/device_manager.py | 60 ++++++++++++ app/routes/api.py | 36 +++++++ app/static/js/repeater-manage.js | 145 +++++++++++++++++++++++++++++ app/templates/repeater-manage.html | 51 ++++++++++ 4 files changed, 292 insertions(+) diff --git a/app/device_manager.py b/app/device_manager.py index 94271f4..9ac5286 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -225,6 +225,9 @@ class DeviceManager: # concurrent repeater operations would corrupt each other's matching. self._repeater_lock = threading.Lock() self._repeater_sessions = {} # {public_key: {is_admin, permissions, logged_in_at}} + # Single-slot waiter for a repeater CLI text reply (guarded by + # _repeater_lock: only one remote command is ever in flight). + self._cli_waiter = None # {'prefix': 12-hex, 'event': threading.Event, 'reply': str|None} # In-place reconnect (heals degraded long-lived TCP without container restart) self._reconnect_lock = threading.Lock() # prevents concurrent force_reconnect calls @@ -844,6 +847,20 @@ class DeviceManager: """Handle incoming direct message.""" try: data = getattr(event, 'payload', {}) + + # Repeater CLI replies (txt_type=1 CLI_DATA) awaited by + # repeater_cmd_wait() are consumed here and never stored as + # chat DMs. Unmatched CLI replies (e.g. console fire-and-forget + # `cmd`) keep the legacy behavior and land in the DM panel. + if data.get('txt_type') == 1: + waiter = self._cli_waiter + prefix = (data.get('pubkey_prefix') or '').lower() + if waiter and prefix and waiter['prefix'] == prefix: + waiter['reply'] = data.get('text', '') + waiter['event'].set() + logger.debug(f"CLI reply consumed from {prefix}") + return + ts = data.get('timestamp', int(time.time())) content = data.get('text', '') sender_key = data.get('public_key', data.get('pubkey_prefix', '')) @@ -3183,6 +3200,49 @@ class DeviceManager: finally: self._repeater_lock.release() + def repeater_cmd_wait(self, name_or_key: str, cmd: str, timeout: float = 45.0) -> Dict: + """Send a CLI command to a repeater and wait for its text reply. + + The reply arrives asynchronously as a CONTACT_MSG_RECV with + txt_type=1 and carries no protocol-level correlation, so we rely + on serialization (repeater lock = single command in flight) plus + a sender-prefix match in _on_dm_received. + """ + 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}"} + coro = self.mc.commands.send_cmd(contact, cmd) + if not self._repeater_lock.acquire(timeout=180): + coro.close() + return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True} + try: + prefix = (contact.get('public_key') or '')[:12].lower() + waiter = {'prefix': prefix, 'event': threading.Event(), 'reply': None} + self._cli_waiter = waiter + started = time.time() + + res = self.execute(coro, timeout=15) + wait_s = 30.0 + payload = getattr(res, 'payload', None) or {} + if isinstance(payload, dict) and 'suggested_timeout' in payload: + wait_s = payload['suggested_timeout'] / 800 + wait_s = min(max(wait_s, 10.0), float(timeout)) + + if waiter['event'].wait(timeout=wait_s): + elapsed_ms = int((time.time() - started) * 1000) + return {'success': True, 'reply': waiter['reply'] or '', 'elapsed_ms': elapsed_ms} + return {'success': False, + 'error': f'No reply from repeater within {wait_s:.0f}s', + 'timeout': True} + except Exception as e: + logger.error(f"repeater_cmd_wait failed: {e}") + return {'success': False, 'error': str(e)} + finally: + self._cli_waiter = None + self._repeater_lock.release() + def repeater_req_status(self, name_or_key: str) -> Dict: """Request status from a repeater.""" if not self.is_connected: diff --git a/app/routes/api.py b/app/routes/api.py index f23dfec..07906bb 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -5889,6 +5889,42 @@ def repeater_telemetry(public_key): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/repeaters//cli', methods=['POST']) +def repeater_cli(public_key): + """Send a CLI text command to a repeater and return its reply. + + Admin-only: the firmware silently ignores text commands from + non-admin clients (which would surface as a pointless timeout), + so guest sessions are rejected up front. + """ + 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 + session = dm.get_repeater_session(pk) + if not session: + return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401 + if not session.get('is_admin'): + return jsonify({'success': False, 'error': 'Admin login required'}), 403 + data = request.get_json(silent=True) or {} + command = (data.get('command') or '').strip() + if not command: + return jsonify({'success': False, 'error': 'Missing command'}), 400 + try: + result = dm.repeater_cmd_wait(pk, command) + if result.get('success'): + return jsonify({'success': True, + 'output': result.get('reply', ''), + 'elapsed_ms': result.get('elapsed_ms')}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'Command failed')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error running repeater CLI command: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @api_bp.route('/repeaters//neighbours', methods=['GET']) def repeater_neighbours(public_key): """Zero-hop neighbours of a repeater, enriched with contact names/coords. diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 46c642f..5d1dece 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -241,6 +241,10 @@ function openToolPane(tool) { renderNeighborsPane(body); return; } + if (tool.key === 'cli') { + renderCliPane(body); + return; + } body.innerHTML = `
@@ -573,6 +577,147 @@ function renderTelemetryCards(container, lpp) { container.innerHTML = html; } +// ================================================================ +// CLI tool +// ================================================================ + +const CLI_QUICK_COMMANDS = ['get name', 'get radio', 'get tx', 'ver', 'clock', 'neighbors']; +let _cliHistory = []; +let _cliHistoryIndex = -1; +let _cliPending = false; + +function cliHistoryKey() { + return `mc-webui-rpt-cli-history-${_pubkey}`; +} + +function loadCliHistory() { + try { + _cliHistory = JSON.parse(localStorage.getItem(cliHistoryKey()) || '[]'); + } catch (e) { + _cliHistory = []; + } + _cliHistoryIndex = -1; +} + +function pushCliHistory(cmd) { + _cliHistory = _cliHistory.filter(c => c !== cmd); + _cliHistory.push(cmd); + if (_cliHistory.length > 50) _cliHistory = _cliHistory.slice(-50); + localStorage.setItem(cliHistoryKey(), JSON.stringify(_cliHistory)); + _cliHistoryIndex = -1; +} + +function renderCliPane(body) { + loadCliHistory(); + _cliPending = false; + const chips = CLI_QUICK_COMMANDS.map(c => + `` + ).join(''); + body.innerHTML = ` +
+
Commands go to ${esc(_repeater ? _repeater.name : 'the repeater')}. One command at a time — replies travel over the mesh.
+
+
${chips}
+
+ + +
+ `; + + const form = body.querySelector('#cliForm'); + const input = body.querySelector('#cliInput'); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + sendCliCommand(input.value); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (!_cliHistory.length) return; + if (_cliHistoryIndex === -1) _cliHistoryIndex = _cliHistory.length; + if (_cliHistoryIndex > 0) _cliHistoryIndex--; + input.value = _cliHistory[_cliHistoryIndex] || ''; + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (_cliHistoryIndex === -1) return; + _cliHistoryIndex++; + if (_cliHistoryIndex >= _cliHistory.length) { + _cliHistoryIndex = -1; + input.value = ''; + } else { + input.value = _cliHistory[_cliHistoryIndex] || ''; + } + } + }); + + body.querySelectorAll('.cli-chip').forEach(chip => { + chip.addEventListener('click', () => { + input.value = chip.dataset.cmd; + input.focus(); + }); + }); + + setTimeout(() => input.focus(), 200); +} + +function cliAppend(cls, text) { + const out = document.getElementById('cliOutput'); + if (!out) return null; + const line = document.createElement('div'); + line.className = `cli-line ${cls}`; + line.textContent = text; + out.appendChild(line); + out.scrollTop = out.scrollHeight; + return line; +} + +async function sendCliCommand(raw) { + const command = (raw || '').trim(); + const input = document.getElementById('cliInput'); + const sendBtn = document.getElementById('cliSendBtn'); + if (!command || _cliPending) return; + + _cliPending = true; + if (input) { input.value = ''; input.disabled = true; } + if (sendBtn) sendBtn.disabled = true; + pushCliHistory(command); + + cliAppend('cmd', command); + const pendingLine = cliAppend('meta cli-pending', 'Waiting for reply…'); + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/cli`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command }) + }); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + + if (pendingLine) pendingLine.remove(); + if (data && data.success) { + cliAppend('reply', data.output || '(empty reply)'); + if (data.elapsed_ms != null) { + cliAppend('meta', `(${(data.elapsed_ms / 1000).toFixed(1)} s)`); + } + } else { + cliAppend('error', (data && data.error) || 'Command failed'); + } + + _cliPending = false; + if (input) { input.disabled = false; input.focus(); } + if (sendBtn) sendBtn.disabled = false; +} + // ================================================================ // Neighbors tool // ================================================================ diff --git a/app/templates/repeater-manage.html b/app/templates/repeater-manage.html index 9f4ba1c..e6c0e39 100644 --- a/app/templates/repeater-manage.html +++ b/app/templates/repeater-manage.html @@ -171,6 +171,57 @@ padding: 1rem; } + /* CLI terminal (dark regardless of theme, like the Console module) */ + .cli-terminal { + background-color: #1a1a2e; + color: #e0e0e0; + font-family: 'Courier New', Consolas, monospace; + font-size: 0.85rem; + border-radius: 0.375rem; + padding: 0.75rem; + height: 340px; + overflow-y: auto; + } + + .cli-line { + margin-bottom: 0.4rem; + white-space: pre-wrap; + word-break: break-word; + } + + .cli-line.cmd { color: #00ff88; } + .cli-line.cmd::before { content: '> '; color: #888; } + + .cli-line.reply { + background-color: #16213e; + padding: 0.4rem 0.5rem; + border-radius: 0.25rem; + border-left: 3px solid #0f3460; + } + + .cli-line.error { color: #ff6b6b; } + + .cli-line.meta { + color: #4ecdc4; + font-style: italic; + font-size: 0.75rem; + } + + .cli-pending::after { + content: ''; + display: inline-block; + width: 11px; + height: 11px; + border: 2px solid #4ecdc4; + border-top-color: transparent; + border-radius: 50%; + animation: cli-spin 1s linear infinite; + margin-left: 0.4rem; + vertical-align: middle; + } + + @keyframes cli-spin { to { transform: rotate(360deg); } } + /* SNR labels on neighbor map connection lines */ .nb-snr-tooltip { background: rgba(13, 110, 253, 0.92);