mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-10 02:32:56 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
// ================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user