feat(repeaters): CLI tool (stage 6)

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/<pk>/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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-18 13:45:51 +02:00
parent 2b6d82a11c
commit 3e80ea0c85
4 changed files with 292 additions and 0 deletions
+60
View File
@@ -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:
+36
View File
@@ -5889,6 +5889,42 @@ def repeater_telemetry(public_key):
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/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/<public_key>/neighbours', methods=['GET'])
def repeater_neighbours(public_key):
"""Zero-hop neighbours of a repeater, enriched with contact names/coords.
+145
View File
@@ -241,6 +241,10 @@ function openToolPane(tool) {
renderNeighborsPane(body);
return;
}
if (tool.key === 'cli') {
renderCliPane(body);
return;
}
body.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
@@ -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 =>
`<button type="button" class="btn btn-outline-secondary btn-sm cli-chip font-monospace" data-cmd="${esc(c)}">${esc(c)}</button>`
).join('');
body.innerHTML = `
<div class="cli-terminal" id="cliOutput">
<div class="cli-line meta">Commands go to ${esc(_repeater ? _repeater.name : 'the repeater')}. One command at a time replies travel over the mesh.</div>
</div>
<div class="d-flex flex-wrap gap-1 mt-2">${chips}</div>
<form id="cliForm" class="d-flex gap-2 mt-2">
<input type="text" id="cliInput" class="form-control form-control-sm font-monospace"
placeholder="Enter command (e.g. get name)" autocomplete="off"
autocapitalize="off" spellcheck="false">
<button type="submit" class="btn btn-sm btn-success" id="cliSendBtn">
<i class="bi bi-send"></i>
</button>
</form>
`;
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
// ================================================================
+51
View File
@@ -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);