feat(regions): per-channel scope picker + send-flow integration

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/<idx>/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).
This commit is contained in:
MarekWo
2026-04-24 07:27:33 +02:00
parent f04f0f1dd8
commit afe0c7cf17
4 changed files with 250 additions and 4 deletions

View File

@@ -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())

View File

@@ -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/<int:index>/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/<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).

View File

@@ -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 = `
<div class="text-center py-4 text-muted">
<i class="bi bi-pin-map fs-3 d-block mb-2"></i>
<p class="mb-2">No regions defined yet.</p>
<button type="button" class="btn btn-sm btn-primary" id="pickerManageRegionsBtn">
<i class="bi bi-gear"></i> Manage Regions
</button>
</div>
`;
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 = [`
<label class="list-group-item d-flex align-items-center gap-2">
<input type="radio" name="regionPickerChoice" value="" class="form-check-input mt-0"
${_regionPickerPending === null ? 'checked' : ''}>
<span class="text-muted"><i class="bi bi-dash-circle"></i> None — use firmware default</span>
</label>
`];
for (const r of regions) {
rows.push(`
<label class="list-group-item d-flex align-items-center gap-2">
<input type="radio" name="regionPickerChoice" value="${r.id}" class="form-check-input mt-0"
${_regionPickerPending === r.id ? 'checked' : ''}>
<span class="flex-grow-1">
<strong>${escapeHtml(r.name)}</strong>
${r.is_default ? '<span class="badge bg-secondary ms-1">default</span>' : ''}
</span>
</label>
`);
}
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 = '<div class="text-center text-muted py-3"><div class="spinner-border spinner-border-sm"></div> Loading...</div>';
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 = `
<div>
<strong>${escapeHtml(channel.name)}</strong>
${hasScope ? `<span class="badge bg-info text-dark ms-2" style="font-size:0.7em;"><i class="bi bi-pin-map"></i> ${escapeHtml(scope.name)}</span>` : ''}
</div>
<div class="btn-group btn-group-sm">
<button class="btn ${isMuted ? 'btn-secondary' : 'btn-outline-secondary'}"
@@ -4112,6 +4252,10 @@ function displayChannelsList(channels) {
title="${isMuted ? 'Unmute notifications' : 'Mute notifications'}">
<i class="bi ${isMuted ? 'bi-bell-slash' : 'bi-bell'}"></i>
</button>
<button class="btn ${hasScope ? 'btn-info' : 'btn-outline-info'}"
onclick="openRegionPicker(${channel.index})" title="${scopeTitle}">
<i class="bi bi-pin-map"></i>
</button>
<button class="btn btn-outline-primary" onclick="shareChannel(${channel.index})" title="Share">
<i class="bi bi-share"></i>
</button>

View File

@@ -282,6 +282,31 @@
</div>
</div>
<!-- Set Channel Region Scope Modal -->
<div class="modal fade" id="regionPickerModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pin-map"></i>
Set Region Scope for <span id="regionPickerChannelName" class="fw-bold"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle"></i> Only repeaters allowing the selected region will forward messages from this channel.
</div>
<div id="regionPickerList" class="list-group mb-0"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="regionPickerSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Share Channel Modal -->
<div class="modal fade" id="shareChannelModal" tabindex="-1">
<div class="modal-dialog">