mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
feat(repeaters): manage regions from Settings, not just the CLI
A repeater's flood-scope map was only reachable by typing `region` commands into the console and reading back an indented text tree. My Repeaters -> Settings -> Regions now renders that tree as a list with per-region actions, an Add form, and a Default scope card. Placed as a row at the foot of the Settings pane rather than a seventh tile: region management is a list with its own add/remove and an explicit save, not the scalar get/set fields the accordion is built for. openToolPane() already accepts any descriptor, so REGIONS_TOOL reuses the pane machinery; _paneCurrent/_paneParent make the pane back arrow return to Settings instead of jumping two levels to the grid. Two firmware facts shape the design, both verified against a live repeater (MarWoj Mobile Observer, 2.2.15): - put/remove/allowf/denyf mutate the map in RAM only and are lost on reboot until `region save` writes flash. Edits therefore stage behind an explicit Save with a warning banner, and leaving with changes pending asks first. `region default` is the exception -- the firmware calls saveRegions() itself -- so it never sets the dirty flag. - The bare `region` reply is an indented tree (indent = depth, `^` = home, trailing ` F` = flood allowed) capped at 160 bytes by exportTo(reply, 160). Suffixes are stripped right-to-left because a region may legitimately be named `F` or end in `^`, and a clipped tail is reported rather than hidden. The binary AnonReqType.REGIONS request stays unused: it answers with exportNamesTo(), a flat list of allowed names with no hierarchy, deny flags or home marker -- most of what this screen shows. Names go through the existing is_valid_region_name() before any command is built, which also blocks CLI injection through a name. 28 new unit tests cover the tree parser (nesting, the `F`-named-region ambiguity, `#` stripping, truncation) and the command builder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
963acb9fed
commit
538976ab1b
9 files changed
+870
-3
No files matched your search
@@ -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/<public_key>/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 <name>` / `<null>`
|
||||
if marker in reply:
|
||||
name = reply.split(marker, 1)[1].strip()
|
||||
payload['default_scope'] = None if name in ('<null>', '') 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 <null>', 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/<public_key>/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.
|
||||
|
||||
@@ -113,6 +113,8 @@ let _repeater = null; // merged entry from GET /api/repeaters/<pk>
|
||||
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 = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
|
||||
@@ -1458,10 +1483,26 @@ function renderSettingsPane(body) {
|
||||
</div>`;
|
||||
}).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 = `
|
||||
<p class="text-muted small mb-2">${tHtml('rptmgmt.set.intro')}</p>
|
||||
<div class="accordion" id="settingsAccordion">${items}</div>
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex align-items-center gap-2 mt-3 rounded border"
|
||||
id="settingsRegionsRow">
|
||||
<span class="tool-icon regions" style="width: 32px; height: 32px; font-size: 1rem;">
|
||||
<i class="bi ${REGIONS_TOOL.icon}"></i>
|
||||
</span>
|
||||
<span class="flex-grow-1 text-start">
|
||||
<span class="d-block fw-semibold">${tHtml('rptmgmt.reg.title')}</span>
|
||||
<span class="d-block small text-muted">${tHtml('rptmgmt.reg.row_desc')}</span>
|
||||
</span>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</button>
|
||||
`;
|
||||
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 = `
|
||||
<p class="text-muted small mb-2">${tHtml('rptmgmt.reg.intro')}</p>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="regRefreshBtn"
|
||||
title="${tHtml('rptmgmt.reg.refresh_title')}">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<span class="d-none d-sm-inline">${tHtml('common.refresh')}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="regAddBtn">
|
||||
<i class="bi bi-plus-lg"></i> ${tHtml('rptmgmt.reg.add')}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-auto" id="regSaveBtn" disabled
|
||||
title="${tHtml('rptmgmt.reg.save_title')}">
|
||||
<i class="bi bi-hdd"></i> ${tHtml('rptmgmt.reg.save')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-warning py-2 px-2 small d-none" id="regDirtyBanner">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>${tHtml('rptmgmt.reg.dirty_note')}
|
||||
</div>
|
||||
<div class="alert alert-warning py-2 px-2 small d-none" id="regTruncBanner">
|
||||
<i class="bi bi-scissors me-1"></i>${tHtml('rptmgmt.reg.truncated')}
|
||||
</div>
|
||||
<div class="d-none mb-3" id="regAddForm">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-signpost"></i></span>
|
||||
<input type="text" class="form-control" id="regNewName" maxlength="30"
|
||||
placeholder="${tHtml('rptmgmt.reg.name_placeholder')}" autocomplete="off">
|
||||
<button type="button" class="btn btn-primary" id="regAddConfirm">
|
||||
${tHtml('rptmgmt.reg.add')}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="regAddCancel">
|
||||
${tHtml('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">${tHtml('rptmgmt.reg.name_help')}</div>
|
||||
<div class="small text-danger d-none" id="regAddError"></div>
|
||||
</div>
|
||||
<div id="regList"></div>
|
||||
<div class="card mt-3" id="regDefaultCard">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="small fw-semibold mb-1">
|
||||
<i class="bi bi-broadcast-pin me-1"></i>${tHtml('rptmgmt.reg.default_title')}
|
||||
</div>
|
||||
<div class="small text-muted mb-2">${tHtml('rptmgmt.reg.default_help')}</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<select class="form-select" id="regDefaultSelect"></select>
|
||||
<button type="button" class="btn btn-outline-primary" id="regDefaultApply">
|
||||
${tHtml('common.apply')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="small text-muted mt-1" id="regDefaultNote"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = `<div class="text-muted text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>${tHtml('rptmgmt.reg.reading')}</div>`;
|
||||
setRegionsBusy(true);
|
||||
try {
|
||||
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/regions`);
|
||||
const data = await resp.json();
|
||||
if (!data.success) {
|
||||
list.innerHTML = `<div class="alert alert-danger py-2 small mb-0">${esc(data.error || t('rptmgmt.reg.read_failed'))}</div>`;
|
||||
return;
|
||||
}
|
||||
_regionsData = data;
|
||||
renderRegionsList();
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="alert alert-danger py-2 small mb-0">${esc(String(e))}</div>`;
|
||||
} 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 = `<div class="text-muted text-center py-4">${tHtml('rptmgmt.reg.none')}</div>`;
|
||||
} else {
|
||||
list.innerHTML = `<div class="list-group">${entries.map(regionRowHtml).join('')}</div>`;
|
||||
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 `<null>`. 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 => `<option value="${esc(e.name)}">${esc(e.name)}</option>`);
|
||||
options.unshift(`<option value="">${esc(t('rptmgmt.reg.default_none'))}</option>`);
|
||||
if (unread) options.unshift(`<option value="${REGION_DEFAULT_UNKNOWN}">${esc(t('rptmgmt.reg.default_unknown'))}</option>`);
|
||||
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
|
||||
? '<i class="bi bi-check-circle-fill text-success"></i>'
|
||||
: '<i class="bi bi-slash-circle-fill text-secondary"></i>';
|
||||
const indent = Math.min(e.depth, 4) * 16;
|
||||
return `
|
||||
<div class="list-group-item d-flex align-items-center gap-2 py-2">
|
||||
<span style="padding-left: ${indent}px;">${icon}</span>
|
||||
<div class="flex-grow-1" style="min-width: 0;">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
${e.is_root ? '<span class="font-monospace flex-shrink-0">*</span>' : ''}
|
||||
<span class="text-truncate">${esc(label)}</span>
|
||||
${e.is_home ? `<span class="badge bg-primary-subtle text-primary-emphasis flex-shrink-0">${esc(t('rptmgmt.reg.home'))}</span>` : ''}
|
||||
</div>
|
||||
<div class="small text-muted">${esc(flood)}</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary reg-menu"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"
|
||||
title="${tHtml('rptmgmt.reg.menu_title')}">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><button type="button" class="dropdown-item reg-action"
|
||||
data-action="${e.flood_allowed ? 'deny_flood' : 'allow_flood'}" data-name="${esc(e.name)}">
|
||||
<i class="bi ${e.flood_allowed ? 'bi-slash-circle' : 'bi-check-circle'} me-2"></i>${e.flood_allowed ? tHtml('rptmgmt.reg.deny_flood') : tHtml('rptmgmt.reg.allow_flood')}
|
||||
</button></li>
|
||||
${e.is_home ? '' : `
|
||||
<li><button type="button" class="dropdown-item reg-action"
|
||||
data-action="set_home" data-name="${esc(e.name)}">
|
||||
<i class="bi bi-house me-2"></i>${tHtml('rptmgmt.reg.set_home')}
|
||||
</button></li>`}
|
||||
${e.is_root ? '' : `
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><button type="button" class="dropdown-item text-danger reg-action"
|
||||
data-action="remove" data-name="${esc(e.name)}">
|
||||
<i class="bi bi-trash me-2"></i>${tHtml('rptmgmt.reg.remove')}
|
||||
</button></li>`}
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <field>` 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/<pk>/settings` | Read one settings section (`?section=basic\|radio\|location\|features\|network\|advert\|operator\|advanced`) as a `get` batch; admin only |
|
||||
| POST | `/api/repeaters/<pk>/settings` | Apply dirty fields (`{values}`) as a `set` batch with per-field `ok\|failed\|reboot_required` results; admin only |
|
||||
| POST | `/api/repeaters/<pk>/action` | One-shot action (`{action: zerohop_advert\|flood_advert\|discover_neighbours\|clock_sync\|reboot}`); admin only |
|
||||
| GET | `/api/repeaters/<pk>/regions` | Region map: the parsed `region` tree plus the default flood scope; admin only |
|
||||
| POST | `/api/repeaters/<pk>/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>`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <null>'),
|
||||
('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
|
||||
Reference in new issue
Block a user