feat(zones): profile-scoped groups, zone modes, sequence brightness

- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 01:58:00 +12:00
parent c1c3e5d71b
commit 6c9e06f33b
21 changed files with 1034 additions and 604 deletions

View File

@@ -1,8 +1,27 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
async function getCurrentProfileIdForGroups() {
try {
const res = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
return id != null ? String(id) : null;
} catch {
return null;
}
}
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
const response = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
@@ -137,6 +156,14 @@ function refreshEditGroupDebug() {
}
}
function syncGroupShareCheckboxFromDoc(g) {
const cb = document.getElementById('edit-group-share-all-profiles');
if (!cb) return;
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
const scoped = raw != null && String(raw).trim() !== '';
cb.checked = !scoped;
}
function loadWifiFieldsFromGroup(g) {
const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds');
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
let g = groupDoc;
if (!g || typeof g !== 'object') {
try {
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
if (response.ok) g = await response.json();
} catch (e) {
console.error(e);
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
});
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g);
syncGroupShareCheckboxFromDoc(g);
refreshEditGroupDebug();
if (modal) modal.classList.add('active');
}
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const meta = document.createElement('div');
meta.className = 'muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
@@ -342,7 +378,10 @@ function renderGroupsList(groups) {
delBtn.addEventListener('click', async () => {
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.ok) await loadGroupsModal();
else {
const data = await res.json().catch(() => ({}));
@@ -354,7 +393,12 @@ function renderGroupsList(groups) {
}
});
row.appendChild(label);
const left = document.createElement('div');
left.style.flex = '1';
left.style.minWidth = '0';
left.appendChild(label);
left.appendChild(meta);
row.appendChild(left);
row.appendChild(editBtn);
row.appendChild(brightBtn);
row.appendChild(applyBtn);
@@ -433,11 +477,16 @@ document.addEventListener('DOMContentLoaded', () => {
const createHandler = async () => {
const name = newNameInput && newNameInput.value.trim();
if (!name) return;
const profileOnly = document.getElementById('new-group-profile-only');
try {
const res = await fetch('/groups', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ name }),
body: JSON.stringify({
name,
profile_scoped: !!(profileOnly && profileOnly.checked),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -445,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
if (newNameInput) newNameInput.value = '';
if (profileOnly) profileOnly.checked = false;
await loadGroupsModal();
} catch (e) {
console.error(e);
@@ -466,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => {
const { gid, payload } = collectGroupEditPayload();
if (!gid) return;
const shareCb = document.getElementById('edit-group-share-all-profiles');
if (shareCb && shareCb.checked) {
payload.profile_id = null;
} else {
const pid = await getCurrentProfileIdForGroups();
payload.profile_id = pid || null;
}
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});