Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.
Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.
API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.
UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.
Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
84 lines
2.9 KiB
JavaScript
84 lines
2.9 KiB
JavaScript
// Device status bar: always-visible strip (below the page title, above
|
|
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
|
|
// battery, so it's not tucked away on just the Stats tab. Shared by
|
|
// every frame page; each sets window.FRAME_BASE_API before this loads
|
|
// -- a stable frame-level base, unlike window.FRAME_API, which the
|
|
// Layout page's widget dialogs repoint to a widget-scoped base while
|
|
// one is open.
|
|
|
|
let lastDeviceStatus = null;
|
|
|
|
function renderDeviceStatusBar(device) {
|
|
const el = document.getElementById('device-status');
|
|
if (!el) return;
|
|
el.innerHTML = '';
|
|
if (!device || !device.last_seen) {
|
|
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
|
return;
|
|
}
|
|
const now = Date.now() / 1000;
|
|
const rows = [];
|
|
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
|
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
|
if (device.firmware_version) {
|
|
let fw = `v${device.firmware_version}`;
|
|
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
|
fw += ` (v${device.firmware_available} waiting)`;
|
|
}
|
|
rows.push(['Firmware', fw, false]);
|
|
}
|
|
if (device.battery) {
|
|
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
|
// Shown as soon as there's any battery reading at all, even before
|
|
// battery_estimate_s has enough discharge samples in battery_log to
|
|
// average (see common.py) -- so it's clear the number is coming, not
|
|
// that the feature is broken.
|
|
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
|
|
rows.push([
|
|
'Est. battery life left',
|
|
hasEstimate ? `~${formatDuration(device.battery_estimate_s)}` : 'Not enough data yet',
|
|
false,
|
|
]);
|
|
}
|
|
for (const [label, value, alert] of rows) {
|
|
const stat = document.createElement('span');
|
|
stat.className = 'device-stat' + (alert ? ' alert' : '');
|
|
const labelPart = document.createTextNode(label + ': ');
|
|
const valuePart = document.createElement('strong');
|
|
valuePart.textContent = value;
|
|
stat.appendChild(labelPart);
|
|
stat.appendChild(valuePart);
|
|
el.appendChild(stat);
|
|
}
|
|
}
|
|
|
|
async function loadDeviceStatusBar() {
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_BASE_API}/status`);
|
|
if (!resp.ok) {
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
lastDeviceStatus = data.device;
|
|
renderDeviceStatusBar(data.device);
|
|
} catch (e) { /* retried on the next poll */ }
|
|
}
|
|
|
|
loadDeviceStatusBar();
|
|
|
|
document.addEventListener('themechange', () => {
|
|
if (lastDeviceStatus) {
|
|
renderDeviceStatusBar(lastDeviceStatus);
|
|
}
|
|
});
|
|
|
|
// Fast tick: re-renders "Last seen" from already-fetched data every
|
|
// second so it counts up smoothly without hitting the server that often.
|
|
setInterval(() => {
|
|
if (lastDeviceStatus) {
|
|
renderDeviceStatusBar(lastDeviceStatus);
|
|
}
|
|
}, 1000);
|
|
|
|
setInterval(loadDeviceStatusBar, 10000);
|