diff --git a/app/routes/api.py b/app/routes/api.py index 1793f16..a1cf85e 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -6781,6 +6781,250 @@ def repeater_settings_post(public_key): return jsonify({'success': False, 'error': str(e)}), 500 + +# ============================================================================= +# Repeater region management (Settings → Regions) +# ============================================================================= +# +# A repeater decides which flood-scoped packets it forwards from its own region +# map, edited over text CLI (`region ...`, helpers/CommonCLI.cpp). Two firmware +# details shape everything below. +# +# 1. Edits are RAM-only. `put`, `remove`, `allowf` and `denyf` mutate the map in +# memory and are lost on reboot until `region save` writes it to flash, so +# the UI stages edits behind an explicit Save. `region default` is the one +# exception: the firmware calls saveRegions() itself, so it persists alone. +# +# 2. The bare `region` reply is an indented tree capped at 160 bytes by the +# firmware (`exportTo(reply, 160)`), one line per region: +# +# *^ F <- wildcard root, `^` = home region, ` F` = flood allowed +# pl F <- one space of indent per level of nesting +# kra <- no ` F` suffix means flood denied +# +# With enough regions that tree does not fit and the firmware truncates it +# silently, so the parser reports truncation rather than pretending the tail +# was never there. +# +# The binary AnonReqType.REGIONS request (device_manager.repeater_req_regions) +# is deliberately not used here: it answers with exportNamesTo(), a flat +# comma-separated list of *allowed* names only — no hierarchy, no deny flags and +# no home marker, which is most of what this screen shows. + +_REGION_TREE_MAX = 160 # firmware's exportTo() buffer; see note above + +# Actions that only touch the in-RAM map, so the UI must follow them with +# `save`. `set_default` and `clear_default` are absent: the firmware persists +# those itself. +_REGION_DIRTYING = frozenset({'add', 'remove', 'allow_flood', 'deny_flood', 'set_home'}) + + +def _parse_region_line(line): + """One line of the `region` tree → (depth, name, flood_allowed, is_home). + + Returns None for a blank line. Suffixes are stripped right-to-left because a + region may legitimately be named `F` or end in `^`; the firmware has the + same ambiguity and resolves it the same way. + """ + stripped = line.lstrip(' ') + if not stripped.strip(): + return None + depth = len(line) - len(stripped) + body = stripped.strip() + flood_allowed = body.endswith(' F') + if flood_allowed: + body = body[:-2].rstrip() + is_home = body.endswith('^') + if is_home: + body = body[:-1] + if not body: + return None + # Stored names may carry the `#` that marks an auto-derived scope key. The + # tree export strips it but `region get` does not, so strip it either way + # and a name read here is the same string the user typed. + return depth, body.lstrip('#'), flood_allowed, is_home + + +def _parse_region_tree(raw): + """Parse a bare `region` reply into a flat list of entries with parents. + + Entries keep tree order and carry `depth` plus `parent` (the enclosing + region's name, None at the root) so the UI can indent without re-deriving + the hierarchy. + """ + entries = [] + stack = [] # (depth, name) of the currently open ancestors + for line in (raw or '').split('\n'): + parsed = _parse_region_line(line) + if not parsed: + continue + depth, name, flood_allowed, is_home = parsed + while stack and stack[-1][0] >= depth: + stack.pop() + entries.append({ + 'name': name, + 'depth': depth, + 'parent': stack[-1][1] if stack else None, + 'flood_allowed': flood_allowed, + 'is_home': is_home, + 'is_root': name == '*', + }) + stack.append((depth, name)) + return entries + + +def _region_tree_truncated(raw): + """True when the firmware's 160-byte export buffer clipped the tree. + + A complete export always ends with the newline printChildRegions() writes + after the last region, so a full-length reply without one lost its tail. + """ + text = raw or '' + return len(text) >= _REGION_TREE_MAX - 2 and not text.endswith('\n') + + +@api_bp.route('/repeaters//regions', methods=['GET']) +def repeater_regions_get(public_key): + """Read a repeater's region map: the tree plus its default flood scope. + + Two mesh round-trips (`region`, `region default`), reported independently so + a lost reply for one still returns the other. Like every settings read it + happens only when asked for. Admin-gated, as all text CLI traffic is. + """ + 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 + try: + tree = dm.repeater_cmd_wait(pk, 'region', timeout=_SETTINGS_FIELD_TIMEOUT) + if not tree.get('success'): + return jsonify({'success': False, + 'error': tree.get('error', 'Could not read regions')}), \ + _repeater_result_status(tree) + raw = tree.get('reply') or '' + if raw.strip().lower().startswith('err'): + return jsonify({'success': False, 'error': raw.strip()}), 502 + + payload = { + 'success': True, + 'entries': _parse_region_tree(raw), + 'truncated': _region_tree_truncated(raw), + 'raw': raw, + } + + # The default scope is a separate command, and a failure there is not + # fatal to the read: it is reported alongside the tree, not over it. + default = dm.repeater_cmd_wait(pk, 'region default', + timeout=_SETTINGS_FIELD_TIMEOUT) + if default.get('success'): + reply = (default.get('reply') or '').strip() + marker = 'default scope is' # ` default scope is ` / `` + if marker in reply: + name = reply.split(marker, 1)[1].strip() + payload['default_scope'] = None if name in ('', '') else name.lstrip('#') + else: + payload['default_error'] = reply or 'Empty reply' + else: + payload['default_error'] = default.get('error') or 'Request failed' + return jsonify(payload), 200 + except Exception as e: + logger.error(f"Error reading repeater regions: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _region_command(action, name, parent): + """Region action → (CLI command text, None), or (None, error message).""" + if action == 'save': + return 'region save', None + if action == 'clear_default': + return 'region default ', None + if not name: + return None, 'Missing region name' + # `*` is the wildcard root: it always exists, cannot be created or deleted, + # and only its flood flag and home marker are editable. + if name == '*': + if action not in ('allow_flood', 'deny_flood', 'set_home'): + return None, 'The wildcard region cannot be added, removed or made default' + else: + ok, err = is_valid_region_name(name) + if not ok: + return None, err + if action == 'add': + if parent and parent != '*': + ok, err = is_valid_region_name(parent) + if not ok: + return None, f'Invalid parent: {err}' + return f'region put {name} {parent}', None + return f'region put {name}', None + commands = { + 'remove': f'region remove {name}', + 'allow_flood': f'region allowf {name}', + 'deny_flood': f'region denyf {name}', + 'set_home': f'region home {name}', + 'set_default': f'region default {name}', + } + cmd = commands.get(action) + return (cmd, None) if cmd else (None, f'Unknown action: {action}') + + +@api_bp.route('/repeaters//regions', methods=['POST']) +def repeater_regions_post(public_key): + """Run one region action on a repeater. + + Body: {'action': ..., 'name': ..., 'parent': ...}. One CLI command per + request rather than a batch, so the UI can report each step as it happens: + a mesh round-trip is over a second, and a batch failing halfway would leave + the caller unsure what landed. `dirty` in the reply says the change is in + RAM only and still needs a `save`. + """ + 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 '').strip() + name = (data.get('name') or '').strip() + parent = (data.get('parent') or '').strip() + # A newline would split the CLI text into a second command, and a space + # would shift the remainder into the next positional argument. + if any(c in (name + parent) for c in '\r\n '): + return jsonify({'success': False, 'error': 'Invalid region name'}), 400 + cmd, error = _region_command(action, name, parent) + if error: + return jsonify({'success': False, 'error': error}), 400 + try: + result = dm.repeater_cmd_wait(pk, cmd, timeout=_SETTINGS_FIELD_TIMEOUT) + if not result.get('success'): + return jsonify({'success': False, + 'error': result.get('error', 'Region command failed')}), \ + _repeater_result_status(result) + reply = (result.get('reply') or '').strip() + # The firmware answers `OK`, `OK - (flood allowed)`, ` home is now x` or + # ` default scope is now x`. Anything starting with `Err` is a failure, + # and an empty reply means the command never ran. + if reply.lower().startswith('err') or not reply: + return jsonify({'success': False, 'action': action, 'command': cmd, + 'reply': reply, + 'error': reply or 'Empty reply'}), 502 + return jsonify({'success': True, 'action': action, 'command': cmd, + 'reply': reply, + 'dirty': action in _REGION_DIRTYING, + 'elapsed_ms': result.get('elapsed_ms')}), 200 + except Exception as e: + logger.error(f"Error running repeater region action: {e}") + 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` and `poweroff` # never reply: the firmware acts immediately without building one. diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 9740bf4..7a350d3 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -113,6 +113,8 @@ let _repeater = null; // merged entry from GET /api/repeaters/ let _session = null; // {logged_in, is_admin, ...} let _passwordModal = null; let _powerOffModal = null; +let _paneCurrent = null; // key of the open tool pane, null on the grid +let _paneParent = null; // pane to return to, for panes opened from a pane // ================================================================ // State screens @@ -143,6 +145,8 @@ function showPanel() { } function goBackToList() { + // Leaving the page entirely also abandons unsaved region edits. + if (_paneCurrent === 'regions' && !confirmLeavingRegions()) return; window.location.href = '/repeaters'; } @@ -217,11 +221,28 @@ function renderTools() { // ================================================================ function showToolsGrid() { + _paneCurrent = null; + _paneParent = null; document.getElementById('toolsGrid').style.display = ''; document.getElementById('toolPane').style.display = 'none'; } +// A pane opened from inside another one (Settings → Regions) remembers where it +// came from, so the pane back arrow returns there instead of jumping to the +// grid two levels up. +function closeToolPane() { + if (_paneCurrent === 'regions' && !confirmLeavingRegions()) return; + const parent = _paneParent ? TOOLS.find(x => x.key === _paneParent) : null; + if (parent) { + openToolPane(parent); + return; + } + showToolsGrid(); +} + function openToolPane(tool) { + _paneCurrent = tool.key; + _paneParent = tool.parent || null; document.getElementById('toolsGrid').style.display = 'none'; const pane = document.getElementById('toolPane'); pane.style.display = ''; @@ -259,6 +280,10 @@ function openToolPane(tool) { renderActionsPane(body); return; } + if (tool.key === 'regions') { + renderRegionsPane(body); + return; + } body.innerHTML = `
@@ -1458,10 +1483,26 @@ function renderSettingsPane(body) {
`; }).join(''); + // Regions is not a list of scalar fields like the sections above — it is a + // list with its own add/remove and an explicit save — so it gets a screen of + // its own rather than a ninth accordion, reached from this row. body.innerHTML = `

${tHtml('rptmgmt.set.intro')}

${items}
+ `; + body.querySelector('#settingsRegionsRow') + .addEventListener('click', () => openToolPane(REGIONS_TOOL)); const cache = loadSettingsCache(); SETTINGS_SECTIONS.forEach(sec => { @@ -2068,6 +2109,347 @@ function applyLocationFromMap() { if (_locMapModal) _locMapModal.hide(); } +// ================================================================ +// Regions (Settings → Regions) +// ================================================================ + +// Reached from a row at the foot of the Settings pane rather than from a tile +// of its own, so it is not in TOOLS — openToolPane() takes any descriptor. +const REGIONS_TOOL = { key: 'regions', icon: 'bi-signpost-split', + title: 'rptmgmt.reg.title', parent: 'settings' }; + +let _regionsData = null; // last GET /regions payload +let _regionsDirty = false; // edits sent to RAM but not yet `region save`d +let _regionsBusy = false; // one region command in flight + +// Placeholder value for the default-scope picker when that read failed. Not a +// region name: `?` is not a legal one (RegionMap::is_name_char rejects 0x3f). +const REGION_DEFAULT_UNKNOWN = '?'; + +// Mirrors RegionMap::is_name_char in the firmware (and is_valid_region_name in +// app/meshcore/regions.py): '-', '$', '#', digits, or any byte >= 'A'. The +// server validates too — this only spares the user a mesh round-trip to learn +// the name was never going to be accepted. +function isValidRepeaterRegionName(name) { + if (!name) return t('rptmgmt.reg.err_empty'); + const bytes = new TextEncoder().encode(name); + if (bytes.length > 30) return t('rptmgmt.reg.err_long', { max: 30 }); + for (const b of bytes) { + if (b === 0x2d || b === 0x24 || b === 0x23) continue; // - $ # + if (b >= 0x30 && b <= 0x39) continue; // digits + if (b >= 0x41) continue; // 'A' and up + return t('rptmgmt.reg.err_char'); + } + return null; +} + +function renderRegionsPane(body) { + _regionsData = null; + _regionsDirty = false; + _regionsBusy = false; + body.innerHTML = ` +

${tHtml('rptmgmt.reg.intro')}

+
+ + + +
+
+ ${tHtml('rptmgmt.reg.dirty_note')} +
+
+ ${tHtml('rptmgmt.reg.truncated')} +
+
+
+ + + + +
+
${tHtml('rptmgmt.reg.name_help')}
+
+
+
+
+
+
+ ${tHtml('rptmgmt.reg.default_title')} +
+
${tHtml('rptmgmt.reg.default_help')}
+
+ + +
+
+
+
+ `; + body.querySelector('#regRefreshBtn').addEventListener('click', () => loadRegions()); + body.querySelector('#regSaveBtn').addEventListener('click', saveRegions); + body.querySelector('#regAddBtn').addEventListener('click', toggleRegionAddForm); + body.querySelector('#regAddCancel').addEventListener('click', () => toggleRegionAddForm(false)); + body.querySelector('#regAddConfirm').addEventListener('click', submitNewRegion); + body.querySelector('#regNewName').addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); submitNewRegion(); } + }); + body.querySelector('#regDefaultApply').addEventListener('click', applyDefaultRegion); + loadRegions(); +} + +function toggleRegionAddForm(show) { + const form = document.getElementById('regAddForm'); + if (!form) return; + const open = (show === undefined) ? form.classList.contains('d-none') : !!show; + form.classList.toggle('d-none', !open); + document.getElementById('regAddError').classList.add('d-none'); + if (open) { + const input = document.getElementById('regNewName'); + input.value = ''; + input.focus(); + } +} + +function setRegionsBusy(busy) { + _regionsBusy = busy; + const pane = document.getElementById('paneBody'); + if (!pane) return; + pane.querySelectorAll('#regRefreshBtn, #regAddBtn, #regAddConfirm, #regDefaultApply, .reg-action') + .forEach(el => { el.disabled = busy; }); + const save = document.getElementById('regSaveBtn'); + if (save) save.disabled = busy || !_regionsDirty; +} + +function setRegionsDirty(dirty) { + _regionsDirty = dirty; + const banner = document.getElementById('regDirtyBanner'); + if (banner) banner.classList.toggle('d-none', !dirty); + const save = document.getElementById('regSaveBtn'); + if (save) save.disabled = _regionsBusy || !dirty; +} + +async function loadRegions() { + const list = document.getElementById('regList'); + if (!list) return; + list.innerHTML = `
+ ${tHtml('rptmgmt.reg.reading')}
`; + setRegionsBusy(true); + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/regions`); + const data = await resp.json(); + if (!data.success) { + list.innerHTML = `
${esc(data.error || t('rptmgmt.reg.read_failed'))}
`; + return; + } + _regionsData = data; + renderRegionsList(); + } catch (e) { + list.innerHTML = `
${esc(String(e))}
`; + } finally { + setRegionsBusy(false); + } +} + +function renderRegionsList() { + const list = document.getElementById('regList'); + const data = _regionsData; + if (!list || !data) return; + + document.getElementById('regTruncBanner').classList.toggle('d-none', !data.truncated); + + const entries = data.entries || []; + if (!entries.length) { + list.innerHTML = `
${tHtml('rptmgmt.reg.none')}
`; + } else { + list.innerHTML = `
${entries.map(regionRowHtml).join('')}
`; + list.querySelectorAll('.reg-action').forEach(btn => { + btn.addEventListener('click', () => runRegionAction(btn.dataset.action, btn.dataset.name)); + }); + } + + // Default scope: the picker offers the regions this repeater knows about, + // plus the unscoped option the firmware spells ``. When that read is + // the one that got lost, the picker says so instead of resting on "none" — + // an unanswered command must not read as an answer of no scope. + const select = document.getElementById('regDefaultSelect'); + const unread = !!data.default_error; + const current = data.default_scope || ''; + const options = entries.filter(e => !e.is_root) + .map(e => ``); + options.unshift(``); + if (unread) options.unshift(``); + select.innerHTML = options.join(''); + select.value = unread ? REGION_DEFAULT_UNKNOWN : current; + const note = document.getElementById('regDefaultNote'); + if (unread) { + note.className = 'small text-danger mt-1'; + note.textContent = t('rptmgmt.reg.default_unread', { error: data.default_error }); + } else { + note.className = 'small text-muted mt-1'; + note.textContent = current + ? t('rptmgmt.reg.default_current', { name: current }) + : t('rptmgmt.reg.default_current_none'); + } +} + +function regionRowHtml(e) { + // The wildcard row is the repeater's rule for packets that carry no scope + // at all. It always exists in the firmware, so it has no Delete. + const label = e.is_root ? t('rptmgmt.reg.wildcard') : e.name; + const flood = e.flood_allowed ? t('rptmgmt.reg.flood_allowed') : t('rptmgmt.reg.flood_denied'); + const icon = e.flood_allowed + ? '' + : ''; + const indent = Math.min(e.depth, 4) * 16; + return ` +
+ ${icon} +
+
+ ${e.is_root ? '*' : ''} + ${esc(label)} + ${e.is_home ? `${esc(t('rptmgmt.reg.home'))}` : ''} +
+
${esc(flood)}
+
+ +
`; +} + +// Every region action is one mesh round-trip, so they are serialised behind +// _regionsBusy and each one refreshes the tree it just changed. +async function postRegionAction(payload) { + setRegionsBusy(true); + try { + const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/regions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return await resp.json(); + } catch (e) { + return { success: false, error: String(e) }; + } finally { + setRegionsBusy(false); + } +} + +async function runRegionAction(action, name) { + if (_regionsBusy) return; + if (action === 'remove' && !window.confirm(t('rptmgmt.reg.confirm_remove', { name }))) return; + const data = await postRegionAction({ action, name }); + if (!data.success) { + showNotification(data.error || t('rptmgmt.reg.action_failed'), 'error'); + return; + } + if (data.dirty) setRegionsDirty(true); + showNotification(t('rptmgmt.reg.action_ok'), 'success'); + await loadRegions(); +} + +async function submitNewRegion() { + if (_regionsBusy) return; + const input = document.getElementById('regNewName'); + const errEl = document.getElementById('regAddError'); + const name = (input.value || '').trim(); + const problem = isValidRepeaterRegionName(name); + if (problem) { + errEl.textContent = problem; + errEl.classList.remove('d-none'); + return; + } + errEl.classList.add('d-none'); + const data = await postRegionAction({ action: 'add', name }); + if (!data.success) { + errEl.textContent = data.error || t('rptmgmt.reg.action_failed'); + errEl.classList.remove('d-none'); + return; + } + setRegionsDirty(true); + toggleRegionAddForm(false); + showNotification(t('rptmgmt.reg.added', { name }), 'success'); + await loadRegions(); +} + +// `region default` is the one region command the firmware persists by itself +// (it calls saveRegions()), so it never sets the unsaved-changes flag. +async function applyDefaultRegion() { + if (_regionsBusy) return; + const select = document.getElementById('regDefaultSelect'); + const name = select.value || ''; + // Still on the "could not read" placeholder: nothing was chosen to apply. + if (name === REGION_DEFAULT_UNKNOWN) { + showNotification(t('rptmgmt.reg.default_pick_first'), 'warning'); + return; + } + const data = await postRegionAction(name ? { action: 'set_default', name } + : { action: 'clear_default' }); + if (!data.success) { + showNotification(data.error || t('rptmgmt.reg.action_failed'), 'error'); + return; + } + showNotification(t('rptmgmt.reg.default_saved'), 'success'); + await loadRegions(); +} + +async function saveRegions() { + if (_regionsBusy) return; + const data = await postRegionAction({ action: 'save' }); + if (!data.success) { + showNotification(data.error || t('rptmgmt.reg.save_failed'), 'error'); + return; + } + setRegionsDirty(false); + showNotification(t('rptmgmt.reg.saved'), 'success'); +} + +// Leaving the pane throws away nothing on the repeater — the edits are already +// in its RAM — but they die at the next reboot, which is not what someone who +// just made them expects. Ask before walking away from that. +function confirmLeavingRegions() { + if (!_regionsDirty) return true; + return window.confirm(t('rptmgmt.reg.confirm_leave')); +} + // ================================================================ // Actions tool // ================================================================ @@ -2376,7 +2758,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('errorBackBtn').addEventListener('click', goBackToList); document.getElementById('errorRetryBtn').addEventListener('click', init); document.getElementById('logoutBtn').addEventListener('click', logout); - document.getElementById('paneBackBtn').addEventListener('click', showToolsGrid); + document.getElementById('paneBackBtn').addEventListener('click', closeToolPane); document.getElementById('passwordSubmitBtn').addEventListener('click', submitPasswordModal); document.getElementById('passwordCancelBtn').addEventListener('click', () => { diff --git a/app/templates/repeater-manage.html b/app/templates/repeater-manage.html index df5267c..a7f6a14 100644 --- a/app/templates/repeater-manage.html +++ b/app/templates/repeater-manage.html @@ -158,6 +158,8 @@ .tool-icon.cli { background: rgba(255, 143, 0, 0.12); color: #e65100; } .tool-icon.settings { background: rgba(220, 53, 69, 0.12); color: #dc3545; } .tool-icon.actions { background: rgba(13, 202, 240, 0.12); color: #0aa2c0; } + /* Reached from inside Settings rather than from a tile of its own. */ + .tool-icon.regions { background: rgba(32, 156, 138, 0.12); color: #17948a; } .tool-tile h6 { margin: 0; diff --git a/app/translations/en.json b/app/translations/en.json index 0b571a0..5435799 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -987,6 +987,48 @@ "rptmgmt.pubkey_copied": "Public key copied", "rptmgmt.read_failed": "Failed to read", "rptmgmt.reboot_required": "reboot required", + "rptmgmt.reg.action_failed": "Region command failed", + "rptmgmt.reg.action_ok": "Done", + "rptmgmt.reg.add": "Add region", + "rptmgmt.reg.added": "Region {name} added", + "rptmgmt.reg.allow_flood": "Allow flood", + "rptmgmt.reg.confirm_leave": "You have region changes that have not been saved to the repeater yet.\n\nThey are active now but will be lost when it reboots. Leave without saving?", + "rptmgmt.reg.confirm_remove": "Remove region {name} from this repeater?\n\nA region that still has regions nested under it cannot be removed.", + "rptmgmt.reg.default_current": "This repeater currently scopes its own flood packets to {name}.", + "rptmgmt.reg.default_current_none": "This repeater currently sends its own flood packets unscoped.", + "rptmgmt.reg.default_help": "The region this repeater stamps on flood packets it sends itself. Only repeaters that allow that region will forward them. Saved on the repeater immediately — it does not need the Save button.", + "rptmgmt.reg.default_none": "— none (unscoped) —", + "rptmgmt.reg.default_pick_first": "Choose a default scope first, or refresh to read the current one.", + "rptmgmt.reg.default_saved": "Default scope updated", + "rptmgmt.reg.default_title": "Default scope", + "rptmgmt.reg.default_unknown": "— could not be read —", + "rptmgmt.reg.default_unread": "Could not read the default scope: {error}", + "rptmgmt.reg.deny_flood": "Deny flood", + "rptmgmt.reg.dirty_note": "Unsaved changes. They are already active, but the repeater will forget them when it reboots — use Save to keep them.", + "rptmgmt.reg.err_char": "That character is not allowed in a region name.", + "rptmgmt.reg.err_empty": "Enter a region name.", + "rptmgmt.reg.err_long": "Name too long (max {max} characters).", + "rptmgmt.reg.flood_allowed": "Flood allowed", + "rptmgmt.reg.flood_denied": "Flood denied", + "rptmgmt.reg.home": "home", + "rptmgmt.reg.intro": "Which flood-scoped packets this repeater passes on. A region set to Flood allowed is repeated; one set to Flood denied is dropped. Changes take effect at once but live in memory only until you save them.", + "rptmgmt.reg.menu_title": "Region actions", + "rptmgmt.reg.name_help": "Letters, digits and - $ # are allowed; no spaces. Max 30 characters.", + "rptmgmt.reg.name_placeholder": "Region name, e.g. pl", + "rptmgmt.reg.none": "This repeater has no regions configured.", + "rptmgmt.reg.read_failed": "Could not read the regions", + "rptmgmt.reg.reading": "Reading regions from the repeater…", + "rptmgmt.reg.refresh_title": "Re-read the region list from the repeater", + "rptmgmt.reg.remove": "Delete region", + "rptmgmt.reg.row_desc": "Which regions this repeater floods", + "rptmgmt.reg.save": "Save", + "rptmgmt.reg.save_failed": "Could not save the regions", + "rptmgmt.reg.save_title": "Write the region list to the repeater so it survives a reboot", + "rptmgmt.reg.saved": "Regions saved on the repeater", + "rptmgmt.reg.set_home": "Set as home region", + "rptmgmt.reg.title": "Regions", + "rptmgmt.reg.truncated": "The repeater sent more regions than fit in one reply, so this list may be incomplete. Use the CLI (`region list allowed`) to see the rest.", + "rptmgmt.reg.wildcard": "Packets without a region scope", "rptmgmt.request_failed": "Request failed", "rptmgmt.set.advanced": "Advanced", "rptmgmt.set.advert": "Advertisement", diff --git a/app/translations/pl.json b/app/translations/pl.json index abea4cb..1e8f1c9 100644 --- a/app/translations/pl.json +++ b/app/translations/pl.json @@ -1049,6 +1049,48 @@ "rptmgmt.pubkey_copied": "Klucz publiczny skopiowany", "rptmgmt.read_failed": "Nie udało się odczytać", "rptmgmt.reboot_required": "wymagany restart", + "rptmgmt.reg.action_failed": "Polecenie regionu nie powiodło się", + "rptmgmt.reg.action_ok": "Gotowe", + "rptmgmt.reg.add": "Dodaj region", + "rptmgmt.reg.added": "Region {name} został dodany", + "rptmgmt.reg.allow_flood": "Zezwól na flood", + "rptmgmt.reg.confirm_leave": "Masz zmiany regionów, które nie zostały jeszcze zapisane na repeaterze.\n\nDziałają już teraz, ale zostaną utracone po jego restarcie. Wyjść bez zapisywania?", + "rptmgmt.reg.confirm_remove": "Usunąć region {name} z tego repeatera?\n\nRegionu, który ma pod sobą zagnieżdżone regiony, nie można usunąć.", + "rptmgmt.reg.default_current": "Ten repeater nadaje obecnie własne pakiety flood w zakresie {name}.", + "rptmgmt.reg.default_current_none": "Ten repeater nadaje obecnie własne pakiety flood bez zakresu.", + "rptmgmt.reg.default_help": "Region, którym ten repeater oznacza wysyłane przez siebie pakiety flood. Przekażą je dalej tylko repeatery, które zezwalają na ten region. Zapisywane na repeaterze od razu — nie wymaga przycisku Zapisz.", + "rptmgmt.reg.default_none": "— brak (bez zakresu) —", + "rptmgmt.reg.default_pick_first": "Najpierw wybierz domyślny zakres albo odśwież, aby odczytać obecny.", + "rptmgmt.reg.default_saved": "Domyślny zakres zaktualizowany", + "rptmgmt.reg.default_title": "Domyślny zakres", + "rptmgmt.reg.default_unknown": "— nie udało się odczytać —", + "rptmgmt.reg.default_unread": "Nie udało się odczytać domyślnego zakresu: {error}", + "rptmgmt.reg.deny_flood": "Zablokuj flood", + "rptmgmt.reg.dirty_note": "Niezapisane zmiany. Już działają, ale repeater zapomni o nich po restarcie — użyj Zapisz, aby je zachować.", + "rptmgmt.reg.err_char": "Ten znak jest niedozwolony w nazwie regionu.", + "rptmgmt.reg.err_empty": "Podaj nazwę regionu.", + "rptmgmt.reg.err_long": "Nazwa zbyt długa (maks. {max} znaków).", + "rptmgmt.reg.flood_allowed": "Flood dozwolony", + "rptmgmt.reg.flood_denied": "Flood zablokowany", + "rptmgmt.reg.home": "macierzysty", + "rptmgmt.reg.intro": "Które pakiety z zakresem regionu ten repeater przekazuje dalej. Region ustawiony na Flood dozwolony jest powtarzany, a ustawiony na Flood zablokowany — odrzucany. Zmiany działają od razu, ale do czasu zapisania istnieją tylko w pamięci.", + "rptmgmt.reg.menu_title": "Akcje regionu", + "rptmgmt.reg.name_help": "Dozwolone są litery, cyfry oraz - $ #; bez spacji. Maks. 30 znaków.", + "rptmgmt.reg.name_placeholder": "Nazwa regionu, np. pl", + "rptmgmt.reg.none": "Ten repeater nie ma skonfigurowanych regionów.", + "rptmgmt.reg.read_failed": "Nie udało się odczytać regionów", + "rptmgmt.reg.reading": "Odczyt regionów z repeatera…", + "rptmgmt.reg.refresh_title": "Odczytaj listę regionów z repeatera ponownie", + "rptmgmt.reg.remove": "Usuń region", + "rptmgmt.reg.row_desc": "Dla których regionów ten repeater przekazuje flood", + "rptmgmt.reg.save": "Zapisz", + "rptmgmt.reg.save_failed": "Nie udało się zapisać regionów", + "rptmgmt.reg.save_title": "Zapisz listę regionów na repeaterze, aby przetrwała restart", + "rptmgmt.reg.saved": "Regiony zapisane na repeaterze", + "rptmgmt.reg.set_home": "Ustaw jako region macierzysty", + "rptmgmt.reg.title": "Regiony", + "rptmgmt.reg.truncated": "Repeater wysłał więcej regionów, niż mieści się w jednej odpowiedzi, więc ta lista może być niepełna. Użyj CLI (`region list allowed`), aby zobaczyć resztę.", + "rptmgmt.reg.wildcard": "Pakiety bez zakresu regionu", "rptmgmt.request_failed": "Żądanie nie powiodło się", "rptmgmt.set.advanced": "Zaawansowane", "rptmgmt.set.advert": "Advert", diff --git a/docs/architecture.md b/docs/architecture.md index b6f1c5e..45b4470 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,6 +119,7 @@ The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are - **Login sessions** — `repeater_login()` waits for `LOGIN_SUCCESS` filtered by the contact's `pubkey_prefix` and stores `{is_admin, permissions, logged_in_at}` in the in-memory `_repeater_sessions` dict (cleared on logout/app restart; the UI auto-relogs with the saved password). All remote endpoints fail fast with 401 `need_login` when no session exists — a repeater only answers pubkeys in its ACL, so requesting without a login would just burn a multi-minute timeout. A wrong password is indistinguishable from an unreachable repeater (the firmware never replies to failed logins), so timeout messages always name both causes - **CLI reply correlation** — `repeater_cmd_wait()` sends one text command and blocks for its reply. Replies arrive as `CONTACT_MSG_RECV` with `txt_type=1` (CLI_DATA) and carry no protocol-level correlation, so correlation = the repeater lock (single command in flight) + a single-slot waiter matched against the sender's 12-hex pubkey prefix at the top of `_on_dm_received`. Matched replies are consumed there and never stored as chat DMs; unmatched CLI replies keep the legacy behavior (stored as a DM) so the Console's fire-and-forget `cmd` flow is unchanged. Wait time derives from the device-suggested timeout (clamped 10–45 s) - **Settings batches** — reads run sequential `get ` commands per section and parse the firmware's `> value` replies, reporting per-field errors without failing the batch; writes send only dirty fields and classify each reply as `ok` (starts with `ok`/`password now`), `reboot_required` (reply mentions reboot — e.g. `set radio`), or `failed` (anything else, surfaced verbatim). Connection-level failures abort the rest of the batch +- **Regions** — the repeater's flood-scope map, edited over text CLI (`region ...`). Two firmware facts drive the design. `put`/`remove`/`allowf`/`denyf` mutate the map **in RAM only** and are lost on reboot until `region save` writes flash, so the UI stages edits behind an explicit Save and warns before navigating away; `region default` is the exception (the firmware calls `saveRegions()` itself) and so never sets the dirty flag. The bare `region` reply is an indented tree — indent = nesting depth, `^` = home region, trailing ` F` = flood allowed — capped at 160 bytes by `exportTo(reply, 160)`, so `_parse_region_tree()` strips the suffixes right-to-left (a region may legitimately be named `F` or end in `^`) and `_region_tree_truncated()` reports a clipped tail rather than hiding it. Names are validated with the shared `is_valid_region_name()` before any command is built, which also blocks CLI injection through a name. The binary `AnonReqType.REGIONS` request is deliberately unused here: it answers with `exportNamesTo()`, a flat list of *allowed* names with no hierarchy, deny flags or home marker - **Role gating** — the firmware silently drops text CLI from non-admin logins (which would surface as a timeout), so the CLI/Settings/Actions endpoints reject guest sessions with 403 up front instead - **Actions whitelist** — `advert.zerohop`, `advert` (flood), `clock sync`, `reboot`. `reboot` never replies (the firmware restarts immediately without building one), so a clean send followed by silence is reported as success. Text `erase` is firmware-gated to the USB serial console (`sender_timestamp == 0`), hence no erase in the UI @@ -403,6 +404,8 @@ Every mutating endpoint hot-reloads the `ObserverManager`, so broker and setting | GET | `/api/repeaters//settings` | Read one settings section (`?section=basic\|radio\|location\|features\|network\|advert\|operator\|advanced`) as a `get` batch; admin only | | POST | `/api/repeaters//settings` | Apply dirty fields (`{values}`) as a `set` batch with per-field `ok\|failed\|reboot_required` results; admin only | | POST | `/api/repeaters//action` | One-shot action (`{action: zerohop_advert\|flood_advert\|discover_neighbours\|clock_sync\|reboot}`); admin only | +| GET | `/api/repeaters//regions` | Region map: the parsed `region` tree plus the default flood scope; admin only | +| POST | `/api/repeaters//regions` | One region action (`{action: add\|remove\|allow_flood\|deny_flood\|set_home\|set_default\|clear_default\|save, name?, parent?}`); admin only | Error mapping is shared across the family: 401 `need_login` (no session), 403 (guest on an admin endpoint), 429 (another repeater operation in progress — single firmware request slot), 503 (device not connected), 504 (timeout: repeater unreachable *or* wrong password — indistinguishable by protocol). Passwords are never returned by any GET (`password_set` boolean only). The panels are served by `GET /repeaters` and `GET /repeaters/manage?pubkey=<64-hex>`. diff --git a/docs/user-guide.md b/docs/user-guide.md index 0679f4d..61283ee 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -695,6 +695,25 @@ Repeater configuration organized into collapsible sections: **Basic** (name, adm - The **Location** section has a **Pick from map** button (enabled once latitude and longitude have values): it opens a map, and clicking a point fills the latitude and longitude fields for you and marks the section changed, ready to Apply — handy when you know where the repeater is but not its exact coordinates - The admin password is write-only (the current one is never displayed). After a successful change, the password saved in mc-webui is updated automatically so one-click login keeps working. Changing it does **not** log out sessions that are already active +#### Regions (admin only) + +At the foot of the Settings page is a **Regions** row, opening a screen of its own. It controls which *flood-scoped* packets the repeater passes on — until now this was only reachable through the CLI (`region ...`). + +A region is just a name, like `pl`. A sender can stamp that name on a flood packet, and only repeaters that allow the region will forward it; everyone else drops it. That is how a busy mesh keeps regional traffic regional instead of flooding the whole network. + +The screen lists what the repeater currently knows: + +- The first row, **Packets without a region scope** (`*`), is the rule for unstamped packets — the ordinary case. It always exists and cannot be deleted, but its flood setting can be changed like any other +- Each region below it shows **Flood allowed** or **Flood denied**. Regions can be nested, and a nested one is indented under its parent +- The region marked **home** is the repeater's own place in that hierarchy + +The ⋮ menu on a row offers **Allow flood** / **Deny flood**, **Set as home region**, and **Delete region**. A region that still has regions nested under it cannot be deleted — remove the children first. **Add region** takes a name of up to 30 characters; letters, digits and `-`, `$`, `#` are accepted, spaces are not. + +- **Changes are not permanent until you save them.** Adding, deleting or changing a region takes effect on the repeater immediately, but lives only in its memory: an orange banner appears and the change is forgotten at the next reboot until you press **Save**. This mirrors the firmware, which keeps the region table in RAM and writes it to flash only when told to. Leaving the screen with unsaved changes asks for confirmation first +- The **Default scope** card below the list is a separate setting: the region this repeater stamps on flood packets *it* sends itself, as opposed to which ones it forwards for others. Leave it on *none* and its own packets go out unscoped. This one the firmware saves by itself, so it does not need the Save button — the card says so +- Each action is one round-trip through the mesh, so an occasional timeout is normal; press the refresh button and try again +- Very large region tables can exceed what the repeater fits into a single reply. If that happens the list says so — use the CLI (`region list allowed`) to see the rest + #### Actions (admin only) One-click operations: diff --git a/docs/whatsnew.md b/docs/whatsnew.md index a498f71..72a5aaf 100644 --- a/docs/whatsnew.md +++ b/docs/whatsnew.md @@ -10,6 +10,12 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g ## Unreleased +### Features + +- **Repeater regions can now be managed from the interface, not just the CLI.** A repeater decides which *flood-scoped* packets it passes on from its own list of regions — a name like `pl` that a sender stamps on a packet, which only repeaters allowing that region will forward. Setting that up meant typing `region` commands into the console and reading back an indented text tree. **My Repeaters → Settings → Regions** now shows that tree as a list: each region with its **Flood allowed** / **Flood denied** state, nested regions indented under their parent, and the **home** region marked. A ⋮ menu on each row allows or denies flood, sets the home region, or deletes it, and **Add region** takes a name directly. The wildcard row at the top — *Packets without a region scope* — is the rule for ordinary unstamped packets, and can be switched like any other. Below the list, **Default scope** sets the region this repeater stamps on packets it sends itself. + + Changes behave the way the firmware does, and the screen is explicit about it: adding, deleting or switching a region takes effect at once but lives only in the repeater's memory, so an orange banner appears until you press **Save**, and leaving with unsaved changes asks first. Without that save the repeater forgets them at its next reboot. The Default scope card is the exception — the firmware stores that one by itself, and the card says so. + --- ## 2.16.0 — 2026-09-07 diff --git a/tests/test_repeater_cli_replies.py b/tests/test_repeater_cli_replies.py index 977de49..b62d93c 100644 --- a/tests/test_repeater_cli_replies.py +++ b/tests/test_repeater_cli_replies.py @@ -1,5 +1,6 @@ """ -Unit tests for classifying repeater CLI replies in the settings batch. +Unit tests for reading repeater CLI replies: the settings batch, and the +`region` tree behind Settings → Regions. The firmware's reply text is the only capability signal available for a remote repeater: FIRMWARE_VER_CODE stayed at 13 across v1.16 → v1.17, so it cannot @@ -10,7 +11,12 @@ Run: python -m pytest tests/test_repeater_cli_replies.py -v import pytest -from app.routes.api import _reply_is_unsupported +from app.routes.api import ( + _parse_region_tree, + _region_command, + _region_tree_truncated, + _reply_is_unsupported, +) # ================================================================ @@ -45,3 +51,124 @@ def test_unsupported_replies(reply): ]) def test_supported_or_failed_replies(reply): assert _reply_is_unsupported(reply) is False + + +# ================================================================ +# Parsing the `region` tree (Settings → Regions) +# ================================================================ +# +# The firmware prints one line per region: indent = nesting depth, `^` marks the +# home region and a trailing ` F` means flood is allowed. Names may legitimately +# be `F` or end in `^`, so the suffixes are stripped right-to-left. +# Captured live from MarWoj Mobile Observer (firmware 2.2.15). + + +def test_parse_live_capture(): + """The exact bytes a real repeater sent for a wildcard plus one region.""" + entries = _parse_region_tree('*^ F\n pl F\n') + assert entries == [ + {'name': '*', 'depth': 0, 'parent': None, 'flood_allowed': True, + 'is_home': True, 'is_root': True}, + {'name': 'pl', 'depth': 1, 'parent': '*', 'flood_allowed': True, + 'is_home': False, 'is_root': False}, + ] + + +def test_missing_f_suffix_means_flood_denied(): + entries = _parse_region_tree('*^ F\n pl F\n kra\n') + assert [(e['name'], e['flood_allowed']) for e in entries] == [ + ('*', True), ('pl', True), ('kra', False), + ] + + +def test_nesting_assigns_parents_by_indent(): + raw = '* F\n pl F\n kra^ F\n waw\n de\n' + entries = _parse_region_tree(raw) + assert [(e['name'], e['depth'], e['parent']) for e in entries] == [ + ('*', 0, None), + ('pl', 1, '*'), + ('kra', 2, 'pl'), + ('waw', 2, 'pl'), + ('de', 1, '*'), # back out to the root, not left under `pl` + ] + assert next(e for e in entries if e['name'] == 'kra')['is_home'] is True + + +def test_region_named_f_is_not_mistaken_for_the_flood_flag(): + """`F` is a legal region name, so ` F F` is the region F with flood allowed.""" + entries = _parse_region_tree('* F\n F F\n G\n') + assert [(e['name'], e['flood_allowed']) for e in entries] == [ + ('*', True), ('F', True), ('G', False), + ] + + +def test_hash_prefix_is_stripped(): + """`#name` marks an auto-derived scope key; the name the user sees has no `#`.""" + entries = _parse_region_tree('* F\n #pl F\n') + assert entries[1]['name'] == 'pl' + + +def test_blank_lines_and_empty_input_are_ignored(): + assert _parse_region_tree('') == [] + assert _parse_region_tree('\n\n') == [] + assert len(_parse_region_tree('* F\n\n pl F\n')) == 2 + + +# ---------------- truncation ---------------- +# `exportTo(reply, 160)` silently clips a tree that does not fit. A complete +# export always ends in the newline the firmware writes after the last region. + +def test_short_reply_is_not_truncated(): + assert _region_tree_truncated('*^ F\n pl F\n') is False + + +def test_full_length_reply_without_trailing_newline_is_truncated(): + raw = '* F\n' + ''.join(f' region{i:02d} F\n' for i in range(14)) + raw = raw[:159] # clipped mid-line, as the firmware would + assert len(raw) >= 158 + assert _region_tree_truncated(raw) is True + + +def test_full_length_reply_that_ends_cleanly_is_not_truncated(): + raw = '* F\n' + ''.join(f' region{i:02d} F\n' for i in range(14)) + assert len(raw) >= 158 and raw.endswith('\n') + assert _region_tree_truncated(raw) is False + + +# ================================================================ +# Building region CLI commands +# ================================================================ + +@pytest.mark.parametrize('action, name, parent, expected', [ + ('add', 'pl', '', 'region put pl'), + ('add', 'kra', 'pl', 'region put kra pl'), + ('add', 'pl', '*', 'region put pl'), # the root is implicit + ('remove', 'pl', '', 'region remove pl'), + ('allow_flood', 'pl', '', 'region allowf pl'), + ('deny_flood', 'pl', '', 'region denyf pl'), + ('set_home', 'pl', '', 'region home pl'), + ('set_default', 'pl', '', 'region default pl'), + ('clear_default', '', '', 'region default '), + ('save', '', '', 'region save'), + ('allow_flood', '*', '', 'region allowf *'), +]) +def test_region_command_builds(action, name, parent, expected): + cmd, err = _region_command(action, name, parent) + assert err is None + assert cmd == expected + + +@pytest.mark.parametrize('action, name', [ + ('add', 'pl;reboot'), # command separator + ('add', 'ab cd'), # a space would become the parent argument + ('add', 'a' * 31), # over the firmware's 30-byte field + ('add', ''), # no name + ('add', '*'), # the wildcard cannot be created + ('remove', '*'), # nor deleted + ('set_default', '*'), # nor made the default scope + ('bogus', 'pl'), # unknown action +]) +def test_region_command_rejects(action, name): + cmd, err = _region_command(action, name, '') + assert cmd is None + assert err