From afe0c7cf17299f86d8ec89532cfb202aac87ecea Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 24 Apr 2026 07:27:33 +0200 Subject: [PATCH] feat(regions): per-channel scope picker + send-flow integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth slice — the feature is now functional end-to-end from UI to radio. - Manage Channels modal: each row now has a pin-map button between Mute and Share that opens a region picker for that channel; rows show an inline badge with the assigned region name. - Region picker modal (new #regionPickerModal): radio list of regions with a "(None) — use firmware default" option at the top. Empty-state shows a "Manage Regions" CTA that deep-links to Settings > Channels. - api.py: two new routes — - GET /api/channels/scopes → bulk map for UI rendering - PUT /api/channels//scope → {region_id: int | null} set/clear - device_manager.send_channel_message: looks up the channel's scope, then — under _send_lock — pushes the 16-byte key via CMD 54 before the actual send_chan_msg. Channels without a mapping get an all-zero key so a previously-set scope doesn't leak across channels (firmware's send_scope is sticky until overwritten, not one-shot). --- app/device_manager.py | 27 +++++++- app/routes/api.py | 54 +++++++++++++++ app/static/js/app.js | 148 +++++++++++++++++++++++++++++++++++++++- app/templates/base.html | 25 +++++++ 4 files changed, 250 insertions(+), 4 deletions(-) diff --git a/app/device_manager.py b/app/device_manager.py index f3d2cca..603c23e 100644 --- a/app/device_manager.py +++ b/app/device_manager.py @@ -1352,12 +1352,35 @@ class DeviceManager: # ================================================================ def send_channel_message(self, channel_idx: int, text: str) -> Dict: - """Send a message to a channel. Returns result dict.""" + """Send a message to a channel. Returns result dict. + + Before each send, the per-channel region scope (if any) is pushed to + the firmware via CMD_SET_FLOOD_SCOPE_KEY. The scope-set + send pair is + serialised under _send_lock so two Flask threads can't swap each + other's send_scope at an await boundary. Channels without a mapping + get an all-zero key so a previously-set scope doesn't leak across + channels (firmware's send_scope is sticky until overwritten). + """ if not self.is_connected: return {'success': False, 'error': 'Device not connected'} + # Look up scope outside the lock — DB is thread-safe and fast. try: - event = self.execute(self.mc.commands.send_chan_msg(channel_idx, text)) + scope = self.db.get_channel_scope(channel_idx) + except Exception as e: + logger.warning(f"get_channel_scope({channel_idx}) failed: {e}") + scope = None + + try: + with self._send_lock: + scope_res = self.set_flood_scope_key(scope['key_hex'] if scope else None) + if not scope_res.get('success'): + scope_name = scope['name'] if scope else 'none' + return { + 'success': False, + 'error': f"Could not set region scope ({scope_name}): {scope_res.get('error')}", + } + event = self.execute(self.mc.commands.send_chan_msg(channel_idx, text)) # Store the sent message in database ts = int(time.time()) diff --git a/app/routes/api.py b/app/routes/api.py index 4e1e88a..a674cc2 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -4016,6 +4016,60 @@ def delete_region_api(region_id): return jsonify({'success': False, 'error': str(e)}), 500 +@api_bp.route('/channels/scopes', methods=['GET']) +def list_channel_scopes_api(): + """Bulk-load the per-channel region mapping for UI rendering. + + Returns: + {"success": true, "scopes": {"0": {region_id, name, key_hex, is_default}, ...}} + """ + try: + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + scopes = db.get_all_channel_scopes() + # JSON object keys must be strings + return jsonify({ + 'success': True, + 'scopes': {str(k): v for k, v in scopes.items()}, + }), 200 + except Exception as e: + logger.error(f"Error listing channel scopes: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@api_bp.route('/channels//scope', methods=['PUT']) +def set_channel_scope_api(index): + """Assign or clear the region scope for a channel. + + Body: {"region_id": int | null}. null removes the mapping (firmware default applies). + """ + try: + if index < 0 or index > 7: + return jsonify({'success': False, 'error': 'Channel index out of range (0-7)'}), 400 + + data = request.get_json() or {} + if 'region_id' not in data: + return jsonify({'success': False, 'error': 'region_id is required (int or null)'}), 400 + region_id = data['region_id'] + + db = _get_db() + if not db: + return jsonify({'success': False, 'error': 'Database not available'}), 500 + + if region_id is not None: + if not isinstance(region_id, int): + return jsonify({'success': False, 'error': 'region_id must be an integer or null'}), 400 + if db.get_region(region_id) is None: + return jsonify({'success': False, 'error': 'Region not found'}), 404 + + db.set_channel_scope(index, region_id) + return jsonify({'success': True, 'scope': db.get_channel_scope(index)}), 200 + except Exception as e: + logger.error(f"Error setting channel scope: {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). diff --git a/app/static/js/app.js b/app/static/js/app.js index bf1b2dd..f0fa93e 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -2449,6 +2449,12 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Region picker (per-channel): Save button + const regionPickerSaveBtn = document.getElementById('regionPickerSaveBtn'); + if (regionPickerSaveBtn) { + regionPickerSaveBtn.addEventListener('click', () => saveChannelScope()); + } + const dmRetryForm = document.getElementById('dmRetrySettingsForm'); if (dmRetryForm) { dmRetryForm.addEventListener('submit', (e) => { @@ -2934,6 +2940,129 @@ async function setDefaultRegion(id) { } } +// ================================================================ +// Per-channel region picker (Manage Channels > row > pin icon) +// ================================================================ + +let _regionPickerChannelIdx = null; +let _regionPickerPending = null; // region_id chosen in the radio list, null = "none" + +async function openRegionPicker(channelIdx) { + _regionPickerChannelIdx = channelIdx; + + // Ensure the registry is loaded (it may not be if user never opened Settings). + if (!Array.isArray(window.regionRegistry)) { + await loadRegions(); + } + + const ch = (availableChannels || []).find(c => c.index === channelIdx); + const nameEl = document.getElementById('regionPickerChannelName'); + if (nameEl) nameEl.textContent = ch ? ch.name : `Channel ${channelIdx}`; + + const currentScope = (window.channelScopes || {})[String(channelIdx)]; + _regionPickerPending = currentScope ? currentScope.region_id : null; + + renderRegionPickerList(); + + const modalEl = document.getElementById('regionPickerModal'); + bootstrap.Modal.getOrCreateInstance(modalEl).show(); +} + +function renderRegionPickerList() { + const listEl = document.getElementById('regionPickerList'); + if (!listEl) return; + const regions = window.regionRegistry || []; + + if (regions.length === 0) { + listEl.innerHTML = ` +
+ +

No regions defined yet.

+ +
+ `; + document.getElementById('pickerManageRegionsBtn')?.addEventListener('click', () => { + bootstrap.Modal.getOrCreateInstance(document.getElementById('regionPickerModal')).hide(); + const settingsModal = document.getElementById('settingsModal'); + bootstrap.Modal.getOrCreateInstance(settingsModal).show(); + // Activate the Channels tab after the modal is shown. + settingsModal.addEventListener('shown.bs.modal', function onceShown() { + settingsModal.removeEventListener('shown.bs.modal', onceShown); + const btn = document.querySelector('[data-bs-target="#tabSettingsChannels"]'); + if (btn) bootstrap.Tab.getOrCreateInstance(btn).show(); + }); + }); + return; + } + + const rows = [` + + `]; + for (const r of regions) { + rows.push(` + + `); + } + listEl.innerHTML = rows.join(''); + + // Track selection so Save knows what to send. + listEl.querySelectorAll('input[name="regionPickerChoice"]').forEach(el => { + el.addEventListener('change', (e) => { + const v = e.target.value; + _regionPickerPending = v === '' ? null : parseInt(v, 10); + }); + }); +} + +async function saveChannelScope() { + if (_regionPickerChannelIdx === null) return; + const idx = _regionPickerChannelIdx; + const regionId = _regionPickerPending; + try { + const resp = await fetch(`/api/channels/${idx}/scope`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ region_id: regionId }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data.success) { + showNotification(data.error || 'Failed to save region scope', 'danger'); + return; + } + // Update the local cache + re-render the channels list. + if (!window.channelScopes) window.channelScopes = {}; + if (regionId === null) { + delete window.channelScopes[String(idx)]; + } else { + window.channelScopes[String(idx)] = data.scope; + } + bootstrap.Modal.getOrCreateInstance(document.getElementById('regionPickerModal')).hide(); + if (typeof availableChannels !== 'undefined' && availableChannels.length) { + displayChannelsList(availableChannels); + } + // PR #5 will also refresh the status-bar indicator here. + if (typeof updateRegionIndicator === 'function') { + updateRegionIndicator(currentChannelIdx); + } + } catch (e) { + console.error('Error saving channel scope:', e); + showNotification('Network error saving region scope', 'danger'); + } +} + /** * Send browser notification when new messages arrive * @param {number} channelCount - Number of channels with new messages @@ -4069,8 +4198,13 @@ async function loadChannelsList() { listEl.innerHTML = '
Loading...
'; try { - const response = await fetch('/api/channels'); - const data = await response.json(); + const [chResp, scResp] = await Promise.all([ + fetch('/api/channels'), + fetch('/api/channels/scopes'), + ]); + const data = await chResp.json(); + const scData = await scResp.json().catch(() => ({})); + window.channelScopes = (scData && scData.success) ? (scData.scopes || {}) : {}; if (data.success) { displayChannelsList(data.channels); @@ -4102,9 +4236,15 @@ function displayChannelsList(channels) { const isPublic = channel.index === 0; const isMuted = mutedChannels.has(channel.index); + const scope = (window.channelScopes || {})[String(channel.index)]; + const hasScope = !!scope; + const scopeTitle = hasScope + ? `Region: ${scope.name} — click to change` + : 'Set region scope'; item.innerHTML = `
${escapeHtml(channel.name)} + ${hasScope ? ` ${escapeHtml(scope.name)}` : ''}
+ diff --git a/app/templates/base.html b/app/templates/base.html index 6812e2c..8f6197a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -282,6 +282,31 @@
+ + +