Build and push server image / build-and-push (push) Successful in 39s
/api/frames/{id}/firmware/check could silently stage new firmware as a
side effect (the auto-apply path, when firmware_auto_update is on and
a newer release exists) but was gated by require_frame_view instead of
require_frame_control like its sibling firmware routes, and being a
GET, was exempt from the app's CSRF check (which only applies to
non-GET/HEAD/OPTIONS). A linked viewer without control -- or a
cross-site page riding a control-holding victim's session via a plain
GET -- could trigger an unreviewed firmware install. Now POST +
require_frame_control, matching /firmware/apply-latest; the frontend's
two callers (passive poll on page load, "Check now" button) both
already handle a 409 from a non-controller gracefully via the existing
apiError()/control-banner pattern, so this doesn't change UX for a
frame's actual controller.
Separately: Immich has been observed to return a face detection entry
with a null bounding-box field (a still-pending or otherwise
incomplete detection). Both places that do arithmetic on those fields
-- image_pipeline._face_aware_crop_box (crop_faces display mode) and
face_labels.compute_face_labels (manage-menu name labels) -- crashed
with an unhandled TypeError on such an entry, taking down that frame's
whole photo instead of the intended graceful fallback. Both now skip
any face missing a bounding-box field via a shared _has_bounding_box()
check; a face list with zero valid entries already degrades cleanly to
the plain center crop (the existing inf/-inf sentinel math already
handled "no faces" correctly, it just couldn't tell "none passed
Immich" apart from "one broken entry" before).
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
// 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' : ''), { 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);
|