From f04f0f1dd82ce59ed9b4a1a52a3392beb8ced35e Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 24 Apr 2026 07:24:15 +0200 Subject: [PATCH] feat(regions): Settings > Channels tab with region registry CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ → cascade channel mappings; if the deleted region was firmware default, best-effort clear on device - POST /api/regions//default → flip DB flag + push CMD 63; if firmware push fails, DB still flips and response includes a non-blocking `warning` for a toast --- app/routes/api.py | 125 +++++++++++++++++++++++++++++++++++ app/static/js/app.js | 141 ++++++++++++++++++++++++++++++++++++++++ app/templates/base.html | 30 +++++++++ 3 files changed, 296 insertions(+) diff --git a/app/routes/api.py b/app/routes/api.py index ee6daa2..4e1e88a 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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/', 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//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 # ============================================================================= diff --git a/app/static/js/app.js b/app/static/js/app.js index a2000aa..bf1b2dd 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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 = '
Failed to load regions
'; + } +} + +function renderRegionsList() { + const listEl = document.getElementById('regionsList'); + if (!listEl) return; + const regions = window.regionRegistry || []; + if (regions.length === 0) { + listEl.innerHTML = '
No regions defined. Add one below.
'; + return; + } + listEl.innerHTML = regions.map(r => { + const isDefault = r.is_default ? 'checked' : ''; + const keyShort = (r.key_hex || '').slice(0, 8) + '…'; + return ` +
+
+ +
+
+
${escapeHtml(r.name)}
+ ${keyShort} +
+ +
+ `; + }).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 diff --git a/app/templates/base.html b/app/templates/base.html index e63bdb0..6812e2c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -394,6 +394,9 @@ +
@@ -740,6 +743,33 @@
+
+
+ Only repeaters allowing a region will forward messages tagged with it. + Find standardised region names at + regions.meshcore.nz. +
+
Region Registry
+
+
+
Loading... +
+
+
+
+ +
+
+ +
+
+
+ Tip: pick the default region via the radio button. The default is also pushed to the firmware so any untagged channel uses it. +
+