feat(regions): Settings > Channels tab with region registry CRUD

Third slice — users can now curate their device-wide region list. No
per-channel mapping yet; that's PR #4.

- base.html: new Channels tab in the Settings modal with an info banner
  pointing at regions.meshcore.nz, the list container, and an add-region
  form.
- app.js: loadRegions / addRegion / deleteRegion / setDefaultRegion
  mirroring the loadContactsSettings / saveContactsSetting pattern. Client
  -side name validation (isValidRegionName) mirrors the firmware
  RegionMap::is_name_char byte-rule exactly so users get instant feedback
  on invalid chars without a round-trip.
- api.py: four routes under /api/regions —
  - GET    /api/regions                       → list registry
  - POST   /api/regions   {name}              → derive key + insert; 409 dup
  - DELETE /api/regions/<id>                  → cascade channel mappings; if
      the deleted region was firmware default, best-effort clear on device
  - POST   /api/regions/<id>/default          → flip DB flag + push CMD 63;
      if firmware push fails, DB still flips and response includes a
      non-blocking `warning` for a toast
This commit is contained in:
MarekWo
2026-04-24 07:24:15 +02:00
parent 0e38e0ce8c
commit f04f0f1dd8
3 changed files with 296 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ from io import BytesIO
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, current_app
from app.meshcore import cli, parser
from app.meshcore.regions import derive_scope_key_hex, is_valid_region_name
from app.config import config, runtime_config
from app.device_manager import decode_path_len
from app.archiver import manager as archive_manager
@@ -3930,6 +3931,130 @@ def update_contacts_settings_api():
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# Regions (MeshCore flood scopes) — Settings > Channels tab
# =============================================================================
@api_bp.route('/regions', methods=['GET'])
def list_regions_api():
"""List the device's region registry."""
try:
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 500
return jsonify({
'success': True,
'regions': db.list_regions(),
}), 200
except Exception as e:
logger.error(f"Error listing regions: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/regions', methods=['POST'])
def create_region_api():
"""Create a new region. Body: {name: str}. Key is derived from the name."""
try:
data = request.get_json() or {}
name = (data.get('name') or '').strip()
ok, err = is_valid_region_name(name)
if not ok:
return jsonify({'success': False, 'error': err}), 400
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 500
if db.get_region_by_name(name) is not None:
return jsonify({'success': False, 'error': f'Region "{name}" already exists'}), 409
import sqlite3
try:
rid = db.create_region(name, derive_scope_key_hex(name))
except sqlite3.IntegrityError:
return jsonify({'success': False, 'error': f'Region "{name}" already exists'}), 409
return jsonify({'success': True, 'region': db.get_region(rid)}), 201
except Exception as e:
logger.error(f"Error creating region: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/regions/<int:region_id>', methods=['DELETE'])
def delete_region_api(region_id):
"""Delete a region. Channels mapped to it have their scope cleared (cascade)."""
try:
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 500
region = db.get_region(region_id)
if region is None:
return jsonify({'success': False, 'error': 'Region not found'}), 404
was_default = bool(region.get('is_default'))
db.delete_region(region_id)
# If we just deleted the firmware default, best-effort clear it on device.
warning = None
if was_default:
dm = _get_dm()
if dm and dm.is_connected:
try:
res = dm.set_default_flood_scope('', '')
if not res.get('success'):
warning = f"Firmware default not cleared: {res.get('error')}"
except Exception as e:
warning = f"Firmware default not cleared: {e}"
out = {'success': True}
if warning:
out['warning'] = warning
return jsonify(out), 200
except Exception as e:
logger.error(f"Error deleting region: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/regions/<int:region_id>/default', methods=['POST'])
def set_default_region_api(region_id):
"""Mark a region as default in the DB AND push it to the firmware (CMD 63).
If the firmware push fails the DB flag is still flipped; we return 200 with
a non-blocking `warning` so the UI can toast it.
"""
try:
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 500
region = db.get_region(region_id)
if region is None:
return jsonify({'success': False, 'error': 'Region not found'}), 404
db.set_default_region(region_id)
warning = None
dm = _get_dm()
if dm and dm.is_connected:
try:
res = dm.set_default_flood_scope(region['name'], region['key_hex'])
if not res.get('success'):
warning = f"Firmware push failed: {res.get('error')}"
except Exception as e:
warning = f"Firmware push failed: {e}"
else:
warning = 'Device disconnected; firmware default not updated'
out = {'success': True, 'region': db.get_region(region_id)}
if warning:
out['warning'] = warning
return jsonify(out), 200
except Exception as e:
logger.error(f"Error setting default region: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# Message Retention Settings
# =============================================================================

View File

@@ -2414,6 +2414,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadChatSettings();
loadUiSettings();
loadContactsSettings();
loadRegions();
});
settingsModal.addEventListener('shown.bs.modal', () => {
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
@@ -2436,6 +2437,18 @@ document.addEventListener('DOMContentLoaded', () => {
// Initial load so suppress flag is available before user opens Settings
loadContactsSettings();
// Channels tab: region registry
const addRegionForm = document.getElementById('addRegionForm');
if (addRegionForm) {
addRegionForm.addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('newRegionName');
const name = (input?.value || '').trim();
if (!name) return;
addRegion(name, input);
});
}
const dmRetryForm = document.getElementById('dmRetrySettingsForm');
if (dmRetryForm) {
dmRetryForm.addEventListener('submit', (e) => {
@@ -2793,6 +2806,134 @@ async function saveContactsSetting(key, value, inputEl) {
}
}
// ================================================================
// Region Registry (Settings > Channels)
// ================================================================
// Mirrors the firmware RegionMap::is_name_char rule: '-', '$', '#',
// digits, or any byte >= 'A'. UTF-8 bytes >= 0x80 pass via byte >= 'A'.
function isValidRegionName(name) {
if (!name || typeof name !== 'string') return false;
const bytes = new TextEncoder().encode(name);
if (bytes.length === 0 || bytes.length > 30) return false;
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'
return false;
}
return true;
}
async function loadRegions() {
const listEl = document.getElementById('regionsList');
if (!listEl) return;
try {
const resp = await fetch('/api/regions');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed');
window.regionRegistry = data.regions || [];
renderRegionsList();
} catch (e) {
console.error('Error loading regions:', e);
listEl.innerHTML = '<div class="text-center text-danger small py-2">Failed to load regions</div>';
}
}
function renderRegionsList() {
const listEl = document.getElementById('regionsList');
if (!listEl) return;
const regions = window.regionRegistry || [];
if (regions.length === 0) {
listEl.innerHTML = '<div class="text-center text-muted small py-3">No regions defined. Add one below.</div>';
return;
}
listEl.innerHTML = regions.map(r => {
const isDefault = r.is_default ? 'checked' : '';
const keyShort = (r.key_hex || '').slice(0, 8) + '…';
return `
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="regionDefault"
id="regionDefault_${r.id}" ${isDefault}
onchange="setDefaultRegion(${r.id})">
</div>
<div class="flex-grow-1">
<div><strong>${escapeHtml(r.name)}</strong></div>
<code class="small text-muted" title="${escapeHtml(r.key_hex)}">${keyShort}</code>
</div>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteRegion(${r.id}, '${escapeHtml(r.name).replace(/'/g, "\\'")}')"
title="Delete region">
<i class="bi bi-trash"></i>
</button>
</div>
`;
}).join('');
}
async function addRegion(name, inputEl) {
if (!isValidRegionName(name)) {
showNotification('Invalid region name. Allowed: letters, digits, - $ # (max 30 bytes, no spaces).', 'warning');
return;
}
try {
const resp = await fetch('/api/regions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to add region', 'danger');
return;
}
if (inputEl) inputEl.value = '';
await loadRegions();
} catch (e) {
console.error('Error adding region:', e);
showNotification('Network error adding region', 'danger');
}
}
async function deleteRegion(id, name) {
if (!confirm(`Delete region "${name}"?\nChannels using this region will revert to no scope.`)) return;
try {
const resp = await fetch(`/api/regions/${id}`, { method: 'DELETE' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to delete region', 'danger');
return;
}
await loadRegions();
} catch (e) {
console.error('Error deleting region:', e);
showNotification('Network error deleting region', 'danger');
}
}
async function setDefaultRegion(id) {
try {
const resp = await fetch(`/api/regions/${id}/default`, { method: 'POST' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to set default region', 'danger');
await loadRegions(); // snap UI back to server truth
return;
}
if (data.warning) {
showNotification(data.warning, 'warning');
}
// Update the local cache in place so radio stays checked without flicker.
(window.regionRegistry || []).forEach(r => { r.is_default = (r.id === id) ? 1 : 0; });
} catch (e) {
console.error('Error setting default region:', e);
showNotification('Network error setting default', 'danger');
await loadRegions();
}
}
/**
* Send browser notification when new messages arrive
* @param {number} channelCount - Number of channels with new messages

View File

@@ -394,6 +394,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsContacts" type="button">Contacts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChannels" type="button">Channels</button>
</li>
</ul>
<div class="tab-content">
<!-- Device Settings Tab -->
@@ -740,6 +743,33 @@
</tbody>
</table>
</div>
<div class="tab-pane fade" id="tabSettingsChannels">
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> Only repeaters allowing a region will forward messages tagged with it.
Find standardised region names at
<a href="https://regions.meshcore.nz/" target="_blank" rel="noopener">regions.meshcore.nz</a>.
</div>
<h6 class="mb-2">Region Registry</h6>
<div id="regionsList" class="list-group mb-3">
<div class="text-center text-muted py-3 small">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
<form id="addRegionForm" class="row g-2 mb-0">
<div class="col-8">
<input type="text" class="form-control form-control-sm" id="newRegionName"
placeholder="Region name (e.g. pl, pl-ma)" required maxlength="30" autocomplete="off">
</div>
<div class="col-4">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-plus-circle"></i> Add
</button>
</div>
</form>
<div class="form-text small">
Tip: pick the default region via the radio button. The default is also pushed to the firmware so any untagged channel uses it.
</div>
</div>
</div>
</div>
<div class="modal-footer py-2">