diff --git a/app/routes/api.py b/app/routes/api.py index 09e9c3d..ca6c435 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -6073,6 +6073,58 @@ def repeater_settings_post(public_key): return jsonify({'success': False, 'error': str(e)}), 500 +# Action key → CLI command. `advert` alone floods the whole mesh; +# `advert.zerohop` reaches direct neighbours only. `reboot` never +# replies: the firmware restarts immediately without building one. +_REPEATER_ACTIONS = { + 'zerohop_advert': {'cmd': 'advert.zerohop'}, + 'flood_advert': {'cmd': 'advert'}, + 'clock_sync': {'cmd': 'clock sync'}, + 'reboot': {'cmd': 'reboot', 'no_reply': True}, +} + + +@api_bp.route('/repeaters//action', methods=['POST']) +def repeater_action(public_key): + """Run a one-shot action on a repeater (adverts, clock sync, reboot). + + Body: {'action': key}. Replies are surfaced verbatim with an `ok` + flag (reply starts with OK). For `reboot`, a clean send followed by + silence is reported as success — the firmware never replies to it. + """ + 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 + auth_error = _require_repeater_admin(dm, pk) + if auth_error: + return auth_error + data = request.get_json(silent=True) or {} + action = data.get('action') or '' + spec = _REPEATER_ACTIONS.get(action) + if not spec: + return jsonify({'success': False, 'error': f'Unknown action: {action}'}), 400 + try: + timeout = 15.0 if spec.get('no_reply') else 45.0 + result = dm.repeater_cmd_wait(pk, spec['cmd'], timeout=timeout) + if result.get('success'): + reply = (result.get('reply') or '').strip() + return jsonify({'success': True, 'reply': reply, + 'ok': reply.lower().startswith('ok'), + 'elapsed_ms': result.get('elapsed_ms')}), 200 + if spec.get('no_reply') and result.get('timeout'): + return jsonify({'success': True, 'ok': True, 'no_reply': True, + 'reply': 'Reboot command sent — the repeater should be ' + 'restarting (no reply is expected)'}), 200 + return jsonify({'success': False, + 'error': result.get('error', 'Action failed')}), _repeater_result_status(result) + except Exception as e: + logger.error(f"Error running repeater action: {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 b30236a..7288e60 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -249,6 +249,10 @@ function openToolPane(tool) { renderSettingsPane(body); return; } + if (tool.key === 'actions') { + renderActionsPane(body); + return; + } body.innerHTML = `
@@ -1463,6 +1467,121 @@ async function syncSavedPassword(newPassword) { } } +// ================================================================ +// Actions tool +// ================================================================ + +// Keys mirror _REPEATER_ACTIONS in api.py. +const REPEATER_ACTIONS = [ + { key: 'zerohop_advert', icon: 'bi-megaphone', iconClass: 'text-primary', + title: 'Send zero-hop advert', btn: 'Send', btnClass: 'btn-outline-primary', + desc: 'Announce this repeater to its direct neighbours only.' }, + { key: 'flood_advert', icon: 'bi-broadcast-pin', iconClass: 'text-warning', + title: 'Send flood advert', btn: 'Send', btnClass: 'btn-outline-warning', + desc: 'Not recommended — the advert is flooded across the whole mesh (high network load).' }, + { key: 'clock_sync', icon: 'bi-clock-history', iconClass: 'text-primary', + title: 'Sync clock', btn: 'Sync', btnClass: 'btn-outline-primary', + desc: "Set the repeater's clock from this device's current time. The firmware refuses to move the clock backwards." }, +]; + +let _actionPending = false; + +function actionRowHtml(a) { + return ` +
+ +
+
${esc(a.title)}
+
${esc(a.desc)}
+
+
+ +
`; +} + +function renderActionsPane(body) { + _actionPending = false; + body.innerHTML = ` +
+
+ ${REPEATER_ACTIONS.map(actionRowHtml).join('
')} +
+
+
+
+ Danger zone +
+
+ ${actionRowHtml({ + key: 'reboot', icon: 'bi-arrow-clockwise', iconClass: 'text-danger', + title: 'Reboot repeater', btn: 'Reboot', btnClass: 'btn-danger', + desc: 'The repeater drops off the mesh for a few seconds while it restarts.', + })} +
+ Erase file system is not available over the mesh — + the firmware only accepts it on the USB serial console (use the MeshCore flasher instead). +
+
+
+ `; + + body.querySelectorAll('.action-row .action-btn').forEach(btn => { + const row = btn.closest('.action-row'); + btn.addEventListener('click', () => runRepeaterAction(row.dataset.action)); + }); +} + +async function runRepeaterAction(action) { + if (_actionPending) return; + if (action === 'reboot' && !window.confirm( + `Reboot ${(_repeater && _repeater.name) || 'this repeater'}?\n\n` + + 'It will drop off the mesh for a few seconds. The firmware does not reply to this command.')) { + return; + } + + const row = document.querySelector(`.action-row[data-action="${action}"]`); + const btn = row ? row.querySelector('.action-btn') : null; + const resultEl = row ? row.querySelector('.action-result') : null; + _actionPending = true; + document.querySelectorAll('.action-row .action-btn').forEach(b => { b.disabled = true; }); + const btnLabel = btn ? btn.innerHTML : ''; + if (btn) btn.innerHTML = ''; + if (resultEl) resultEl.classList.add('d-none'); + + let data = null; + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + data = await resp.json(); + } catch (e) { + data = { success: false, error: 'Request failed' }; + } + + _actionPending = false; + document.querySelectorAll('.action-row .action-btn').forEach(b => { b.disabled = false; }); + if (btn) btn.innerHTML = btnLabel; + + if (resultEl) { + resultEl.classList.remove('d-none', 'text-success', 'text-danger'); + if (data && data.success) { + resultEl.classList.add(data.ok ? 'text-success' : 'text-danger'); + const elapsed = data.elapsed_ms != null ? ` (${(data.elapsed_ms / 1000).toFixed(1)} s)` : ''; + resultEl.textContent = (data.reply || 'Done') + elapsed; + } else { + resultEl.classList.add('text-danger'); + resultEl.textContent = (data && data.error) || 'Action failed'; + } + } + if (!data || !data.success) { + showNotification((data && data.error) || 'Action failed', 'danger'); + } +} + // ================================================================ // Login flow // ================================================================