chore(release): beta-1.03

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 16:55:07 +12:00
parent 822d9d8e01
commit 0ae39ab94b
22 changed files with 1816 additions and 184 deletions

452
src/static/groups.js Normal file
View File

@@ -0,0 +1,452 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
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 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)}`);
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);
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 WiFi 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'})`;
label.style.flex = '1';
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 WiFi 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 WiFi 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 WiFi 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 WiFi drivers received the config (check defaults and connections).',
);
} catch (err) {
console.error(err);
alert('Apply failed');
}
});
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' });
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');
}
});
row.appendChild(label);
row.appendChild(editBtn);
row.appendChild(brightBtn);
row.appendChild(applyBtn);
row.appendChild(delBtn);
container.appendChild(row);
});
}
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 createHandler = async () => {
const name = newNameInput && newNameInput.value.trim();
if (!name) return;
try {
const res = await fetch('/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Create failed');
return;
}
if (newNameInput) newNameInput.value = '';
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;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT',
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'));
}
});