Next/back button assignment moves from a frame-level "Button assignments" card into each widget's own gear-icon dialog, prefilled with a sane default at creation (photos/calendar -> advance/back, whiteboard/weather -> check_now, others -> none). At most one binding per (widget, button) now -- cross-widget execution order never mattered since each widget's action only touches its own state. New firmware capability: holding NEXT or BACK past a configurable duration (min 3s, server-side default) triggers a frame-wide action instead of the per-widget short-press one -- cycling saved layouts, refreshing all widgets, or freezing/unfreezing every photo widget (see app/global_actions.py). Firmware next/back checks gain the same hold-duration polling the combo button already had; the threshold comes from the previous wake's /frame/config fetch (persisted in NVS), since this wake's button decision happens before that request. Not done here: firmware/version.txt is intentionally left unbumped -- this hasn't been built or hardware-tested (no ESP-IDF toolchain in this environment), so no firmware release build should be triggered yet.
391 lines
15 KiB
JavaScript
391 lines
15 KiB
JavaScript
// 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 ----
|
|
//
|
|
// 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 ----
|
|
// 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}.`;
|
|
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);
|
|
|