// Configuration tab: frame-wide settings (orientation, quiet hours, // palette/color/contrast/dither, firmware, battery alerts) + take // control. Frame name lives in the page header now (frame_header.js); // every per-widget setting (album, calendar view/inclusion, whiteboard // source) lives in its own widget's gear-icon dialog instead (see // static/frame_layout.js) -- this tab never touches those. // 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({ orientation: document.getElementById('orientation').value, refresh_interval_s: String(minutes * 60), 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)); } } // Orientation swaps the widget grid's long/short axis (see // grid.grid_dims), so an existing widget layout is usually left with // out-of-bounds coordinates on the new grid -- the server resets it to // one full-panel widget when this actually changes (see // api_frames.py's api_config_save). Warn before that happens rather // than silently losing whatever layout was on the Layout tab. let lastSavedOrientation = document.getElementById('orientation').value; document.getElementById('config-form').addEventListener('submit', async (e) => { e.preventDefault(); const newOrientation = document.getElementById('orientation').value; if (newOrientation !== lastSavedOrientation) { const proceed = confirm( "Changing orientation resets this frame's widget layout to a single " + 'full-panel widget -- any other widgets placed on the Layout tab will ' + 'be removed. Continue?' ); if (!proceed) { document.getElementById('orientation').value = lastSavedOrientation; return; } } try { await saveConfig(); lastSavedOrientation = newOrientation; showStatus(true, 'Saved.'); } catch (e) { showStatus(false, e.message); } }); // Hold-for-global-action (see app/global_actions.py) -- a frame-wide // setting, not per-widget, so it shares api_config_save/the /config // endpoint rather than getting its own -- just a separate card/form on // this page for a distinct-enough concern. document.getElementById('hold-config-form').addEventListener('submit', async (e) => { e.preventDefault(); const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3; const body = new URLSearchParams({ hold_duration_ms: String(seconds * 1000), next_hold_action: document.getElementById('next_hold_action').value, back_hold_action: document.getElementById('back_hold_action').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.'); } catch (err) { showStatus(false, err.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}/status`); 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(s) ---- // // 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. // // Parameterized by classPrefix ("palette" for the main one, "photo- // palette" for the photos-only one added alongside it) rather than // duplicated wholesale -- there are exactly two real instances of this, // not a speculative future one, and the two would otherwise be ~90 // near-identical lines apart. function paletteHexInputs(classPrefix) { return Array.from(document.querySelectorAll(`.${classPrefix}-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(classPrefix, index) { const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`); return { hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`), swatch: at(`${classPrefix}-swatch-preview`), }; } function syncPaletteFromHex(classPrefix, index) { const f = paletteFieldsFor(classPrefix, 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(classPrefix, index) { const f = paletteFieldsFor(classPrefix, index); const hex = hexFromRgb(f.r.value, f.g.value, f.b.value); f.hex.value = hex; f.swatch.style.background = hex; } function wirePaletteInputs(classPrefix) { const count = paletteHexInputs(classPrefix).length; for (let i = 0; i < count; i++) { const f = paletteFieldsFor(classPrefix, i); f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i)); [f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i))); } } wirePaletteInputs('palette'); wirePaletteInputs('photo-palette'); // Sliders: live numeric readout next to each, no save until the button // below is clicked. ['color_boost', 'contrast_boost', 'dither_strength', 'photo_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(classPrefix, paletteFormKey, extra) { const body = new URLSearchParams(extra || {}); for (const input of paletteHexInputs(classPrefix)) { body.append(paletteFormKey, 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('palette', 'palette', { 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('palette'); window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; syncPaletteFromHex('palette', i); }); ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { document.getElementById(id).value = '1'; document.getElementById(`${id}_value`).textContent = '1.00'; }); savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' }); }); // Fills the table with a community-measured starting point (see the // card's own explanatory text) -- doesn't save by itself, same as // editing the hex/RGB fields by hand; the user still clicks Save (or // Reset) to commit or discard it. document.getElementById('palette-load-calibrated').addEventListener('click', () => { const inputs = paletteHexInputs('palette'); window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => { inputs[i].value = hex; syncPaletteFromHex('palette', i); }); }); // ---- Photos configuration: its own separate palette/dithering ---- document.getElementById('photo-palette-save').addEventListener('click', () => { savePalette('photo-palette', 'photo_palette', { photo_dither_strength: document.getElementById('photo_dither_strength').value, }); }); document.getElementById('photo-palette-reset').addEventListener('click', () => { const inputs = paletteHexInputs('photo-palette'); window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { inputs[i].value = hex; syncPaletteFromHex('photo-palette', i); }); document.getElementById('photo_dither_strength').value = '1'; document.getElementById('photo_dither_strength_value').textContent = '1.00'; savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' }); }); // ---- Theme ---- document.getElementById('theme-save').addEventListener('click', async () => { const body = new URLSearchParams({ theme: document.getElementById('theme-select').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); } }); // ---- Preview: current photo vs. how it renders with saved settings ---- // Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, // set by the template) rather than window.FRAME_API -- palette/color/ // contrast/dither are frame-level, but "the current photo" to preview // them against is necessarily one specific photo widget's. Null (no // photo widget on this frame) means the template didn't render the // preview section at all -- nothing to wire up. function loadPreview() { if (!window.PHOTO_WIDGET_PREVIEW_API) return; const bust = Date.now(); // avoid a stale cached image after settings change document.getElementById('preview-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`; document.getElementById('preview-rendered').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/rendered?_=${bust}`; } const previewRefreshBtn = document.getElementById('preview-refresh'); if (previewRefreshBtn) previewRefreshBtn.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' : ''), { method: 'POST' }); 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}` + (data.running_version ? ` (currently running v${data.running_version}).` : '.'); btn.style.display = 'inline-block'; } else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) { // Already staged (or auto-applied) but the frame hasn't woken up // and picked it up yet -- not "up to date" until it actually has. statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` + `(currently running v${data.running_version}).`; btn.style.display = 'none'; } 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);