- 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>
572 lines
22 KiB
JavaScript
572 lines
22 KiB
JavaScript
// Device groups: members (MAC ids) + Wi‑Fi 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' },
|
||
credentials: 'same-origin',
|
||
});
|
||
if (!response.ok) return {};
|
||
const data = await response.json();
|
||
return data && typeof data === 'object' ? data : {};
|
||
} catch (e) {
|
||
console.error('fetchGroupsMap:', e);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
async function fetchDevicesMapForGroups() {
|
||
try {
|
||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||
if (!response.ok) return {};
|
||
const data = await response.json();
|
||
return data && typeof data === 'object' ? data : {};
|
||
} catch (e) {
|
||
console.error('fetchDevicesMapForGroups:', e);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
|
||
if (!containerEl) return;
|
||
containerEl.innerHTML = '';
|
||
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
||
|
||
macRows.forEach((row, idx) => {
|
||
const div = document.createElement('div');
|
||
div.className = 'zone-device-row profiles-row';
|
||
const label = document.createElement('span');
|
||
label.className = 'zone-device-row-label';
|
||
const strong = document.createElement('strong');
|
||
strong.textContent = row.label || row.mac || '—';
|
||
label.appendChild(strong);
|
||
label.appendChild(document.createTextNode(' '));
|
||
const sub = document.createElement('span');
|
||
sub.className = 'muted-text';
|
||
sub.textContent = row.mac || '';
|
||
label.appendChild(sub);
|
||
|
||
const rm = document.createElement('button');
|
||
rm.type = 'button';
|
||
rm.className = 'btn btn-danger btn-small';
|
||
rm.textContent = 'Remove';
|
||
rm.addEventListener('click', () => {
|
||
macRows.splice(idx, 1);
|
||
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
|
||
});
|
||
div.appendChild(label);
|
||
div.appendChild(rm);
|
||
containerEl.appendChild(div);
|
||
});
|
||
|
||
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
|
||
const addWrap = document.createElement('div');
|
||
addWrap.className = 'zone-devices-add profiles-actions';
|
||
const sel = document.createElement('select');
|
||
sel.className = 'zone-device-add-select';
|
||
sel.appendChild(new Option('Add device…', ''));
|
||
entries.forEach(([mac, d]) => {
|
||
if (macsInRows.has(mac)) return;
|
||
const labelName = d && d.name ? String(d.name).trim() : '';
|
||
const optLabel = labelName ? `${labelName} — ${mac}` : mac;
|
||
sel.appendChild(new Option(optLabel, mac));
|
||
});
|
||
const addBtn = document.createElement('button');
|
||
addBtn.type = 'button';
|
||
addBtn.className = 'btn btn-primary btn-small';
|
||
addBtn.textContent = 'Add';
|
||
addBtn.addEventListener('click', () => {
|
||
const mac = sel.value;
|
||
if (!mac || !devicesMap[mac]) return;
|
||
const n = String((devicesMap[mac].name || '').trim() || mac);
|
||
macRows.push({ mac, label: n });
|
||
sel.value = '';
|
||
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
|
||
});
|
||
addWrap.appendChild(sel);
|
||
addWrap.appendChild(addBtn);
|
||
containerEl.appendChild(addWrap);
|
||
refreshEditGroupDebug();
|
||
}
|
||
|
||
function collectGroupEditPayload() {
|
||
const idInput = document.getElementById('edit-group-id');
|
||
const nameInput = document.getElementById('edit-group-name');
|
||
const gid = idInput && idInput.value;
|
||
const rows = window.__editGroupDeviceRows || [];
|
||
const devices = rows.map((r) => r.mac).filter(Boolean);
|
||
const payload = {
|
||
name: nameInput ? nameInput.value.trim() : '',
|
||
devices,
|
||
};
|
||
const dn = document.getElementById('edit-group-wifi-driver-name');
|
||
const nl = document.getElementById('edit-group-wifi-num-leds');
|
||
const co = document.getElementById('edit-group-wifi-color-order');
|
||
const ws = document.getElementById('edit-group-wifi-startup-mode');
|
||
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
|
||
else payload.wifi_driver_display_name = null;
|
||
if (nl && nl.value !== '') {
|
||
const n = parseInt(nl.value, 10);
|
||
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
|
||
else payload.wifi_driver_num_leds = null;
|
||
} else payload.wifi_driver_num_leds = null;
|
||
if (co && co.value) payload.wifi_color_order = co.value;
|
||
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
|
||
const gob = document.getElementById('edit-group-output-brightness');
|
||
if (gob && gob.value !== '') {
|
||
const nb = parseInt(gob.value, 10);
|
||
if (!Number.isNaN(nb)) payload.output_brightness = Math.max(0, Math.min(255, nb));
|
||
}
|
||
return { gid, payload };
|
||
}
|
||
|
||
function refreshEditGroupDebug() {
|
||
const ta = document.getElementById('edit-group-debug');
|
||
if (!ta) return;
|
||
try {
|
||
const { gid, payload } = collectGroupEditPayload();
|
||
const loaded = window.__editGroupLoadedSnapshot;
|
||
ta.value = JSON.stringify(
|
||
{
|
||
group_id: gid || null,
|
||
loaded_from_server: loaded != null ? loaded : null,
|
||
save_payload_preview: payload,
|
||
},
|
||
null,
|
||
2,
|
||
);
|
||
} catch (e) {
|
||
ta.value = String(e);
|
||
}
|
||
}
|
||
|
||
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');
|
||
const wCo = document.getElementById('edit-group-wifi-color-order');
|
||
const wStart = document.getElementById('edit-group-wifi-startup-mode');
|
||
if (wName) {
|
||
const v = g && Object.prototype.hasOwnProperty.call(g, 'wifi_driver_display_name')
|
||
? g.wifi_driver_display_name
|
||
: null;
|
||
wName.value = v != null && String(v).trim() !== '' ? String(v).trim() : '';
|
||
}
|
||
if (wLeds) {
|
||
const v = g && g.wifi_driver_num_leds;
|
||
wLeds.value =
|
||
v != null && v !== '' && String(v).trim() !== ''
|
||
? String(v)
|
||
: '';
|
||
}
|
||
if (wCo) {
|
||
const co = (g && g.wifi_color_order) || 'rgb';
|
||
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
|
||
? String(co).toLowerCase()
|
||
: 'rgb';
|
||
}
|
||
if (wStart) {
|
||
const sm = (g && g.wifi_startup_mode) || 'default';
|
||
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
|
||
? String(sm).toLowerCase()
|
||
: 'default';
|
||
}
|
||
const gob = document.getElementById('edit-group-output-brightness');
|
||
const gobv = document.getElementById('edit-group-output-brightness-value');
|
||
if (gob) {
|
||
let bv = 255;
|
||
if (g && g.output_brightness != null && g.output_brightness !== '') {
|
||
const n = parseInt(String(g.output_brightness), 10);
|
||
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
|
||
}
|
||
gob.value = String(bv);
|
||
if (gobv) gobv.textContent = String(bv);
|
||
}
|
||
}
|
||
|
||
async function openEditGroupModal(groupId, groupDoc) {
|
||
const modal = document.getElementById('edit-group-modal');
|
||
const idInput = document.getElementById('edit-group-id');
|
||
const nameInput = document.getElementById('edit-group-name');
|
||
const editor = document.getElementById('edit-group-devices-editor');
|
||
|
||
let g = groupDoc;
|
||
if (!g || typeof g !== 'object') {
|
||
try {
|
||
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);
|
||
}
|
||
}
|
||
g = g || {};
|
||
try {
|
||
window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g));
|
||
} catch (e) {
|
||
window.__editGroupLoadedSnapshot = g;
|
||
}
|
||
|
||
if (idInput) idInput.value = groupId;
|
||
if (nameInput) nameInput.value = g.name || '';
|
||
|
||
const dm = await fetchDevicesMapForGroups();
|
||
const macs = Array.isArray(g.devices) ? g.devices : [];
|
||
window.__editGroupDeviceRows = macs.map((m) => {
|
||
const mac = String(m).trim().toLowerCase().replace(/:/g, '').replace(/-/g, '');
|
||
const d = dm[mac];
|
||
return {
|
||
mac,
|
||
label: d && d.name ? String(d.name).trim() : mac,
|
||
};
|
||
});
|
||
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
|
||
loadWifiFieldsFromGroup(g);
|
||
syncGroupShareCheckboxFromDoc(g);
|
||
refreshEditGroupDebug();
|
||
if (modal) modal.classList.add('active');
|
||
}
|
||
|
||
async function loadGroupsModal() {
|
||
const container = document.getElementById('groups-list-modal');
|
||
if (!container) return;
|
||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||
try {
|
||
const data = await fetchGroupsMap();
|
||
renderGroupsList(data || {});
|
||
} catch (e) {
|
||
console.error('loadGroupsModal:', e);
|
||
container.innerHTML = '<span class="muted-text">Failed to load groups.</span>';
|
||
}
|
||
}
|
||
|
||
function renderGroupsList(groups) {
|
||
const container = document.getElementById('groups-list-modal');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
const ids = Object.keys(groups).filter((k) => groups[k] && typeof groups[k] === 'object');
|
||
if (ids.length === 0) {
|
||
const p = document.createElement('p');
|
||
p.className = 'muted-text';
|
||
p.textContent = 'No groups yet. Create one to assign devices and Wi‑Fi defaults.';
|
||
container.appendChild(p);
|
||
return;
|
||
}
|
||
ids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||
ids.forEach((gid) => {
|
||
const g = groups[gid];
|
||
const row = document.createElement('div');
|
||
row.className = 'profiles-row';
|
||
row.style.display = 'flex';
|
||
row.style.alignItems = 'center';
|
||
row.style.gap = '0.5rem';
|
||
row.style.flexWrap = 'wrap';
|
||
|
||
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'})`;
|
||
|
||
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';
|
||
editBtn.addEventListener('click', () => openEditGroupModal(gid, g));
|
||
|
||
const brightBtn = document.createElement('button');
|
||
brightBtn.className = 'btn btn-secondary btn-small';
|
||
brightBtn.type = 'button';
|
||
brightBtn.textContent = 'Apply brightness';
|
||
brightBtn.title = 'Push group output brightness to Wi‑Fi drivers in this group';
|
||
brightBtn.addEventListener('click', async () => {
|
||
try {
|
||
const res = await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
alert(data.error || 'Apply brightness failed');
|
||
return;
|
||
}
|
||
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||
alert(
|
||
n
|
||
? `Sent brightness to ${n} driver(s).`
|
||
: 'No Wi‑Fi drivers received brightness (check connections).',
|
||
);
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Apply brightness failed');
|
||
}
|
||
});
|
||
|
||
const applyBtn = document.createElement('button');
|
||
applyBtn.className = 'btn btn-primary btn-small';
|
||
applyBtn.type = 'button';
|
||
applyBtn.textContent = 'Apply defaults to drivers';
|
||
applyBtn.title = 'Push Wi‑Fi defaults to each connected driver in this group';
|
||
applyBtn.addEventListener('click', async () => {
|
||
try {
|
||
const res = await fetch(`/groups/${encodeURIComponent(gid)}/driver-config`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
alert(data.error || 'Apply failed');
|
||
return;
|
||
}
|
||
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||
alert(
|
||
n
|
||
? `Sent defaults to ${n} driver(s).`
|
||
: 'No Wi‑Fi drivers received the config (check defaults and connections).',
|
||
);
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Apply failed');
|
||
}
|
||
});
|
||
|
||
const identifyBtn = document.createElement('button');
|
||
identifyBtn.className = 'btn btn-secondary btn-small';
|
||
identifyBtn.type = 'button';
|
||
identifyBtn.textContent = 'Identify';
|
||
identifyBtn.title =
|
||
'Identify all devices in this group at once (red blink at 10 Hz)';
|
||
identifyBtn.addEventListener('click', async () => {
|
||
await identifyGroupById(gid);
|
||
});
|
||
|
||
const delBtn = document.createElement('button');
|
||
delBtn.className = 'btn btn-danger btn-small';
|
||
delBtn.textContent = 'Delete';
|
||
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',
|
||
credentials: 'same-origin',
|
||
});
|
||
if (res.ok) await loadGroupsModal();
|
||
else {
|
||
const data = await res.json().catch(() => ({}));
|
||
alert(data.error || 'Delete failed');
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Delete failed');
|
||
}
|
||
});
|
||
|
||
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);
|
||
row.appendChild(identifyBtn);
|
||
row.appendChild(delBtn);
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function identifyGroupById(gid) {
|
||
if (!gid) return;
|
||
try {
|
||
const res = await fetch(`/groups/${encodeURIComponent(gid)}/identify`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
alert(data.error || 'Identify failed');
|
||
return;
|
||
}
|
||
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||
const errs = Array.isArray(data.errors) ? data.errors : [];
|
||
const failed = errs.filter((e) => e && e.error).length;
|
||
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
|
||
if (failed) {
|
||
msg += ` ${failed} failed — see console for details.`;
|
||
console.warn('Group identify errors', errs);
|
||
}
|
||
alert(msg);
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('Identify failed');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const groupsBtn = document.getElementById('groups-btn');
|
||
const groupsModal = document.getElementById('groups-modal');
|
||
const groupsCloseBtn = document.getElementById('groups-close-btn');
|
||
const newNameInput = document.getElementById('new-group-name');
|
||
const createBtn = document.getElementById('create-group-btn');
|
||
const editForm = document.getElementById('edit-group-form');
|
||
const editCloseBtn = document.getElementById('edit-group-close-btn');
|
||
const editModal = document.getElementById('edit-group-modal');
|
||
|
||
if (groupsBtn && groupsModal) {
|
||
groupsBtn.addEventListener('click', () => {
|
||
groupsModal.classList.add('active');
|
||
loadGroupsModal();
|
||
});
|
||
}
|
||
if (groupsCloseBtn && groupsModal) {
|
||
groupsCloseBtn.addEventListener('click', () => groupsModal.classList.remove('active'));
|
||
}
|
||
|
||
const grpOutBr = document.getElementById('edit-group-output-brightness');
|
||
const grpOutBrVal = document.getElementById('edit-group-output-brightness-value');
|
||
if (grpOutBr && grpOutBrVal) {
|
||
grpOutBr.addEventListener('input', () => {
|
||
grpOutBrVal.textContent = grpOutBr.value;
|
||
});
|
||
}
|
||
|
||
const editIdentifyBtn = document.getElementById('edit-group-identify-btn');
|
||
if (editIdentifyBtn) {
|
||
editIdentifyBtn.addEventListener('click', async () => {
|
||
const idInput = document.getElementById('edit-group-id');
|
||
const gid = idInput && idInput.value;
|
||
if (!gid) return;
|
||
await identifyGroupById(gid);
|
||
});
|
||
}
|
||
|
||
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,
|
||
profile_scoped: !!(profileOnly && profileOnly.checked),
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
alert(data.error || 'Create failed');
|
||
return;
|
||
}
|
||
if (newNameInput) newNameInput.value = '';
|
||
if (profileOnly) profileOnly.checked = false;
|
||
await loadGroupsModal();
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('Create failed');
|
||
}
|
||
};
|
||
if (createBtn) createBtn.addEventListener('click', createHandler);
|
||
if (newNameInput) {
|
||
newNameInput.addEventListener('keypress', (ev) => {
|
||
if (ev.key === 'Enter') createHandler();
|
||
});
|
||
}
|
||
|
||
if (editForm) {
|
||
editForm.addEventListener('input', () => refreshEditGroupDebug());
|
||
editForm.addEventListener('change', () => refreshEditGroupDebug());
|
||
editForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
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),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
alert(data.error || 'Save failed');
|
||
return;
|
||
}
|
||
try {
|
||
await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
} catch (_) {
|
||
/* ignore push errors after save */
|
||
}
|
||
if (editModal) editModal.classList.remove('active');
|
||
await loadGroupsModal();
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Save failed');
|
||
}
|
||
});
|
||
}
|
||
if (editCloseBtn && editModal) {
|
||
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
|
||
}
|
||
|
||
window.openDeviceGroupsModal = async () => {
|
||
const gm = document.getElementById('groups-modal');
|
||
if (!gm) return;
|
||
gm.classList.add('active');
|
||
try {
|
||
await loadGroupsModal();
|
||
} catch (e) {
|
||
console.error('openDeviceGroupsModal', e);
|
||
}
|
||
};
|
||
});
|