mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-02 03:22:40 +02:00
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:
@@ -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())
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user