// 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 = 'Loading...'; try { const data = await fetchGroupsMap(); renderGroupsList(data || {}); } catch (e) { console.error('loadGroupsModal:', e); container.innerHTML = 'Failed to load groups.'; } } 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); } }; });