// Configuration tab: frame settings + firmware card + take control. // window.FRAME_API is set by the template. Checkboxes are always sent // explicitly as "true"/"false" -- the server treats absent fields as // "leave unchanged", so a checkbox must never be simply omitted. async function saveConfig() { const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60; const body = new URLSearchParams({ name: document.getElementById('frame_name').value || '', order: document.getElementById('order').value, orientation: document.getElementById('orientation').value, refresh_interval_s: String(minutes * 60), display_mode: document.getElementById('display_mode').value, quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked), quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00', quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00', timezone: document.getElementById('timezone').value || 'UTC', }); const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) { throw new Error(await apiError(resp)); } } document.getElementById('config-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await saveConfig(); showStatus(true, 'Saved.'); } catch (e) { showStatus(false, e.message); } }); async function takeControl() { try { const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'You have control now.'); loadControl(); } catch (e) { showStatus(false, e.message); } } async function loadControl() { const banner = document.getElementById('control-banner'); try { const resp = await fetch(`${window.FRAME_API}/queue`); if (!resp.ok) return; // unconfigured frame: control still works via 409s const data = await resp.json(); if (data.control && !data.control.you) { banner.style.display = 'flex'; document.getElementById('control-holder').textContent = data.control.controller ? `${data.control.controller} currently has control of this frame.` : 'Nobody has control of this frame yet.'; } else { banner.style.display = 'none'; } } catch (e) { /* banner is best-effort */ } } document.getElementById('take-control').addEventListener('click', takeControl); // ---- Advanced configuration: color palette ---- // // Hex field and R/G/B number fields are kept in sync live, both // directions -- editing either updates the other plus the preview // swatch. Hex stays the field actually read at save time (it's what // the server already validates as #rrggbb); the R/G/B fields are purely // an alternate, more precise way to arrive at the same value than // eyeballing a color-picker swatch. function paletteHexInputs() { return Array.from(document.querySelectorAll('.palette-hex')) .sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index)); } function hexFromRgb(r, g, b) { const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0))); return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join(''); } function rgbFromHex(hex) { const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim()); if (!m) return null; const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function paletteFieldsFor(index) { const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`); return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') }; } function syncPaletteFromHex(index) { const f = paletteFieldsFor(index); const rgb = rgbFromHex(f.hex.value); if (!rgb) return; [f.r.value, f.g.value, f.b.value] = rgb; f.swatch.style.background = f.hex.value; } function syncPaletteFromRgb(index) { const f = paletteFieldsFor(index); const hex = hexFromRgb(f.r.value, f.g.value, f.b.value); f.hex.value = hex; f.swatch.style.background = hex; } const palettePickerCount = paletteHexInputs().length; for (let i = 0; i < palettePickerCount; i++) { const f = paletteFieldsFor(i); f.hex.addEventListener('input', () => syncPaletteFromHex(i)); [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i))); } // Sliders: live numeric readout next to each, no save until the button // below is clicked. ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { const input = document.getElementById(id); const readout = document.getElementById(`${id}_value`); input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); }); }); async function savePalette(extra) { const body = new URLSearchParams(extra || {}); for (const input of paletteHexInputs()) { body.append('palette', input.value); } try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); loadPreview(); } catch (e) { showStatus(false, e.message); } } document.getElementById('palette-save').addEventListener('click', () => { savePalette({ color_boost: document.getElementById('color_boost').value, contrast_boost: document.getElementById('contrast_boost').value, dither_strength: document.getElementById('dither_strength').value, }); }); document.getElementById('palette-reset').addEventListener('click', () => { const inputs = paletteHexInputs(); window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; syncPaletteFromHex(i); }); ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { document.getElementById(id).value = '1'; document.getElementById(`${id}_value`).textContent = '1.00'; }); savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' }); }); // ---- Preview: current photo vs. how it renders with saved settings ---- function loadPreview() { const bust = Date.now(); // avoid a stale cached image after settings change document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`; document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`; } document.getElementById('preview-refresh').addEventListener('click', loadPreview); loadPreview(); // ---- Battery alerts card ---- document.getElementById('battery-alert-save').addEventListener('click', async () => { const raw = document.getElementById('battery_alert_threshold_pct').value.trim(); const body = new URLSearchParams({ battery_alert_threshold_pct: raw === '' ? '-1' : raw, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); } catch (e) { showStatus(false, e.message); } }); // ---- Firmware card ---- document.getElementById('firmware-upload').addEventListener('click', async () => { const input = document.getElementById('firmware-file'); if (!input.files.length) { showStatus(false, 'Pick a firmware .bin first.'); return; } const form = new FormData(); form.append('file', input.files[0]); try { const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form }); if (!resp.ok) { throw new Error(await apiError(resp)); } const result = await resp.json(); document.getElementById('firmware-available').textContent = `Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`; showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`); } catch (e) { showStatus(false, e.message); } }); function showRepoDisplayMode(url) { document.getElementById('firmware-repo-text').textContent = url; document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none'; document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block'; } document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => { document.getElementById('firmware-repo-display').style.display = 'none'; document.getElementById('firmware-repo-edit').style.display = 'block'; document.getElementById('firmware_update_repo_url').focus(); }); document.getElementById('firmware-settings-save').addEventListener('click', async () => { try { const body = new URLSearchParams({ firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '', firmware_auto_update: String(document.getElementById('firmware_auto_update').checked), }); const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim()); loadFirmwareCheck(); } catch (e) { showStatus(false, e.message); } }); async function loadFirmwareCheck(force) { const statusEl = document.getElementById('firmware-gitea-status'); const btn = document.getElementById('firmware-update-btn'); const boardEl = document.getElementById('firmware-board'); try { const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : '')); if (!resp.ok) { if (force) { showStatus(false, await apiError(resp)); } return; } const data = await resp.json(); if (data.board) { boardEl.textContent = `Detected board: ${data.board}`; } if (!data.enabled) { statusEl.style.display = 'none'; btn.style.display = 'none'; if (force) { showStatus(false, 'No Gitea repo URL configured.'); } return; } statusEl.style.display = 'block'; if (!data.board) { statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.'; btn.style.display = 'none'; } else if (data.update_available) { statusEl.textContent = `Update available: v${data.latest_version}.`; btn.style.display = 'inline-block'; } else if (data.latest_version) { statusEl.textContent = `Up to date (v${data.latest_version}).`; btn.style.display = 'none'; } else { statusEl.textContent = 'No releases found yet.'; btn.style.display = 'none'; } if (force) { showStatus(true, 'Checked.'); } } catch (e) { // A failed passive poll is silent; an explicit "Check now" click // still surfaces the error. if (force) { showStatus(false, e.message); } } } document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true)); document.getElementById('firmware-update-btn').addEventListener('click', async () => { const btn = document.getElementById('firmware-update-btn'); btn.disabled = true; try { const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' }); if (!resp.ok) { throw new Error(await apiError(resp)); } const result = await resp.json(); document.getElementById('firmware-available').textContent = `Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`; showStatus(true, `Firmware v${result.version} staged from Gitea.`); loadFirmwareCheck(); } catch (e) { showStatus(false, e.message); } finally { btn.disabled = false; } }); loadControl(); loadFirmwareCheck(); // The server throttles actual Gitea API calls itself, so this poll is // cheap either way. setInterval(loadFirmwareCheck, 60000);