70 Commits
Author SHA1 Message Date
tfaour 5247f5e512 Merge remote-tracking branch 'origin/main'
Build and push server image / test (push) Successful in 1m15s
Build and push server image / build-and-push (push) Successful in 5m1s
Build and push server image / deploy (push) Successful in 52s
Resolved CURRENT TODO conflict: kept Thomas's reformatted list and new
items, dropped the two scan-to-download lines this branch just finished.
2026-07-27 14:39:27 +00:00
tfaour 8bcc574f99 Update CLAUDE.md: mark scan-to-download TODOs done, add commit/push convention
Standing convention: commit and push once a task is verified working,
without waiting for a separate go-ahead each time -- see the new bullet
under Conventions specific to this repo.
2026-07-27 14:38:36 +00:00
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00
tfaour 6fa3e2c2d2 Update CLAUDE.md
Updated todo
2026-07-26 23:39:33 -04:00
tfaour af513c1b5a Added a todo 2026-07-27 02:33:52 +00:00
tfaour c7b164e6cd Update CLAUDE.md 2026-07-26 18:40:40 -04:00
tfaour 5dfa6c8197 Update CLAUDE.md 2026-07-26 09:15:30 -04:00
tfaour e362519261 Update CLAUDE.md
Just adding some todos
2026-07-25 15:08:41 -04:00
Thomas Faour c1c657c0a9 Saved layouts feature
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
2026-07-25 18:18:48 +00:00
Thomas Faour edbd90745b Add font family choice to the text widget, move toolbar below the editor
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 51s
Six more vendored families alongside the existing Noto Sans (Inter,
Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/
serif/slab/mono variety), all OFL-licensed with their own per-family
license file in app/fonts/ since each has a different copyright holder.
Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only
families (Inter and Source Sans's current Google Fonts releases, plus
Playfair Display/Lora/Merriweather) were skipped in favor of static
builds from their own upstream repos, keeping every family's loading
code uniform with what was already there. Considered but deliberately
left out: Georgia -- a proprietary Microsoft core font, not freely
redistributable, unlike everything else vendored here.

Also moves the bold/italic/underline/color toolbar below the
contenteditable box per request, and reorders the dialog's Settings
card to a more natural family-then-size order.

Fixes a latent migration bug this surfaced: migration 20 (static image
widget) used Base.metadata.create_all, which creates every table
declared in Base.metadata that's missing, not just its own new one --
harmless when nothing else pending, but once TextWidgetConfig existed
it would silently pre-create text_widget_configs (in whatever shape
models.py currently declares) before migration 21 got a turn, so
migration 21's own CREATE TABLE (or a later ALTER TABLE adding
font_family) would collide with a table create_all had already leaked
into existence. Both migrations 20 and 21 now use raw, frozen CREATE
TABLE SQL instead, matching migration 17's existing precedent for
exactly this reason.
2026-07-25 16:05:22 +00:00
Thomas Faour 3735c5bfa7 Add a text widget (rich text: bold/italic/underline, per-run color/highlight)
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 1m9s
A new self-contained widget type showing user-authored rich text -- no
live upstream to poll, like the static image widget, just word-wrapped
styled text instead of an uploaded image.

The dialog's contenteditable HTML is never stored or replayed as HTML:
app/text_content.py parses it server-side (on save) into a plain
paragraphs-of-styled-runs structure -- the actual sanitization
boundary, since raw HTML never round-trips back into any browser DOM
(the dialog rebuilds its editor from that same JSON via
createElement/textContent). app/widgets/text.py renders it with a
custom word-wrap/shrink-to-fit layout, using real vendored font weights
(app/fonts/NotoSans-{Regular,Bold,Italic,BoldItalic}.ttf, OFL-licensed
like the emoji fonts already there) rather than every other widget's
single ImageFont.load_default() -- the one widget type where that
distinction matters.
2026-07-25 14:21:52 +00:00
Thomas Faour f1fda9bdee Move make-widget/run-server skills to root .claude/skills/
Nested .claude/skills/ dirs (previously under server/) are only
auto-discovered on-demand once a file under that subdirectory is
touched, so /make-widget and /run-server weren't invocable from a
fresh session. Root .claude/skills/ is scanned at session start.

Fixes setup.sh/start-server.sh's relative cd-depth math (was hardcoded
for the old server/.claude/skills/run-server/ depth) to instead
resolve the repo root via git and cd into server/ explicitly, and
updates SKILL.md/driver.py's path references to match the new layout.
2026-07-25 12:09:39 +00:00
Thomas Faour b2f63601c0 Add a make-widget skill; belatedly document the static image widget
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 55s
The static-image widget shipped without updating docs/widgets.md's
per-type enumeration -- fixed, and added a skill encoding the full
file-by-file checklist a new widget type touches, so future ones (a
text widget is next) don't repeat either gap.
2026-07-25 11:56:14 +00:00
Thomas Faour 35e80c6d1c Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 59s
A new widget type showing a single user-uploaded image with no live
upstream to poll -- decoded once at upload time (PDF's first page via
pypdfium2, BSD-3-Clause/Apache-2.0, no copyleft exposure) into plain RGB
PNG bytes, then composed per a crop/stretch/letterbox display mode like
the photos widget.
2026-07-25 08:39:09 +00:00
Thomas Faour 4a2b1f3795 Let a tasks widget have a custom on-panel name
Build and push server image / test (push) Successful in 25s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 53s
Adds TaskWidgetConfig.name (migration 19, plain column add) shown at
the top of the widget on the actual panel instead of the hardcoded
"Tasks" header -- e.g. "Chores" or "Mom's list". The only widget type
with its own on-panel title at all, since it's the only one where
"which list is this" isn't already obvious from a calendar/photo/
whiteboard's own content.

Threaded through calendar_render's _draw_tasks/_build_tasks/
render_tasks/render_tasks_preview_png as a `title` param (default
"Tasks", truncated to fit -- a long custom name shouldn't be able to
overflow the widget's box), the config-save endpoint (tasks_name,
truncated server-side to a sane header length rather than rejected),
and a new "Settings" section in the tasks dialog.

Verified live in the browser: the name actually renders at the top of
the real composited panel (not just the dialog preview, which stays
gated on having a configured source), and persists correctly on both
desktop and mobile. Full suite (196 tests) passes.
2026-07-25 04:05:36 +00:00
Thomas Faour 14c47aa2a0 Let a tasks widget merge multiple task lists, checkbox+color like calendar
Build and push server image / test (push) Successful in 27s
Build and push server image / build-and-push (push) Successful in 2m8s
Build and push server image / deploy (push) Successful in 59s
Tasks widgets could only ever point at one CalDAV task list (a radio-
button picker, owner-only). Now they merge any number of included task
lists across every linked user, same checkbox-inclusion + optional
pinned-color shape a calendar widget already has for its calendars --
FrameTaskList mirrors FrameCalendar exactly, down to the same owner-
adds/anyone-mutes permission split (api_widget_task_list_select/
api_widget_task_list_color). Reused calendar_render._event_colors/
_draw_color_bar as-is for the per-task color bar -- a task dict's
owner_display_name/color_index is exactly that function's single-
source fallback shape.

Also added an opt-in "show tasks completed in the last 24 hours"
toggle (TaskWidgetConfig.show_completed): caldav_client.fetch_tasks
now accepts a completed_since cutoff and returns completed VTODOs
(with their completion time) instead of silently dropping them, and
_draw_tasks gives a completed task a filled checkbox + muted text
instead of the normal empty-box/due-date row.

Migration 18 splits the single-source TaskWidgetConfig columns
(added by 17, splitting tasks out of the calendar widget in the first
place) into frame_task_lists, carrying forward each widget's existing
single source as its first included list -- same shape migration 9
used carrying forward frame_calendars' old single opt-in.

Verified live in the browser (desktop + mobile): the new "Included
task lists" + "Recently completed" dialog sections, the show_completed
toggle actually persisting through a real HTTP round-trip, and no
regression in the calendar widget's own "Included calendars" dialog.
Full suite (192 tests, including new merge_tasks/config_save/migration
coverage) passes.
2026-07-25 03:39:26 +00:00
Thomas Faour b5c52004c8 Split the tasks feature out of the calendar widget into its own widget type
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00:00
Thomas Faour 9f3f4b6f62 Add a "Clear all" button to the Layout tab's widget canvas
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 54s
One request (DELETE /api/frames/{id}/widgets) removes every widget on
the frame in a single locked transaction, cascading their configs and
button-action bindings the same way single-widget delete already does.
Gated behind confirm() like the existing per-widget remove button, and
disabled when there's nothing to clear. Covered by the same owner/
unrelated-user/linked-but-not-controlling permission shape used
elsewhere (test_widget_placement.py).

Also fixes a real, pre-existing mobile bug this surfaced: the Layout
page's .layout grid used a bare `1fr` track on the <860px breakpoint
instead of `minmax(0, 1fr)` like the desktop rule already does, so a
wide enough descendant (previously nothing hit this; the new title-row
button did) would force the whole page into horizontal scroll on phone
widths. Verified before/after with the run-server driver's new
`viewport` command.
2026-07-25 01:32:25 +00:00
Thomas Faour 0e35735a2a Add mobile-viewport testing to run-server + require it in CLAUDE.md
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m2s
Build and push server image / deploy (push) Successful in 55s
Confirmed the theatre-mode preview dialog actually renders correctly
at phone widths (390x844) -- centers properly, backdrop and close
button both fine, nothing overflows. Added a `viewport` command to the
driver (defaults to mobile, since that's the step easy to skip) and a
CLAUDE.md rule to screenshot both breakpoints for future UI changes,
since the 860px sidebar/mobile-bar fork is a real, previously-hit
source of bugs here.
2026-07-25 01:19:44 +00:00
Thomas Faour 20c7620393 Add a run-server skill for launching + browser-driving the FastAPI app
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 58s
This sandbox ships with no Python/Node/Docker/browser and no sudo, so
the bulk of this is setup.sh: bootstrap Python via uv, then get
Playwright's Chromium (and tmux, also missing) working by extracting
their .deb dependencies non-root instead of apt-get install. driver.py
is a small Playwright REPL standing in for chromium-cli, which isn't
available here either.

Also carves out .claude/skills/ from the blanket .claude/ gitignore --
skills are shared project tooling, not personal/local state.
2026-07-25 01:12:53 +00:00
Thomas Faour 173d82a238 Add theatre-mode dialog for the frame header preview thumbnail
Build and push server image / test (push) Successful in 23s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 55s
Clicking the small preview thumbnail now opens an enlarged version in
a dialog (fresh render, same as the old click-to-refresh), closable
via the X button or a backdrop click -- mirroring #widget-dialog's
existing pattern.
2026-07-25 00:50:55 +00:00
tfaour 914eaed71c Add CLAUDE.md and docs/widgets.md; fix stale single-mode architecture docs
Repo-tracked context so a fresh Claude Code session (this machine or a
remote/cloud one) gets accurate project context without relying on this
session's local, machine-specific memory: repo conventions (no
co-author trailers, flag copyleft deps explicitly, scope security
gates broadly -- each backed by a real past incident), testing/deploy
workflow, and pointers into the existing docs.

docs/architecture.md's sequence diagram and boot-flow text still
described the pre-widget-system single-photo-queue model (e.g. "force-
advance to next queued photo") even though that was fully replaced by
the widget system across this branch's recent history -- fixed, and
added docs/widgets.md distilling the widget system's actual design
(data model, grid placement, compositor, button-action dispatch) as
current-state documentation, including the still-open Phase 6 cleanup
(legacy Frame columns not yet dropped) as a known gap.
2026-07-24 20:04:08 -04:00
tfaour 289d308b57 Disambiguate same-type widgets in the button-assignment dropdowns
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 56s
Two widgets of the same type (e.g. two Photos widgets) both showed up
as plain "Photos" with no way to tell which was which. The buttons API
now includes each widget's grid placement plus the frame's grid dims;
the UI derives a rough position ("top-left", "right", etc.) from that
and only appends a number+position suffix when a type actually
repeats on the frame, leaving the common single-widget-per-type case
unchanged.
2026-07-24 18:00:01 -04:00
tfaour 8b9f636cce Mark whiteboard as (alpha); add the Phase 5 button-assignment UI
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 52s
Whiteboard rendering isn't fully reliable yet -- tag it (alpha)
everywhere it's user-facing (widget label, add-widget button, dialog
title, Settings' WebDAV section) via one shared WIDGET_LABELS map
(moved to common.js so both the Layout canvas and the new Configuration
tab section can use it).

Button assignments: a new "Button assignments" card on the
Configuration tab lets you assign an ordered list of (widget, action)
bindings to each physical NEXT/BACK button -- add/remove/reorder, all
autosaved. Backed by new GET/PUT /api/frames/{id}/buttons endpoints;
PUT replaces a button's whole list in one atomic, fully-validated call
rather than separate add/remove/reorder endpoints. Device-side
consumption already existed (routers/device.py's _run_button_actions);
this is the UI for what was previously only reachable via the default
migration mapping.
2026-07-24 17:13:51 -04:00
tfaour 9c8a87e90d Add a live preview thumbnail next to the frame name
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 48s
Small header thumbnail showing exactly what the frame is currently
displaying -- same widget compositor /frame/image uses, handed back as
a plain PNG (image_pipeline.render_panel/render_placeholder gain an
as_png option) instead of packed native-panel bytes. New session-authed
GET /api/frames/{id}/preview exposes it; click-to-refresh plus a slow
60s poll on the frame header so it doesn't hammer Immich/calendar
sources just for a header thumbnail.
2026-07-24 15:55:59 -04:00
tfaour 82f60ed428 Auto-reset device_token_ack when a reprovisioned frame is reclaimed
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m58s
Build and push server image / deploy (push) Successful in 53s
Once set, device_token_ack permanently locks out id-only requests
(auth.require_device) -- fine for a device that still has its token,
but a reprovisioned device has wiped its own token locally and had no
way back in short of a manual DB edit. The device's captive portal
always redirects the phone to /claim?device_id=... after
(re)provisioning, so reopen the handshake window there instead,
scoped to a logged-in owner/linked user of that frame.
2026-07-24 15:30:45 -04:00
tfaour 569bf733e9 Show save/error status inside the open widget dialog, not behind it
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 54s
showStatus() always wrote to the page-level #result div, which sits
behind a <dialog>'s backdrop -- a save inside a widget's gear-icon
dialog produced a message the user couldn't see without closing the
dialog first. It now prefers a .dialog-result element inside whichever
<dialog> is currently open, falling back to #result everywhere else.

The dialog is a flex column with its body scrolling independently so
.dialog-result stays pinned as a visible footer regardless of scroll
position -- otherwise a save message on a long form (e.g. the calendar
dialog) could land off-screen below the fold with no visible feedback
at all.
2026-07-24 14:48:50 -04:00
tfaour cb11ffdd2d Fix Load Albums button in the photos widget dialog
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 56s
The dialog calls /albums via window.FRAME_API, but that's repointed to
this widget's own API base while a dialog is open (see frame_layout.js)
-- /albums is frame-level (lists the owner's whole Immich library, not
scoped to one photo widget), so it needs window.FRAME_BASE_API instead.
The button silently 404'd since the resulting URL never existed.
2026-07-24 14:39:01 -04:00
tfaour a33a3a71e4 Widget system Phase 4b: per-widget gear-icon config dialogs
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s
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.
2026-07-24 14:31:24 -04:00
tfaour 63751a79ad Update .gitea/workflows/server-docker-build.yml
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m30s
Build and push server image / deploy (push) Successful in 54s
Fix user name
2026-07-24 13:39:02 -04:00
tfaour 86b94a9e64 Auto-deploy the server image to production over SSH after a build
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m56s
Build and push server image / deploy (push) Failing after 1s
Adds a deploy job to the existing build-and-push workflow: SSHes into
the deploy host as a dedicated espressoframeuser account and runs
docker compose pull && up -d. Runs only after build-and-push succeeds,
using a key/host pulled from repo secrets (DEPLOY_SSH_KEY, DEPLOY_HOST,
optional DEPLOY_PORT).
2026-07-24 13:34:50 -04:00
tfaour 77fe78d874 Fix Layout tab canvas not rendering on mobile
Build and push server image / test (push) Successful in 20s
Build and push server image / build-and-push (push) Successful in 1m58s
The placement canvas sized itself with CSS aspect-ratio plus
percentage widths/heights on the widget boxes. aspect-ratio isn't
supported on every mobile browser this app gets viewed from, and
without it a percentage height on an absolutely-positioned child
collapses to 0 against an indeterminate-height ancestor -- the canvas
rendered with no visible size at all, just unstyled labels and
buttons floating in normal document flow. Confirmed working on
desktop, broken on the reporter's phone.

Canvas and widget-box geometry is now computed and applied in pixels
from JS (measuring the wrap's own width, deriving height from the
grid's row/col ratio), recalculated on window resize. Also gives
.widget-box's color-mix() background a plain-color fallback for the
same class of older-browser gap.
2026-07-24 13:08:09 -04:00
tfaour 5d4bb53b8a Widget system Phase 4a: widget CRUD + grid placement UI
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
2026-07-24 12:48:56 -04:00
tfaour 99069ba5fe Widget system Phase 3: calendar widgets become size-aware
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 1m58s
calendar_render.py's _build_* functions now take a real target box
and pick font sizes/margins from three discrete size tiers (nearest
pixel-area fit) instead of always laying out at full panel size and
resizing after the fact -- a calendar widget placed smaller than the
full panel gets an actually-legible layout instead of shrunk text.
Month view falls back to agenda below the smallest tier, where 7
columns can no longer stay readable.

The old photo-inlay split (inlay_region/_content_region/_paste_inlay)
is deleted along with it -- arbitrary widget placement already
subsumes what a fixed half-panel split did, and every call site has
passed photo_inlay=None since the Phase 2 cutover.

Also adds HTTP-level test coverage for GET .../preview/calendar,
which had none before this -- it's what caught a stale photo_inlay
kwarg left over from the _build signature change that would have
TypeError'd on every request.
2026-07-24 09:38:10 -04:00
tfaour 37bd657299 Widget system Phase 2: full cutover to widget-based rendering
Build and push server image / test (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 1m56s
device.py's mode-keyed dispatch is replaced by a real compositor:
load a frame's widgets, compute pixel rects via app/grid.py, render
each through its widget module, and composite with render_panel.
Physical NEXT/BACK buttons now execute each frame's assigned
FrameButtonAction rows instead of one hardcoded per-mode action.

api_frames.py, manage.py, and common.py's build_manage_content are
repointed to read/write the frame's widget config rows instead of
the old Frame columns, and every settings page (Photos/Calendar/
Whiteboard tabs) now pre-fills its form from the same widget config
the write endpoints actually save to -- previously the read and
write sides would have silently diverged. The old mode selector and
photo-inlay checkbox are removed along with their now-inert wiring;
arbitrary widget placement subsumes what the fixed inlay split did.

Ships together with Phase 1 (per-type render/action modules) since
splitting the read/write cutover across deploys would have left
settings changes with no visible effect.
2026-07-24 09:26:28 -04:00
tfaour f48daa71c8 Widget system Phase 1: per-type render/action modules
Build and push server image / test (push) Successful in 17s
Build and push server image / build-and-push (push) Successful in 2m2s
New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus
the WIDGET_TYPES registry) -- the widget-system analogue of
routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS,
generalized from "one mode owns the whole panel" to "each widget renders
into its own region and responds to named button actions." Each module
exposes render(db, frame, widget, target_w, target_h) -> Image.Image
(never raises -- a widget's own fetch hiccup falls back to a small
placeholder rather than taking the whole panel's render down) and an
ACTIONS registry for NEXT/BACK button assignment.

Supporting changes needed to give the widget modules something to call,
all mechanical/behavior-preserving for every existing caller:

- image_pipeline.py: render_panel(regions, ...) generalizes render_frame's
  tail (paste, enhance once, overlay once, quantize once, pack once) from
  one photo to N regions -- not a restructuring, since calendar mode's
  photo-inlay feature already pastes a second composed image onto the
  canvas before that single shared pipeline runs.
- photo_queue.py: advance_forced/back_forced/remove_from_rotation/
  get_current take an explicit `frame` param now that `cfg` won't always
  be the Frame itself once photo-queue state moves to PhotoWidgetConfig
  -- caught a real latent bug while doing this: get_current was reading
  refresh_interval_s off `cfg`, but that's a frame-level wake-cadence
  setting, not something that becomes per-widget, so it now reads that
  off `frame` explicitly instead.
- routers/common.py: list_assets/fetch_source_and_faces take album_id/
  display_mode directly instead of a whole Frame (both only ever read
  that one attribute off it); new get_or_refresh_*_for_widget siblings
  of the existing calendar/weather/tasks/whiteboard cache helpers, read/
  writing the new per-widget config tables -- the Frame-scoped originals
  are untouched and still what routers/device.py's actual dispatch calls
  until the Phase 2 cutover.

26 new tests (95 total): render_panel size/placement/orientation
coverage, and per-widget-type render/action tests (unconfigured ->
placeholder, a fetch failure -> placeholder not a crash, actions mutate
the right state). Full suite passes; diff-reviewed to confirm
device.py's actual RENDERERS dispatch and the old Frame-scoped
get_or_refresh_* bodies are unchanged, so this is safe to deploy on its
own despite being step 1 of a two-step cutover (see the project plan on
why the *next* step, not this one, has to ship atomically).
2026-07-24 08:44:53 -04:00
tfaour 8bc0749b42 Widget system Phase 0: data model + migration
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m5s
First step of replacing Frame.mode (one renderer owns the whole panel)
with an Android-home-screen-style widget system -- a frame will hold N
independently placed/sized widgets (photos/calendar/whiteboard), each
with its own config/state, plus fully user-assignable NEXT/BACK button
actions. Full plan at .claude/plans/prancy-snacking-iverson.md.

This phase is additive only and changes no existing behavior -- nothing
reads these new tables yet:

- models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/
  WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this
  codebase's existing convention of dedicated tables for naturally-scoped
  state rather than one wide table) + FrameButtonAction (ordered
  (widget, action) bindings per physical button).
- grid.py: pure snap-to-grid placement math, defined relative to the
  panel's long/short axis so it stays valid across
  logical_render_size(orientation)'s genuine width/height swap for
  portrait, not just a rotation applied at the end.
- db.py: widget_locked(), the widget-scoped equivalent of frame_locked()
  -- deliberately still locks at frame granularity (not a new per-widget
  lock) to avoid a new class of multi-lock deadlock bugs.
- migration.py: _migration_16 creates the new tables; a separate
  _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less
  error-prone for this much per-mode branching) gives every existing
  frame a widget reproducing its exact current mode/settings, so
  upgrading changes nothing about what a frame displays or what its
  buttons do. calendar_photo_inlay frames specifically get two widgets
  (calendar + photo, split like the old inlay did) rather than silently
  losing the photo half.

10 new tests covering fresh-install backfill, re-run idempotency, the
photo-inlay two-widget case, whiteboard's check_now button mapping, and
migration_16's actual CREATE TABLE path against a simulated pre-existing
database (not just the fresh-install create_all() shortcut). Full suite
(69 tests) passes.
2026-07-24 08:27:47 -04:00
tfaour 1c67dd20d7 Auto-restart the whiteboard render sidecar if it crashes
Build and push server image / test (push) Successful in 16s
Build and push server image / build-and-push (push) Successful in 1m57s
Root cause of "whiteboard stuck on old content, no errors anywhere":
the Node sidecar had crashed at some point and, since it was just a
bare backgrounded process with nothing supervising it, stayed dead
permanently. Every refresh since then hit connection-refused, which
get_or_refresh_whiteboard treats as a soft failure and falls back to
the last successfully cached image -- so it looked exactly like a
caching bug from the outside, silently, forever, with no error visible
anywhere except a crash trace that had already scrolled out of the log
buffer.

Wrap it in a restart loop instead of a bare `&` so a future crash (a
still-unknown third jsdom/Excalidraw edge case, most likely) is a
few-second hiccup instead of a silent permanent outage.
2026-07-23 21:22:17 -04:00
tfaour 1100580c2c Live-update whiteboard/tasks source display; add firmware CI build check
Build and push server image / test (push) Successful in 15s
Firmware build check / build-check (push) Successful in 2m6s
Build and push server image / build-and-push (push) Successful in 2m26s
Whiteboard and tasks-source save/clear used to just tell the user to
reload the page to see the change. Both endpoints always assign a
successful "set" to the calling user, so the new state is fully known
client-side already -- rewrite the "Currently using/showing ..." block
in place instead, no server round trip or reload needed.

Also adds .gitea/workflows/firmware-build-check.yml: builds both board
variants (devkit, xiao) on every push touching firmware/**, unlike
firmware-release-build.yml which only builds on a version.txt bump.
Verified both builds succeed locally against the actual ESP-IDF
toolchain before wiring this in.
2026-07-23 19:08:01 -04:00
tfaour 31adc34a19 Add a real pytest suite, gating the CI build
Build and push server image / test (push) Successful in 1m18s
Build and push server image / build-and-push (push) Successful in 2m12s
64 tests covering: auth/setup and the CSRF gate, the "owner adds their
own data, anyone linked can mute it" permission pattern shared across
calendar-select/tasks-source/whiteboard-source, migration correctness
(fresh install, idempotent re-run, expected columns), battery estimate
outlier rejection, calendar_feed's fetch/merge/partial-failure handling,
webdav_client's fetch/list-directory, the whiteboard force-refresh
throttle bypass and browse endpoint, and render-size invariants across
calendar views/orientations.

No DB/HTTP fixtures need Docker, Node, or a real Immich/CalDAV/WebDAV
server -- a fresh temp SQLite file plus a couple of small local HTTP
servers as test doubles cover it all. Table data is wiped and reseeded
between tests rather than relying on SQLAlchemy's transaction-rollback
isolation pattern, which needs a pysqlite event-listener workaround
app/db.py's engine doesn't have and has no reason to gain just for tests.

Wired into .gitea/workflows/server-docker-build.yml as its own job that
build-and-push now depends on, so a failing suite blocks the image push
rather than just running alongside it for show.
2026-07-23 18:51:54 -04:00
tfaour b4ca795003 Add a whiteboard file picker and force-refresh
Build and push server image / build-and-push (push) Successful in 1m57s
File picker: an optional WebDAV browse root in Settings
(User.webdav_base_url) plus a plain-PROPFIND directory listing
(webdav_client.list_directory) power a "Browse..." panel on a frame's
Whiteboard tab, so a file can be clicked into rather than typing its
exact WebDAV URL. Manual URL entry still works unchanged either way.

Force-refresh: get_or_refresh_whiteboard takes a force flag that skips
the fetch throttle entirely; the Whiteboard tab's refresh button now
passes it, so clicking it always re-fetches and re-renders instead of
possibly just re-showing the same cached image from within the last
~20 minutes.
2026-07-23 18:20:39 -04:00
tfaour 8556221b08 Skip font inlining in whiteboard SVG export
Build and push server image / build-and-push (push) Successful in 2m6s
exportToSvg's font-embedding path (base64 @font-face rules) goes through
the browser FontFace API, which jsdom doesn't implement -- that's what
crashed fontFacesStylesGenerator after the previous global-shimming fix
got past startup. skipInliningFonts avoids that path entirely; resvg
already falls back to system fonts for rasterizing regardless, so
embedded fonts were never going to affect the final PNG.
2026-07-23 17:56:48 -04:00
tfaour dadd9ec164 Fix whiteboard render sidecar crash on startup
Build and push server image / build-and-push (push) Successful in 1m57s
@excalidraw/utils is a browser bundle -- it references devicePixelRatio,
location, matchMedia etc. as bare globals the way inline <script> code
would, not as window.foo. Only copying window/document/navigator onto
Node's global left everything else undefined, so the very first bare
reference threw a ReferenceError as soon as the module loaded. Copy
jsdom's entire window onto global instead, enable pretendToBeVisual so
jsdom actually populates devicePixelRatio/requestAnimationFrame, and
stub matchMedia since jsdom doesn't implement it at all.
2026-07-23 17:50:27 -04:00
tfaour c171047adf Split server Dockerfile into smaller layers
Build and push server image / build-and-push (push) Successful in 2m2s
The 413 from git.thumeit.com was on a single blob PUT, not the whole
image -- split the Node.js apt install and the render-service npm
install into several smaller RUN layers (purging curl/gnupg in the
same layer they're installed in, cleaning npm's cache after each
package) so no single pushed blob is as large as before.
2026-07-23 17:23:20 -04:00
tfaour afbe9db409 Revert "Push server image to local registry instead of git.thumeit.com"
This reverts commit 8ae09f238b.
2026-07-23 17:17:46 -04:00
tfaour 49794b4973 Revert "Configure BuildKit to allow plain HTTP to the local registry"
This reverts commit 1f62653118.
2026-07-23 17:17:46 -04:00
tfaour 1f62653118 Configure BuildKit to allow plain HTTP to the local registry
Build and push server image / build-and-push (push) Failing after 10s
Docker defaults to HTTPS for any non-docker.io registry, and
10.0.0.246:3000 (a bare LAN IP:port) isn't serving valid HTTPS -- the
push kept going out over HTTPS and failing. Adds a buildkitd config
telling BuildKit specifically to use HTTP for that host.

This only fixes the actual build+push step (BuildKit). The "Log in"
step runs a plain `docker login` through the classic Docker CLI/daemon
instead, which doesn't read this config -- that one still needs
10.0.0.246:3000 added to "insecure-registries" in the actual Docker
daemon's /etc/docker/daemon.json on whatever host runs the Gitea
Actions runner, followed by a daemon restart. That's runner-host
infrastructure outside this repo.
2026-07-23 17:12:33 -04:00
tfaour 8ae09f238b Push server image to local registry instead of git.thumeit.com
Build and push server image / build-and-push (push) Failing after 9s
Cloudflare (fronting git.thumeit.com) started rejecting image pushes
with 413 Payload Too Large once whiteboard mode's Node runtime + native
resvg bindings made the image significantly bigger than before. Switched
the build/push target to a LAN-local registry (10.0.0.246:3000) that has
nothing in front of it to hit that limit, and updated
docker-compose.yml.example to match.
2026-07-23 17:10:21 -04:00
tfaour 644fdefa66 Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV)
Build and push server image / build-and-push (push) Failing after 1m10s
New third mode alongside photos/calendar: fetches a .whiteboard file
over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and
renders it via a small Node.js sidecar using Excalidraw's own real
export code (@excalidraw/utils + @resvg/resvg-js, no headless browser),
since a .whiteboard file turns out to be Excalidraw scene JSON, not an
image. The sidecar runs as a second process inside this same container
(Dockerfile installs Node, start.sh backgrounds it before exec'ing
uvicorn) rather than a separate docker-compose service -- lightweight,
stateless, reachable only at 127.0.0.1 from the Python process, nothing
worth independently scaling.

The rendered PNG is treated exactly like a photo from there on --
composed/quantized through the existing image_pipeline (letterboxed,
never cropped) rather than a second parallel rendering pipeline.

WebDAV credentials support the common "it's actually the same Nextcloud
account as my CalDAV" case (an explicit opt-in checkbox, not silently
inferred) while still working with any WebDAV server generically.
Frame-level source (URL + owning account) follows the same owner-
controls-their-own-data permission split as calendar sources and the
week view's task list: only the account owner can point a frame at it,
anyone linked can clear it.

Honest limitation: this environment has no Node.js/npm, so
render-service/ is written carefully against each library's documented
API (verified via the npm registry, including transitive dependency
licenses after the CalDAV/AGPL surprise earlier this session) but has
never actually been executed. First real docker build is the first
true test -- see render-service/README.md.
2026-07-23 17:02:08 -04:00
tfaour 14cf212a60 Move Order and Display mode to the Photos tab
Build and push server image / build-and-push (push) Successful in 53s
Both are photos-specific settings (rotation order, how a photo's aspect
ratio gets reconciled with the panel), not device-wide configuration --
they belonged on the Photos tab next to the album/queue settings, not
Configuration. No backend change: both already save through the shared
partial-update /config endpoint regardless of which tab's form sends
them.
2026-07-23 09:07:49 -04:00
tfaour db9a6f1875 Week view: relative start-day offset for non-7-day counts; hide week-only settings elsewhere
Build and push server image / build-and-push (push) Successful in 53s
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.

Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
2026-07-23 08:17:25 -04:00
tfaour ce8525bee8 Week view flexibility: configurable day count, layout, and a CalDAV task list
Build and push server image / build-and-push (push) Successful in 52s
- Day count (2-10, was fixed at 7) -- 5 days trims the weekend clutter
  without losing the grid format.
- Layout choice: days side by side (original behavior) or stacked
  vertically as full agenda-style sections (reuses _draw_agenda_day,
  same approach _build_today_tomorrow already used for a fixed 2 days).
- Optional task list (CalDAV VTODO collections only -- a plain ICS
  subscription doesn't meaningfully have one) that takes the space of
  one day slot instead of adding an extra one. Same owner-controls-
  their-own-data permission split as calendar sources: only the
  calendar's owner can point a frame's task list at it, but anyone
  linked to the frame can clear it.

Browse-offset paging now moves by N days (was hardcoded to weeks),
identical to the old behavior when days=7. Changing the day count
resets the browse offset, same reasoning as changing views already did.
2026-07-23 07:58:13 -04:00
tfaour 01b9e9f1d0 Reject outlier readings in the battery estimate
Build and push server image / build-and-push (push) Successful in 51s
A single noisy ADC/regulator glitch (see firmware/main/battery.c)
survives the existing recharge filter: whichever way it reads, one of
the two steps around it (into a dip, or out of a spike) still looks
like an ordinary drop and got averaged straight into the remaining-
time estimate, letting one bad reading swing it dramatically.

Added a MAD-based modified z-score outlier check on top of the
existing recency-weighted average. Had to special-case the standard
MAD degenerating to exactly 0, which happens whenever more than half
the steps share the same value -- the norm for battery data (most
wakes cost the same small integer percent), and exactly the shape a
single spliced-in glitch among a steady discharge rate has, so the
naive case would have let the outlier this is for sail straight
through. Falls back to mean absolute deviation there instead.

Verified against constructed glitch scenarios in both directions
(spurious dip and spurious spike): estimate now comes out identical
to the same series with the glitch removed entirely.
2026-07-23 06:22:12 -04:00
tfaour 33af5408fd Render calendar emoji in full color
Build and push server image / build-and-push (push) Successful in 54s
Switched from the monochrome emoji font to color: NotoColorEmoji's
embedded CBDT bitmap glyphs, rasterized once at their native 109px
size and scaled to the target row height (unlike normal vector text,
color bitmap glyphs aren't stored at arbitrary sizes). Confirmed by
rendering an actual agenda row through the real quantizer that the
dithered-to-6-color result still reads clearly, not just muddy noise.

Falls back to the monochrome font if a deployment's Pillow/FreeType
wasn't built with embedded color bitmap support, so this degrades
instead of crashing or showing nothing.
2026-07-23 04:25:10 -04:00
tfaour 67d99dd6c0 Actually render emoji in calendar event titles; stack duplicate shared events
Build and push server image / build-and-push (push) Successful in 53s
The earlier fix stripped emoji instead of rendering them, which wasn't
what was asked for. Event titles now draw with two fonts: the usual
default font for text, and a vendored monochrome emoji font (Noto
Emoji, OFL-1.1) for actual emoji runs, so they show up as real glyphs
instead of a tofu box or nothing at all. Monochrome rather than color,
since reliably rendering COLR/CBDT color glyphs depends on how Pillow's
FreeType was built -- not something to depend on across deployments.

Also: events sharing the exact same title and time across different
calendars (e.g. a shared family event synced onto more than one
person's calendar) now collapse into one row instead of showing twice,
with a color bar split between every contributing calendar so it's
still clear whose event it is.
2026-07-22 23:13:34 -04:00
tfaour 7fc262f9c3 Strip emoji from calendar event titles before rendering
Build and push server image / build-and-push (push) Successful in 49s
ImageFont.load_default() has no emoji glyphs, and PIL/FreeType don't
skip an unsupported codepoint -- they substitute a visible ".notdef"
tofu box, which read as a rendering glitch rather than "not supported."
Stripped instead (covers the standard emoji Unicode blocks, skin-tone
modifiers, and the ZWJ used to combine them into one glyph), with
surrounding whitespace collapsed.
2026-07-22 22:58:06 -04:00
tfaour 27cd6b3703 Manual per-calendar color choice for calendar mode
Build and push server image / build-and-push (push) Successful in 49s
Each linked person can pin one of the panel's four non-black/white
colors (Yellow/Red/Blue/Green) to their own calendar instead of relying
on calendar_render.py's old auto-cycle-by-owner-name order -- owner-only,
like adding a calendar in the first place. Colors resolve against
whichever palette a frame actually renders with (including a custom
Advanced configuration override), so a pinned "Blue" stays this frame's
actual blue. Event color bars/dots are also bigger and rounded now
across agenda/week/month views, easier to tell apart at a glance.
2026-07-22 22:30:55 -04:00
tfaour ffce798754 Add weather to calendar mode; fix CalDAV events never showing
Build and push server image / build-and-push (push) Successful in 48s
Weather: multiple cities per frame, geocoded via Open-Meteo (no API
key), shown above the event list on agenda/today & tomorrow/week views
-- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/
thunderstorm icons (no new font/icon asset, same primitives-only
approach the rest of calendar_render.py already uses). City geocoding
handles "City, State" qualifiers Open-Meteo's own search doesn't
(disambiguates same-named cities, e.g. the three "Portland"s).

CalDAV fix: events were never showing despite calendars discovering
fine, because fetch_calendar_events relied on the calendar-query
REPORT's server-side time-range filter, which real servers implement
inconsistently (confirmed against a real server, not just guessed --
reproduced locally with Radicale). Switched to fetching every event
unfiltered and doing all date-window filtering/expansion client-side,
same approach already used for plain ICS feeds.
2026-07-22 22:22:11 -04:00
tfaour acdb929a99 Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Build and push server image / build-and-push (push) Successful in 50s
Calendar rule/grid lines were light gray, which dithers away to
near-invisible on the 6-color e-ink palette -- now black.

CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked
alongside the existing single ICS subscription, since one account can
expose several calendars. A frame's Calendar tab now lists calendars
per person rather than one opt-in per person: your own row shows every
calendar you have available with a full add/remove toggle, while other
linked users' rows show only calendars they've included, toggleable
off (mute) but not on -- only a calendar's owner can add it to a
shared frame. FrameCalendar replaces the old single-boolean
UserFrame.calendar_included; existing opt-ins are migrated forward.
2026-07-22 22:01:05 -04:00
tfaour 95d69a5512 Rewrite battery-remaining estimate around per-wake drop rate
Build and push server image / build-and-push (push) Successful in 42s
The old estimate used a single linear percent/second rate from the
current discharge cycle's battery_history, which resets to empty on
every recharge -- so "not enough data yet" kept showing up despite the
frame having plenty of history overall, and the rate it did compute was
tied to whatever refresh interval produced it (changing the interval
didn't move the estimate until enough new history accumulated under
the new setting).

Now pulls the last 100 rows from the permanent battery_log table
instead, and averages the *per-wake* percent drop (not per-second) --
recharge jumps are skipped rather than counted as negative drain,
flat/zero-drop wakes still count so the rate isn't overstated, and
more recent steps are weighted more heavily. The per-wake rate then
converts to wall-clock time using the frame's current
refresh_interval_s and quiet-hours settings, so halving the refresh
interval roughly halves the estimate immediately, and quiet hours
correctly stretches it out (fewer wakes/day at the same per-wake cost).
2026-07-22 21:21:17 -04:00
tfaour 3a0007118c Move frame name/mode into the page header; Calendar gets its own tab
Frame name (pencil-icon inline edit) and the Photos/Calendar mode
selector now live in the page header, shared across all four per-frame
pages instead of being buried in the Configuration form -- so renaming
a frame or flipping its mode no longer requires navigating to a
specific tab first.

Calendar settings move out of a conditionally-hidden card on the
Configuration tab into their own dedicated tab (new /frames/{id}/
calendar route), visually greyed out when the frame is in Photos mode
but still fully usable so calendar settings can be configured ahead of
switching modes. Also wires the week-start setting into the UI for the
first time (the column/backend support landed earlier but had no
control anywhere).
2026-07-22 21:21:00 -04:00
tfaour aa194be09a Calendar mode polish batch + Today & Tomorrow view
Responds to post-launch feedback on calendar mode: configurable
week-start day for week/month views, crisper non-antialiased text
(threshold-masked instead of drawn straight, so Floyd-Steinberg
dithering doesn't speckle glyph edges), a color-coded/proportionally
filled battery icon on the manage overlay, word-wrapped placeholder
text so "Calendar isn't set up yet" no longer clips in portrait, photo
inlay support extended from agenda-only to every view, and a fix so
manage-overlay face labels reposition correctly when a photo inlay is
active (they previously assumed the photo filled the whole canvas).

Also adds a fourth calendar view, "Today & Tomorrow" -- a two-day
agenda that reuses the same per-day row-layout helper the single-day
agenda view already has.
2026-07-22 21:20:44 -04:00
tfaour 716776c3f2 Bump firmware to 1.3.0
Build and release firmware / build-and-release (push) Successful in 1m48s
Manage overlay now composited server-side.
2026-07-22 19:07:29 -04:00
tfaour 0f98d96d25 Untrack .claude/ (editor tooling, not project content) 2026-07-22 19:07:08 -04:00
tfaour c007acde75 Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds,
render agenda/week/month views. manage_overlay.py: composites the
manage-button overlay server-side (QR, battery, location/date,
share-QR, face labels), reused by every render mode. device.py/common.py
wire both together: mode dispatch for /frame/image+advance+back, and
the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings
calendar URL field) and the icalendar/recurring-ical-events deps.
2026-07-22 19:06:49 -04:00
tfaour 15e37c77cd Simplify firmware: manage overlay now composited server-side
Deletes manage_qr_overlay.c/.h; frame_client.c's manage-button flow is
now one fetch with &manage=1 instead of on-device QR/text generation
plus separate photo-info/face-labels requests.
2026-07-22 19:06:49 -04:00
tfaour 1fa1c68478 Add calendar frame mode data model (migration 7)
New columns, all ADD COLUMN with inert defaults -- no existing frame's
behavior changes until mode is explicitly switched to "calendar":

- users.calendar_ics_url: one personal iCal/CalDAV subscription per
  user, same shape as the existing per-user immich_url/immich_api_key.
- user_frames.calendar_included: explicit per-(user,frame) opt-in,
  default off. Being linked to a frame does not by itself contribute
  your calendar to it -- each person's calendar is their own data to
  share, not something a frame's controller decides on their behalf.
- frames.calendar_view/calendar_photo_inlay/calendar_browse_offset:
  per-frame display settings and NEXT/BACK navigation state.
- frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary:
  the throttled merge-fetch cache, same shape as the existing
  firmware_update_checked_at/firmware_gitea_latest_version pattern.
2026-07-22 19:05:02 -04:00
tfaour fc65b19cf2 Bump firmware to 1.2.4
Build and release firmware / build-and-release (push) Successful in 1m47s
Trimmed-mean battery ADC sampling to reduce noisy readings.
2026-07-22 16:57:34 -04:00
tfaour e1bca5a81a Fix false recharge-cycle detection from a single noisy battery reading
Build and push server image / build-and-push (push) Successful in 38s
A report was flagged as "the battery got recharged" (resetting
battery_history and stats_recharge_cycles, and re-arming the low-battery
alert) whenever it came in >= RECHARGE_JUMP_PCT above the single
immediately-previous report. That's exactly what a real recharge looks
like, but it's also exactly what a normal reading looks like right
after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back
to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge.

Now compared against the max of the last RECHARGE_LOOKBACK (3) reports
instead of just the one before it, so a lone stray reading doesn't get
to set the bar a normal reading then trips. A real recharge still needs
to clear all of them, so genuine recharges are still caught immediately
(verified: 18% -> 90% still triggers, history still resets).

Paired with the firmware-side battery.c change (trimmed-mean ADC
sampling) that reduces how often a stray reading like the 53 above
happens in the first place.
2026-07-22 16:51:37 -04:00
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00
159 changed files with 16618 additions and 1704 deletions
+178
View File
@@ -0,0 +1,178 @@
---
name: make-widget
description: Scaffold a new widget type for the espresso_frame server (the ~13-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
---
Adding a widget type is a very consistent, repeated pattern in this
codebase (`photos`/`calendar`/`whiteboard`/`tasks`/`static`) -- see
`docs/widgets.md` for the system's actual data model/rendering/dialog
architecture (read that first if you haven't). This skill is the
checklist of every file that pattern touches, so nothing gets silently
dropped (the static-image widget shipped without a `docs/widgets.md`
update; this skill exists so that doesn't keep happening).
**For a complete worked example touching every item below**, `git show
35e80c6 --stat` (the static-image widget's commit) in this repo.
All paths below are relative to `server/`.
## Before writing any code: shape decisions
Answer these first -- they determine which existing widget type is the
closest template to copy from:
- **Live upstream to poll, or self-contained/user-authored?** Calendar/
whiteboard/tasks fetch from somewhere external on a throttle
(`checked_at` + `get_or_refresh_*` in `routers/common.py`). Photos'
queue and the static image widget don't -- their content is set once
via the dialog (an upload, a pick) and just sits there until changed.
A text widget is almost certainly this second shape.
- **Single source, or multi-source merge?** Calendar/tasks merge
several *people's* data (`FrameCalendar`/`FrameTaskList`, owner-adds/
anyone-mutes). Only reach for that shape if the new type genuinely
needs to combine several linked users' own data -- most new widget
types are single-owner/single-config and don't need it.
- **Any button actions**, or is `ACTIONS = {}` correct (nothing to
advance/back/force)? Tasks and static image are both `{}`.
- **Minimum sane grid footprint** -- how small can this widget be
before its content is illegible/pointless?
Pick your template accordingly:
| New widget shape | Copy from |
|---|---|
| Self-contained, user-authored/uploaded, no fetch, no actions | `app/widgets/static_image.py` |
| Single external source, throttled fetch, one "check_now" action | `app/widgets/whiteboard.py` |
| Multi-source merge, owner-adds/anyone-mutes permissions | `app/widgets/tasks.py` (simpler) or `calendar.py` (also has size-tier rendering) |
| Stateful queue/rotation with advance/back | `app/widgets/photos.py` |
## The checklist
1. **`app/models.py`** -- new `<Type>WidgetConfig` table, `widget_id`
`Mapped[int]` primary key `ForeignKey("widgets.id", ondelete="CASCADE")`,
plus whatever fields the type needs. Add it to the `WIDGET_CONFIG_MODELS`
dict at the bottom of the file.
2. **`app/grid.py`** -- add an entry to `MIN_FOOTPRINT`.
3. **`app/widgets/<type>.py`** -- new module exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image`
-- RGB, exactly `target_w x target_h`, **never raises** for a
foreseeable failure (missing config, fetch error) -- fall back to
`._shared.placeholder_image(target_w, target_h, [lines])` instead.
- `ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]`
- `ACTION_LABELS: dict[str, str]`
4. **`app/widgets/__init__.py`** -- import the new module, add it to
`WIDGET_TYPES`.
5. **`app/migration.py`** -- new `_migration_N`. A brand-new table with
no legacy data to carry forward is just
`Base.metadata.create_all(bind=conn)` (see `_migration_20`) -- it
only creates the one new table, existing ones are untouched. Register
`(N, _migration_N)` as the new last entry in `MIGRATIONS`.
6. **`app/routers/api_widgets.py`**:
- Add any new `Form(...)` fields to `api_widget_config_save`'s
signature, and a new `elif widget.widget_type == "<type>":` branch
inside its body. Reuse an existing field name (e.g. `display_mode`)
where the semantics genuinely match -- fields are namespaced by
which widget type actually reads them, not by name collision, so
this is safe (see the comment above `display_mode` in that
function).
- Add type-specific endpoints as needed (upload/source-select/etc.).
Use `require_widget_control` for widget-wide settings a dialog Save
button changes; use `require_widget_view` (not control) for the
owner-adds/anyone-mutes multi-source pattern, matching
`api_widget_calendar_select`/`api_widget_task_list_select`.
- Add a `GET .../preview/<type>` endpoint mirroring the others --
`render_preview_png` (the full palette/dither pipeline) for
image-like content, or a dedicated `render_<type>_preview_png` in a
rendering module for text/graphics content (see
`calendar_render.render_tasks_preview_png`).
7. **`app/routers/frame_pages.py`** -- import the new config model, add
an `if widget.widget_type == "<type>":` branch in `widget_dialog()`
returning `templates.TemplateResponse("_widget_dialog_<type>.html", {...})`.
8. **`app/templates/_widget_dialog_<type>.html`** -- the dialog
fragment: settings card(s) + `<img class="preview-img"
id="<type>-preview">` + a refresh button, using the existing
`.card`/`.card-title`/`.sub`/`.checkbox-row` classes from
`theme.css` rather than inventing new ones.
9. **`app/static/widget_dialog_<type>.js`** -- an `init<Type>Dialog()`/
`close<Type>Dialog()` pair (not a page-load script -- see any
existing `widget_dialog_*.js`'s header comment for the contract).
`window.fetch` already CSRF-injects (see `common.js`), so POSTs don't
need a manual header. **Never build user-supplied text into the DOM
via `innerHTML` string interpolation** -- use `textContent`/
`createElement` (a filename, a task summary, anything another linked
user's account could have set is a stored-XSS vector otherwise).
10. **`app/templates/frame_layout.html`** -- add
`<script src="/static/widget_dialog_<type>.js"></script>` next to
the other widget dialog scripts.
11. **`app/static/frame_layout.js`** -- add the type to both
`DIALOG_INIT` and `DIALOG_CLOSE`.
12. **`app/static/common.js`** -- add a `WIDGET_LABELS` entry (the
human label shown in the add-widget button, the widget box, and the
button-assignment picker in `frame_config.js`).
13. **`docs/widgets.md`** -- update every place that enumerates widget
types: the intro sentence, the `widget_type` column-value list, the
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
widget without it staying accurate.
## Tests (`server/tests/`)
- `test_widgets_<type>.py` -- unit-level `render()` tests, no HTTP:
correct size/mode with no config, with config, at
`grid.MIN_FOOTPRINT`'s smallest box, `ACTIONS == {}` if passive.
Mirror `test_widgets_static.py` (self-contained) or
`test_widgets_tasks.py` (fetch-backed, monkeypatches the fetch call).
- `test_widget_config_and_queue_endpoints.py` -- add a
`_add_<type>_widget` helper plus an HTTP-level
`test_config_save_updates_a_<type>_widget` test, and tests for any new
endpoints (upload/select/preview: 400 before configured, 200 after,
400 for the wrong widget type via `_require_widget_type`).
- `test_migrations.py` -- add the new table to
`test_expected_columns_exist_on_current_schema`'s spot-check
(`inspector.get_table_names()` or `inspector.get_columns(...)`).
- Owner-adds/anyone-mutes multi-source table? Add cases to
`test_permission_boundaries.py` following its existing
calendar-select/task-list-select pattern (owner can add, non-owner
can mute but not add, 404 for an unrelated widget id, 400 for the
wrong widget type).
- Any pure-logic helper module (decoding, parsing -- like
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
DB, just the function.
Run the full suite before calling it done:
```bash
cd server && .venv/bin/pytest -q
```
Comfortably under 30s for the whole suite (~200+ tests) -- there's no
reason to skip this or run a subset.
## Browser verification (required, not optional)
Per `CLAUDE.md`, reading the JS is not enough -- this project has
shipped UI bugs (mobile viewport CSS collapse, a dialog's status message
landing behind its own backdrop, a JSON/form body mismatch) that only
showed up live. Use the `run-server` skill:
- Clear existing widgets and add one of the new type
(`POST /api/frames/1/widgets`), resize it (`PATCH`), open its dialog
(`click .widget-box-settings`), exercise its actual settings/upload
flow through the real UI controls (not just a raw `fetch` in `eval` --
that only proves the endpoint works, not that the button is wired to
it), and check `console-errors` for anything beyond the expected
favicon 404.
- Check the full composited panel preview
(`#frame-preview-thumb` on `/frames/{id}/config`) actually shows the
new widget's content -- not just its own dialog's `preview/<type>`
image, which only proves the render function works in isolation.
- Screenshot **both** desktop (`viewport 1280 900`) and mobile
(the driver's default) widths -- the layout genuinely forks at the
860px breakpoint in `theme.css`.
## Commit
One commit for the whole widget (models + migration + render + router +
UI + tests + docs) -- this project's convention is one feature per
commit, not split by layer. No `Co-Authored-By: Claude` trailer (see
root `CLAUDE.md`).
+2
View File
@@ -0,0 +1,2 @@
# Generated by setup.sh -- bakes in this host's /tmp paths, not portable.
env.sh
+234
View File
@@ -0,0 +1,234 @@
---
name: run-server
description: Build, run, and drive the espresso_frame FastAPI server (server/) -- start it against a scratch DB, browser-test its UI, run its pytest suite. Use when asked to start the server, take a screenshot of a frame page, click through the web UI, or verify a server/UI change actually works.
---
The espresso_frame server is a FastAPI app (`app.main:app`) with a
server-rendered Jinja UI. For agent/automated use it's driven by a
Playwright REPL at `.claude/skills/run-server/driver.py`, run under
tmux -- `chromium-cli` isn't available in this container, so this
driver replaces it (same command vocabulary: nav/wait-for/click/fill/
screenshot/eval/console-errors).
This container ships with **no Python, Node, Docker, or browser, and
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
what makes this skill non-obvious. Run it once per fresh container.
Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
`env.sh`, `driver.py`) are invoked from the **repo root** via their
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
themselves. Anything under `.venv/` (the venv itself, `pytest`,
`uvicorn`) lives inside `server/`, so those commands need `server/`
prefixed or `cd server` first.
## Prerequisites
None to install manually -- `setup.sh` does it all without root, using
only `curl`/`apt-get download`/`dpkg-deb -x` (never `apt-get install`,
which needs root). It downloads ~450MB total (Python, Chromium, tmux,
shared libs) on first run.
```bash
bash .claude/skills/run-server/setup.sh
```
Re-running is safe and fast -- every step checks whether it already
happened (venv exists? chromium downloaded? libs extracted? tmux
present?) before doing any work.
This creates:
- `.venv/` -- Python 3.12 + `requirements.txt` + `playwright`, via `uv`
(a static Rust binary that fetches its own Python -- no compiler
needed, install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
- `~/.cache/ms-playwright/` -- Chromium (full `chrome` + headless-shell)
- `/tmp/run-server-chromium-deps/` -- Chromium's + tmux's shared libs
and fonts, extracted (not installed) from `.deb` files
- `.claude/skills/run-server/env.sh` -- the `PATH`/`LD_LIBRARY_PATH`/
`FONTCONFIG_PATH`/`RUN_SERVER_CHROME_BIN` exports the driver needs
(gitignored -- host-specific `/tmp` paths, regenerated by setup.sh)
## Run (agent path)
**1. Start the server** against a scratch DB (never the real
deployment's data -- see `CLAUDE.md`):
```bash
bash .claude/skills/run-server/start-server.sh
# -> server PID <pid> up on http://127.0.0.1:8420 (log: /tmp/run-server-scratch/server.log)
```
Optional args: `start-server.sh [scratch-dir] [port]` (defaults
`/tmp/run-server-scratch` / `8420`).
**2. Drive it**, wrapped in tmux so you can send one command at a time
and read the response before sending the next. `tmux` itself was
extracted the same non-root way as Chromium's libs (see Prerequisites)
and needs `LD_LIBRARY_PATH` set in *this* shell too, not just inside
the pane -- source `env.sh` before the first `tmux` call:
```bash
source .claude/skills/run-server/env.sh
tmux new-session -d -s runserver -x 200 -y 50
tmux send-keys -t runserver \
'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
# Every fresh scratch DB starts with no users -- bootstrap-admin
# completes first-run /setup and logs in (see Commands table):
tmux send-keys -t runserver 'bootstrap-admin' Enter
timeout 15 bash -c 'until tmux capture-pane -t runserver -p | grep -q "bootstrapped admin"; do sleep 0.3; done'
tmux send-keys -t runserver 'nav /frames/1/config' Enter
tmux send-keys -t runserver 'wait-for #frame-preview-thumb' Enter
tmux send-keys -t runserver 'screenshot before' Enter
tmux capture-pane -t runserver -p
```
Poll for a specific marker between `send-keys` and `capture-pane`
(`driver>`, `bootstrapped admin`, `screenshot:`, ...) rather than a
fixed `sleep` -- it's faster and fails loudly instead of capturing a
half-rendered screen. Give each poll its own `timeout` (~15-20s); don't
chain many polling loops inside one shell invocation -- see Gotchas.
Screenshots land in `/tmp/run-server-shots/` (override:
`SCREENSHOT_DIR`). **Actually look at them** -- a blank or error-page
screenshot is a failure to launch, not success.
**Test every UI change at both a desktop and a mobile viewport.** The
driver defaults to a desktop size (1280x900); switch with `viewport`.
The app's mobile breakpoint is 860px (`theme.css`) -- below that the
sidebar goes off-canvas behind a hamburger (`.mobile-bar`). A page that
looks right at 1280px can still overflow, overlap the mobile bar, or
mis-center a `<dialog>` at phone widths -- screenshot both:
```bash
tmux send-keys -t runserver 'viewport 1280 900' Enter # desktop (also the default)
tmux send-keys -t runserver 'screenshot desktop-x' Enter
tmux send-keys -t runserver 'viewport 390 844' Enter # iPhone-ish mobile width
tmux send-keys -t runserver 'screenshot mobile-x' Enter
```
### Driver commands
| command | what it does |
|---|---|
| `nav <path-or-url>` | navigate (relative paths resolve against `http://127.0.0.1:8420`, override with `RUN_SERVER_BASE_URL`) |
| `wait-for <selector>` | wait up to 10s for a selector (plain CSS -- see Gotchas for attribute selectors) |
| `click <selector>` | click an element |
| `fill <selector> <value>` | fill an input |
| `press <key>` | keyboard press (e.g. `Enter`) |
| `screenshot [name]` | → `/tmp/run-server-shots/<name>.png` |
| `eval <js>` | evaluate JS in the page, prints JSON |
| `console-errors` | prints all captured console/page errors as a JSON array |
| `viewport [w] [h]` | resize the viewport, default `390 844` -- use `1280 900` for desktop (see Gotchas re: real touch input) |
| `is-open <dialog-selector>` | prints `true`/`false` for a `<dialog>` element's `.open` -- use this instead of `wait-for sel[open]` |
| `bootstrap-admin [user] [pass]` | completes first-run `/setup` (defaults `admin`/`testpassword123`); links frame #1 and logs in |
| `quit` | closes the browser, exits the driver |
**3. Stop the server** when done:
```bash
bash .claude/skills/run-server/stop-server.sh
tmux kill-session -t runserver
```
## Run (human path)
```bash
cd server
DATABASE_URL="sqlite:////tmp/dev.db" CONFIG_PATH="/tmp/dev-config.json" \
.venv/bin/uvicorn app.main:app --reload --port 8420
```
Open `http://localhost:8420` in a real browser. `start.sh` (the Docker
entrypoint) is not this -- it also launches the Node whiteboard
render-service sidecar, which needs Node (not installed here) and
isn't needed for most UI testing.
## Test
```bash
cd server && .venv/bin/pytest
```
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
needed beyond the venv.
---
## Gotchas
- **`viewport` only resizes the window -- it does not emulate touch
input.** `click` still dispatches a mouse click, not a tap; there's
no touch-delay, no `:hover`-stickiness-after-tap, no `hasTouch`
context. It catches real bugs (layout overflow, off-canvas sidebar,
a `<dialog>` mis-centering at phone widths) but won't catch anything
that's specifically a touch-vs-mouse event difference. Good enough
for CSS/layout verification; not a substitute for testing on an
actual phone if a change touches touch-specific interaction.
- **`chrome-headless-shell` (Playwright's default headless target)
crashes on basic calls in this container**, e.g. `page.set_content()`
returns `TargetClosedError`, even after every `ldd`-reported missing
library is resolved. The full `chrome` binary (`chromium-*/chrome-linux64/chrome`)
+ `--no-sandbox` is stable; `driver.py` and `setup.sh` both use it,
not the headless-shell default.
- **Missing fonts silently break `fill()`, not just rendering.** Before
`fontconfig`/`libfontconfig1` were extracted and `fonts.conf` pointed
at the extracted font dir, `page.fill()` ran with no error but left
inputs empty (`input_value()` returned `""`), and all text rendered
invisible in screenshots. It looks like a scripting bug, not a
missing-lib problem -- if `fill` silently no-ops, suspect fonts
before suspecting the selector or a race.
- **No `apt-get install` / `playwright install-deps` (no root) and no
`apt-get update` into the real `/var/lib/apt/lists` (root-owned).**
Worked around by redirecting apt's state dirs to a scratch,
user-writable path (`-o Dir::State::Lists=... -o Dir::Cache=...`),
which makes plain `update` and `install --download-only --print-uris`
work as a non-root user; then `dpkg-deb -x <deb> <root>` (extract,
not install) needs no root either. `setup.sh` does this for
Chromium's deps *and* for `tmux` itself, which also isn't
preinstalled.
- **`wait-for` with an attribute selector like `#frame-preview-dialog[open]`
is unreliable through `tmux send-keys`** -- shell/tmux escaping of
`[`/`]` easily mangles it (seen: a real 10s Playwright timeout from a
garbled selector, not a fast failure). Use the app-specific `is-open
<selector>` command instead of `wait-for sel[open]` to check a
`<dialog>`'s open state.
- **Don't chain many `tmux send-keys` + polling-`timeout` loops inside
one shell invocation.** Each poll can legitimately take up to its own
timeout (e.g. 15s) if a selector is wrong; five or six chained in one
command can add up past this tool's own command timeout even though
each individual step is fine. Send one or two commands per shell
call and check the pane before continuing.
- **A crashed `chrome-headless-shell` process can leave a large core
dump file** (`server/core`, ~170MB, from the crash described above)
if core dumps are enabled. It's not part of the app -- delete it, and
use full `chrome` (as `driver.py` does) to avoid triggering it again.
## Troubleshooting
- **`error while loading shared libraries: libglib-2.0.so.0` (or similar)
when launching Chromium directly**: `LD_LIBRARY_PATH` isn't set --
source `.claude/skills/run-server/env.sh` first, or run through
`driver.py`, which reads `RUN_SERVER_CHROME_BIN` from it.
- **`fc-list` prints nothing after extracting fonts**: `fonts.conf`'s
`<dir>` entries still point at the real (unpopulated) `/usr/share/fonts`.
`setup.sh` patches this with `sed`; if you extracted packages by hand,
do the same.
- **`E: Could not open lock file ... Permission denied` from `apt-get`**:
you're missing the `-o Dir::State::Lists=... -o Dir::Cache=...`
overrides -- plain `apt-get update`/`install` always needs root here.
- **`tmux: command not found`**: not preinstalled and no sudo; run
`setup.sh`, which fetches it the same non-root way as Chromium's libs.
- **`tmux: error while loading shared libraries: libutempter.so.0`**:
you sourced `env.sh` inside the driver's tmux pane but not in the
shell that *invokes* `tmux` itself -- `tmux` was extracted from the
same non-root `.deb` set as Chromium and needs `LD_LIBRARY_PATH` too.
`source .claude/skills/run-server/env.sh` before the first `tmux`
command, not just inside `send-keys`.
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""REPL driver for the espresso_frame server's web UI.
Playwright-based since chromium-cli isn't available in this container.
Reads one command per line from stdin, prints a result line -- built
for tmux send-keys/capture-pane use by an agent. Vocabulary mirrors
chromium-cli where it overlaps (nav/wait-for/click/fill/screenshot/
eval/console-errors).
Requires setup.sh to have run first (Python venv + Playwright Chromium
+ the non-root shared-lib/font extraction). Run via:
.claude/skills/run-server/env.sh sourced, then
server/.venv/bin/python .claude/skills/run-server/driver.py
See SKILL.md for the full agent-path invocation (tmux wrapping etc).
"""
import glob
import json
import os
import sys
from playwright.sync_api import sync_playwright
SHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/tmp/run-server-shots")
os.makedirs(SHOT_DIR, exist_ok=True)
BASE = os.environ.get("RUN_SERVER_BASE_URL", "http://127.0.0.1:8420")
def find_chrome() -> str:
override = os.environ.get("RUN_SERVER_CHROME_BIN")
if override and os.path.exists(override):
return override
matches = glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome"))
if not matches:
sys.exit("chrome binary not found -- run setup.sh first")
return matches[0]
pw = sync_playwright().start()
# The FULL `chrome` binary, not chrome-headless-shell (Playwright's
# default headless target): chrome-headless-shell crashed on basic
# calls like set_content() in this container even once every
# ldd-reported missing lib was resolved. Full chrome + --no-sandbox is
# stable here.
browser = pw.chromium.launch(executable_path=find_chrome(), args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1280, "height": 900})
console_errors: list[str] = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
# Playwright auto-DISMISSES native confirm()/alert() dialogs by default
# (returns false) -- several destructive actions in this app (remove
# widget, clear all widgets) gate on `confirm()`, so without this a
# `click` on one of those buttons would silently no-op. Auto-accept
# instead, since a driver testing a "yes, do the destructive thing"
# flow needs the confirm to actually go through.
page.on("dialog", lambda dialog: dialog.accept())
def cmd_nav(arg):
url = arg if arg.startswith("http") else BASE + arg
page.goto(url)
print("nav ->", page.url)
def cmd_wait_for(arg):
page.wait_for_selector(arg, timeout=10000)
print("found:", arg)
def cmd_click(arg):
page.click(arg)
print("clicked:", arg)
def cmd_fill(arg):
sel, _, value = arg.partition(" ")
page.fill(sel, value)
print("filled:", sel, "=", value)
def cmd_press(arg):
page.keyboard.press(arg)
print("pressed:", arg)
def cmd_screenshot(arg):
name = arg or f"ss-{len(os.listdir(SHOT_DIR))}"
path = os.path.join(SHOT_DIR, name + ".png")
page.screenshot(path=path)
print("screenshot:", path)
def cmd_eval(arg):
try:
print(json.dumps(page.evaluate(arg)))
except Exception as e:
print("ERROR:", e)
def cmd_console_errors(_arg):
print(json.dumps(console_errors))
def cmd_viewport(arg):
"""Resize the viewport. No args -> 390x844 (iPhone-ish mobile
width); the page itself starts at 1280x900 (desktop) on launch, so
`viewport 1280 900` gets back to that. The app's mobile breakpoint
is 860px (see theme.css) -- anything under that exercises the
off-canvas sidebar/mobile-bar layout."""
parts = arg.split()
width = int(parts[0]) if len(parts) > 0 else 390
height = int(parts[1]) if len(parts) > 1 else 844
page.set_viewport_size({"width": width, "height": height})
print("viewport:", width, "x", height)
def cmd_is_open(arg):
"""App-specific: print whether a <dialog> element is open (true/false)."""
print(json.dumps(page.eval_on_selector(arg, "el => el.open")))
def cmd_bootstrap_admin(arg):
"""App-specific: complete first-run /setup (username/password args,
default admin/testpassword123). Every scratch DB starts with no
users, and /setup is the only way in -- it also auto-links the
pre-existing frame #1 (created by migrations) to the new admin, so
/frames/1/... is reachable right after this."""
parts = arg.split()
username = parts[0] if len(parts) > 0 else "admin"
password = parts[1] if len(parts) > 1 else "testpassword123"
page.goto(BASE + "/setup")
page.fill("input[name=username]", username)
page.fill("input[name=password]", password)
page.click("button[type=submit]")
page.wait_for_load_state("networkidle")
print("bootstrapped admin, now at:", page.url)
def cmd_quit(_arg):
browser.close()
pw.stop()
sys.exit(0)
COMMANDS = {
"nav": cmd_nav,
"wait-for": cmd_wait_for,
"click": cmd_click,
"fill": cmd_fill,
"press": cmd_press,
"screenshot": cmd_screenshot,
"eval": cmd_eval,
"console-errors": cmd_console_errors,
"viewport": cmd_viewport,
"is-open": cmd_is_open,
"bootstrap-admin": cmd_bootstrap_admin,
"quit": cmd_quit,
}
print("run-server driver -- commands:", ", ".join(COMMANDS), flush=True)
print("driver>", end=" ", flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
print("driver>", end=" ", flush=True)
continue
cmd, _, rest = line.partition(" ")
fn = COMMANDS.get(cmd)
if fn is None:
print("unknown command:", cmd, "-- try one of:", ", ".join(COMMANDS))
else:
try:
fn(rest)
except Exception as e:
print("ERROR:", e)
print("driver>", end=" ", flush=True)
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# One-time (idempotent) environment bootstrap for running the
# espresso_frame FastAPI server and browser-driving its UI, in a
# container that ships with NO Python/Node/Docker/browser and NO sudo.
# Re-run any time; every step checks whether it already happened.
set -euo pipefail
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
UV_BIN="$HOME/.local/bin/uv"
DEPS_ROOT="/tmp/run-server-chromium-deps"
APT_WORK="/tmp/apt-work-run-server"
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SKILL_DIR/env.sh"
# 1. uv: a static Rust binary that can fetch its own Python build with
# no C compiler needed (this container has none).
if [ ! -x "$UV_BIN" ]; then
echo "installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
# 2. Python 3.12 + venv + server deps
if [ ! -x .venv/bin/uvicorn ]; then
echo "creating venv + installing server deps..."
"$UV_BIN" python install 3.12
"$UV_BIN" venv --python 3.12 .venv
"$UV_BIN" pip install -r requirements.txt
fi
# 3. Playwright (Python) + its Chromium download (~280MB: full chrome +
# chrome-headless-shell + ffmpeg)
if ! .venv/bin/python -c "import playwright" 2>/dev/null; then
echo "installing playwright..."
"$UV_BIN" pip install playwright
fi
if ! ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome >/dev/null 2>&1; then
echo "downloading chromium..."
.venv/bin/playwright install chromium
fi
# 4. Chromium's shared libs + fonts. `playwright install-deps` and
# `apt-get install` both need root; neither is available. Instead:
# download the .deb files directly (apt-get download works read-only
# without root once given a user-writable state dir) and extract
# (not install) them with dpkg-deb -x, which needs no root either.
if [ ! -f "$DEPS_ROOT/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0" ]; then
echo "fetching chromium's shared libs + fonts (non-root)..."
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
-o Dir::Etc::SourceParts=/dev/null update
PKGS="libglib2.0-0t64 libnspr4 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64
libdbus-1-3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1
libxkbcommon0 libasound2t64 libatspi2.0-0t64 libcups2t64 libcairo2
libpango-1.0-0 libpangocairo-1.0-0 libx11-6 libxcb1 libxext6
fonts-liberation fontconfig libfontconfig1"
# ^ first row: what chrome-headless-shell's ldd reported missing.
# second row: what the FULL chrome binary additionally needed (we use
# full chrome, not headless-shell -- see Gotchas in SKILL.md).
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
--print-uris $PKGS | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/urls.txt"
(cd "$APT_WORK/debs" && xargs -n1 -P8 curl -sS -O --max-time 30) < "$APT_WORK/urls.txt"
for f in "$APT_WORK"/debs/*.deb; do dpkg-deb -x "$f" "$DEPS_ROOT"; done
# fonts.conf as shipped points at the real /usr/share/fonts, which is
# root-owned and has nothing extracted into it. Point it at our
# extracted copy instead, and give it a writable cache dir.
mkdir -p /tmp/run-server-fontcache
sed -i "s#<dir>/usr/share/fonts</dir>#<dir>$DEPS_ROOT/usr/share/fonts</dir>#" \
"$DEPS_ROOT/etc/fonts/fonts.conf"
sed -i "s#<cachedir>.*</cachedir>#<cachedir>/tmp/run-server-fontcache</cachedir>#" \
"$DEPS_ROOT/etc/fonts/fonts.conf"
PATH="$DEPS_ROOT/usr/bin:$PATH" \
LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu" \
FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts" \
"$DEPS_ROOT/usr/bin/fc-cache" -f
fi
# 5. tmux -- also missing, also no apt/sudo. Same non-root download +
# dpkg-deb -x trick, into the same extracted root (so its `usr/bin` is
# already on PATH via env.sh).
if [ ! -f "$DEPS_ROOT/usr/bin/tmux" ]; then
echo "fetching tmux (non-root)..."
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
--print-uris tmux | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/tmux_urls.txt"
(cd "$APT_WORK/debs" && xargs -n1 -P3 curl -sS -O --max-time 30) < "$APT_WORK/tmux_urls.txt"
for f in $(sed -E 's#.*/##' "$APT_WORK/tmux_urls.txt"); do dpkg-deb -x "$APT_WORK/debs/$f" "$DEPS_ROOT"; done
fi
CHROME_BIN="$(ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | head -1)"
cat > "$ENV_FILE" <<EOF
# Generated by setup.sh. Source this before running driver.py (it
# needs LD_LIBRARY_PATH/FONTCONFIG_PATH set before the Chromium
# subprocess launches -- driver.py does not source it for you).
# start-server.sh does NOT need this file -- uvicorn has no such deps.
export PATH="\$HOME/.local/bin:$DEPS_ROOT/usr/bin:\$PATH"
export LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu"
export FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts"
export RUN_SERVER_CHROME_BIN="$CHROME_BIN"
EOF
echo "setup complete -> $ENV_FILE"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Background-launch the server against a scratch DB/config -- never the
# real deployment's data (see CLAUDE.md). Waits for readiness, prints
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
set -euo pipefail
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
SCRATCH="${1:-/tmp/run-server-scratch}"
PORT="${2:-8420}"
mkdir -p "$SCRATCH"
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
CONFIG_PATH="$SCRATCH/config.json" \
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
> "$SCRATCH/server.log" 2>&1 &
PID=$!
echo "$PID" > "$SCRATCH/server.pid"
for _ in $(seq 1 30); do
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
sleep 0.5
done
if ! curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; then
echo "server did not become ready -- check $SCRATCH/server.log" >&2
exit 1
fi
echo "server PID $PID up on http://127.0.0.1:$PORT (log: $SCRATCH/server.log, db: $SCRATCH/test.db)"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Usage: ./stop-server.sh [scratch-dir]
SCRATCH="${1:-/tmp/run-server-scratch}"
if [ -f "$SCRATCH/server.pid" ]; then
kill "$(cat "$SCRATCH/server.pid")" 2>/dev/null || true
rm -f "$SCRATCH/server.pid"
echo "stopped"
else
echo "no $SCRATCH/server.pid -- nothing to stop"
fi
+51
View File
@@ -0,0 +1,51 @@
name: Firmware build check
# Fires on every push touching firmware source, unlike
# firmware-release-build.yml (which only builds+publishes when
# firmware/version.txt itself is bumped -- the "cut a release" signal).
# This just verifies both board variants still compile; nothing else in
# CI catches a firmware/** push that breaks the build until someone
# happens to bump the version next.
on:
push:
branches: [main]
paths:
- "firmware/**"
- ".gitea/workflows/firmware-build-check.yml"
jobs:
build-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Same docker create/cp/start pattern as firmware-release-build.yml
# (see that file's own comment for why -- the runner's job
# workspace lives in a named Docker volume, not a real host path,
# so a nested `docker run -v "$PWD:..."` bind-mounts nothing
# useful). No release/artifact step here -- this only needs to
# prove `idf.py build` still succeeds for each board.
- name: Build (devkit -- ESP32-C6-DevKitC-1)
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh devkit set-target esp32c6 &&
./build_for_board.sh devkit build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
- name: Build (xiao -- Seeed XIAO ESP32-C6)
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh xiao set-target esp32c6 &&
./build_for_board.sh xiao build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
+38
View File
@@ -8,7 +8,27 @@ on:
- ".gitea/workflows/server-docker-build.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
working-directory: server
run: pip install -r requirements-dev.txt
- name: Run tests
working-directory: server
run: pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -32,3 +52,21 @@ jobs:
tags: |
git.thumeit.com/tfaour/espresso-frame-server:latest
git.thumeit.com/tfaour/espresso-frame-server:${{ gitea.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '22' }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
espressoframe_deployer@"$DEPLOY_HOST" \
'cd ~/espresso-frame && docker compose pull && docker compose up -d'
+12
View File
@@ -17,6 +17,13 @@ server/**/__pycache__/
server/.venv/
server/*.egg-info/
server/data/
server/.pytest_cache/
# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
# inside the Docker image, never committed. No package-lock.json exists
# yet either (no Node/npm available in this project's dev environment to
# generate one -- see render-service/README.md); if one's added later, do
# NOT ignore it, lockfiles belong in git.
server/render-service/node_modules/
# Real deploy config, copied from docker-compose.yml.example -- holds the
# Immich API key, must never be committed.
server/docker-compose.yml
@@ -27,3 +34,8 @@ server/docker-compose.yml
.idea/
*.swp
.DS_Store
.claude/*
# ...except Claude Code skills (e.g. agent-run instructions for this
# app) -- those are project tooling worth sharing, not personal/local
# state like settings.local.json or worktrees/.
!.claude/skills/
+103
View File
@@ -0,0 +1,103 @@
# espresso_frame
A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable
photos/calendar/whiteboard widget system to the device.
CURRENT TODO
-add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-make a weather widget
-widget border option
-battery life widget
-sharing layouts with linked users
-a "coming up this week" widget
-scan to download for non-immich photos too?
-switch button reset action? and on reset dismiss the menu.
Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
server talk (sequence diagram, boot flow).
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
(data model, grid placement, compositor, button-action dispatch). Notes
a known gap at the bottom (legacy `Frame` columns not yet dropped).
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
-- per-component setup, config, and a lot of accumulated gotchas
(Immich API shape, TLS trust-anchor details, button GPIO wakeup
quirks, etc.) -- check these before assuming something is a new bug.
## Conventions specific to this repo
- **No `Co-Authored-By: Claude` trailers in commits.** Attribution lives
in the root [`README.md`](README.md) instead (see its last line) --
the maintainer's explicit preference, not the default.
- **Copyleft dependencies need an explicit flag, not a silent decision.**
Before adding anything LGPL/GPL/AGPL (or unclear), verify the actual
license via `pip show`/package metadata -- including transitive deps,
not just the top-level package -- and present the finding and tradeoff
in plain text rather than picking an approach unilaterally (hand-rolling
an alternative, swapping packages, silently accepting
a "coming up this week" widget). This project
has knowingly accepted AGPL-3.0-or-later exposure once already
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
explicit call -- not a precedent for skipping the check next time.
- **Scope new auth/access-control broadly, not just to the literal
endpoint named.** When a request changes the trust model (e.g. adding
public-internet exposure), apply the new gate to every endpoint serving
real data or performing a real action, and call out anything you're
tempted to exclude and why. This repo shipped a token gate once that
covered `/api/*` but left `/frame/image` -- the actual photo bytes --
open; caught immediately in production.
- **Commit and push once a task is verified working, without waiting to
be asked separately.** Once tests pass (and, for UI changes, the
browser check has been done), stage the relevant files, write a normal
commit message, and push to the current branch -- the maintainer's
standing authorization for the commit/push step itself. This doesn't
relax anything else: still run `git status`/review the diff before
staging, still never force-push/amend a pushed commit/skip hooks, and
still surface anything that looks like it needs a real decision (e.g.
a change that would trigger `main`'s deploy workflow, see below)
instead of pushing through it silently.
## Working in this repo
- **Server tests**: `cd server && pytest` (SQLite, fixtures wipe/reseed
between tests -- see `tests/conftest.py`). Migration changes need a
matching test in `tests/test_migrations.py`; anything touching
`require_frame_view`/`require_frame_control` boundaries needs a
same-shape permission test (see `tests/test_permission_boundaries.py`
and `tests/test_button_actions.py` for the pattern: owner, linked user,
unrelated user, logged out).
- **UI changes**: verify in a real browser (Playwright), not just by
reading the JS -- this project has hit multiple bugs that only showed up
live (mobile viewport CSS collapse, a dialog's status message landing
behind its own backdrop, a JSON/form-urlencoded body mismatch). Spin up
`uvicorn app.main:app` against a scratch `DATABASE_URL`/`CONFIG_PATH`
sqlite file, don't touch the real deployment's data. `.claude/skills/run-server/`
(`/run-server`) has a driver for exactly this.
- **New/changed UI must work at both desktop and mobile widths --
screenshot both, don't assume one implies the other.** The layout
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
goes off-canvas behind a hamburger below
a "coming up this week" widget. A dialog, header
control, or new widget that looks right at a wide viewport can
overflow, overlap the mobile bar, or mis-center at phone widths.
`run-server`'s driver has a `viewport` command for exactly this
(defaults to a phone size; switch to `1280 900` for desktop).
- **Deploy**: Gitea Actions at `git.thumeit.com/tfaour/espresso_frame`
(`.gitea/workflows/server-docker-build.yml`: `test` -> `build-and-push`
-> `deploy` on any push to `main` touching `server/**`; `deploy` SSHes
into the host as `espressoframe_deployer` and runs `docker compose pull
&& docker compose up -d`). A separate workflow
(`firmware-release-build.yml`) builds+publishes firmware binaries as
Gitea release assets when `firmware/version.txt` changes. Poll CI status
with `curl https://git.thumeit.com/api/v1/repos/tfaour/espresso_frame/actions/tasks`
rather than asking the user to check.
- **Device-facing paths are frozen.** `/frame/image`, `/frame/advance`,
`/frame/back`, `/frame/config`, `/frame/battery`, `/frame/firmware` and
their exact JSON key names (`refresh_interval_s`, `firmware_version`,
etc.) are baked into deployed firmware -- never rename or restructure
these without a firmware-side migration story to match.
+3 -2
View File
@@ -24,9 +24,10 @@ time in deep sleep.
- ESP32-C6 dev board (8MB flash)
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI
See [`docs/hardware.md`](docs/hardware.md) for wiring and
See [`docs/hardware.md`](docs/hardware.md) for wiring,
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk
to each other.
to each other, and [`docs/widgets.md`](docs/widgets.md) for the server's
placeable photos/calendar/whiteboard widget system.
## Getting started
+16 -5
View File
@@ -22,17 +22,17 @@ sequenceDiagram
Frame->>Frame: Connect to home WiFi
alt next-photo button pressed
Frame->>Server: POST /frame/advance
Server->>Server: Force-advance to next queued photo, reset interval clock
Server->>Server: Run every action assigned to NEXT, in order<br/>(may span several widgets -- see docs/widgets.md)
else back-photo button pressed
Frame->>Server: POST /frame/back
Server->>Server: Return to previously-current photo (bounded history),<br/>reset interval clock
Server->>Server: Run every action assigned to BACK, in order
else normal wake
Frame->>Server: GET /frame/image
Server->>Server: Advance only if refresh_interval_s has elapsed<br/>since the current photo was set -- otherwise a no-op
Server->>Server: Render every widget on the panel into its own region<br/>(each independently idempotent -- a photo widget only<br/>actually advances once its own refresh_interval_s has elapsed)
end
Server->>Immich: List album assets / download preview / faces
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
Immich-->>Server: JPEG + face bounding boxes
Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
Server-->>Frame: 192,000 raw bytes, streamed
Frame->>Frame: Write to panel SPI buffer, compute CRC32
alt CRC unchanged since last physical refresh
@@ -45,6 +45,17 @@ sequenceDiagram
Frame->>Frame: Deep sleep (server-configured interval, or a short<br/>retry interval on any failure)
```
The device-facing endpoints above (`/frame/image`, `/frame/advance`,
`/frame/back`, `/frame/config`) are frozen -- baked into deployed firmware
-- and unchanged by any of this. What *does* change server-side: a frame's
panel isn't a single fixed "mode" anymore, it holds an arbitrary
arrangement of independently placed/sized widgets (photos/calendar/
whiteboard, including several of the same type), each rendered into its
own region and composited together, with NEXT/BACK each mapped to their
own ordered list of per-widget actions rather than one fixed meaning. See
[`docs/widgets.md`](widgets.md) for the widget system's data model,
placement grid, and button-action dispatch.
## Firmware boot flow
1. **No stored config** (first boot, or NVS erased): bring up the display,
+209
View File
@@ -0,0 +1,209 @@
# Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
arranging icons on an Android home screen. A frame can hold several widgets of the
same type (e.g. two photo widgets pointed at different Immich albums side
by side).
This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
is still physically present but unused, pending a final cleanup migration
(see "Known gaps" below).
The device-facing contract is unchanged by any of this: `GET /frame/image`,
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
firmware has always called (see `docs/architecture.md`) -- what changed is
entirely server-side, in how those endpoints decide what to render and what
a button press does.
## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see
`server/app/text_content.py`'s module docstring for why that parse
step is the widget's actual stored-XSS sanitization boundary.
`PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only, single-list task list); split
into its own widget type (migration 17) so a task list can be placed
and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has.
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical
shape and permission model (owner-added, anyone-linked-can-mute, see
"Per-widget config UI" below) -- `FrameTaskList` just has no `"ics"`
calendar_key variant, since a plain ICS subscription has no VTODO
(task) collection.
- `FrameButtonAction` (`id`, `frame_id`, `button` [`"next"`|`"back"`],
`widget_id`, `action`, `sort_order`) -- see "Button actions" below.
## Placement: a grid, not freeform pixels
`app/grid.py` is pure grid math, no I/O. The grid is `GRID_LONG=8` x
`GRID_SHORT=5` cells, defined relative to the panel's long/short axis
(not "landscape" specifically) so it stays valid across
`image_pipeline.logical_render_size(orientation)`'s genuine width/height
swap for portrait -- landscape orientations are 8 cols x 5 rows, portrait
are 5 cols x 8 rows, same cell size either way. **Changing a frame's
orientation invalidates its existing layout** (an 8x5 arrangement isn't
valid on a 5x8 grid) -- the server resets to one full-panel widget on an
orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone.
## Rendering: one shared compositor
`app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`, `text.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's
content composed into its own region. Never raises for a foreseeable
failure (an Immich hiccup, an unconfigured widget) -- falls back to a
small placeholder within its own region instead, so one widget having a
bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks, static image, and text -- nothing to
advance/back/force for a passive checklist, a fixed uploaded image, or
a fixed block of authored text.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI.
`routers/device.py`'s `_render_widgets` loads every `Widget` row for the
frame, maps each one's grid rect to pixels (`grid.cell_to_pixels`), calls
its module's `render()`, and hands the whole list of `(rect, image)`
regions to `image_pipeline.render_panel` -- which pastes every region onto
one shared canvas, then runs enhance/manage-overlay/quantize/dither/pack
**once** over the composited result. Quantizing the whole canvas together
(not each region separately before pasting) is what keeps the 6-color
e-ink dithering pattern consistent across a widget boundary instead of a
visible seam at the edge.
Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
`_SIZE_TIERS`) for font size/margins/row heights based on their actual
grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible.
## Button actions
Each physical button (NEXT/BACK) maps to an **ordered list** of
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
"photo widget A: advance" *and* "calendar widget B: advance" together, or
even a mismatched combination on purpose. On a press,
`routers/device.py`'s `_run_button_actions` runs every assigned action for
that button in order (each in its own `widget_locked` span -- never nested,
since the underlying per-frame lock isn't reentrant), catching and
logging any single action's failure without blocking the rest, then
re-renders and returns the whole composed panel once at the end regardless
of which actions succeeded.
The web UI for this is the "Button assignments" card on a frame's
Configuration tab (`static/frame_config.js`, `GET`/`PUT
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
the same type would otherwise both just say "Photos" in the assignment
dropdowns; the UI disambiguates using each widget's grid position (e.g.
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
apart by eye on the Layout canvas.
A newly-created widget (including the one auto-migrated from a frame's old
`mode` on upgrade) gets a sensible default binding reproducing its old
button behavior -- see `migration.py`'s `_default_button_actions`.
## Per-widget config UI
Each widget has a gear-icon button on the Layout canvas that opens a
`<dialog>` with that widget's own settings (album, calendar/task-list
inclusion, whiteboard source, etc.) -- not a per-frame tab, since a
frame can now have several widgets of the same type with independent
settings. The
dialog HTML is injected server-rendered (`routers/frame_pages.py`'s
`widget_dialog`, dispatching on `widget.widget_type`); its JS is a
top-level, always-loaded file (`static/widget_dialog_*.js`) exposing
`init<Type>Dialog()`/`close<Type>Dialog()`, since dynamically-injected
HTML can't carry executable `<script>` tags. While a dialog is open,
`window.FRAME_API` is repointed at that widget's own API base
(`/api/frames/{id}/widgets/{widget_id}`) and restored on close;
`window.FRAME_BASE_API` stays pointed at the frame-level base throughout
for the always-present header/status-bar JS.
## Saved layouts
A user can snapshot a frame's whole widget arrangement -- every widget's
type/placement/settings, calendar/task sources, and button-action
bindings -- under a name (`SavedLayout` + `SavedLayoutWidget` +
`SavedLayoutSource` + `SavedLayoutButtonAction`, `server/app/models.py`),
then switch back to it later, or apply it to a *different* frame. Saved
layouts are owned by the **user**, not any one frame -- the same set
shows up (with a per-frame `compatible` flag) on every frame that user
controls whose grid matches (`grid.grid_dims(orientation)`'s cols/rows,
landscape-class 8x5 vs. portrait-class 5x8), not just the frame it was
captured from.
Saving only captures an authored *setting*, never runtime/cache state --
a photo widget's current queue position, a calendar's fetch cache, a
whiteboard's rendered-image cache, etc. are deliberately left out (see
`routers/api_layouts.py`'s `LAYOUT_CONFIG_FIELDS` allowlist per
`widget_type`), so applying a layout feels like a fresh widget of that
type with its settings pre-filled, not a resurrection of stale state
from whenever it was saved. A static-image widget's uploaded bytes are
the one exception carried through verbatim (`SavedLayoutWidget.image`).
Saving again under a name the user already has overwrites that layout's
snapshot in place rather than erroring or creating a duplicate --
`SavedLayout`'s own docstring.
Applying a layout to a frame (`api_layout_apply`, `require_frame_control`)
deletes every widget currently on that frame and recreates the saved
arrangement from scratch, remapping calendar/task sources and button
bindings onto the newly-created widget ids -- same "act unconditionally
on the server, confirm on the client" posture as the Layout tab's own
"Clear all". A source whose owning user account no longer exists is
silently dropped rather than left dangling (config is JSON, not
FK-checked, so nothing else would catch that).
The web UI lives in the Layout tab's "Saved layouts" card
(`static/saved_layouts.js`, `GET`/`POST /api/frames/{id}/layouts`,
`PATCH`/`DELETE /api/layouts/{id}`, `POST
/api/frames/{id}/layouts/{id}/apply`) -- name + Save, then a list of
saved layouts each with Apply/rename/delete, incompatible ones shown
greyed-out with a "different orientation" badge rather than hidden.
## Known gaps (Phase 6, not yet done)
The original 8-phase rollout plan's last phase is still open:
- Legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are
still physically present in the schema but no longer read or written
anywhere -- they need a dedicated final migration to drop them. Left in
place deliberately through the widget-system rollout (a much larger
blast radius cutover than this project's usual same-migration-drop
convention) but there's no reason to keep carrying them now that every
phase has shipped.
- `server/README.md` still describes photos/calendar/whiteboard as
per-frame "modes" in several places rather than widgets -- needs a pass
once the column drop above is safely deployed.
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
reliable yet, treat it as experimental if extending it.
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c ota_update.c board_antenna.c
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html)
+30 -7
View File
@@ -1,3 +1,5 @@
#include <stdlib.h>
#include "driver/gpio.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_adc/adc_oneshot.h"
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
#define BATTERY_SAMPLES 8
#define BATTERY_SAMPLES 16
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
* RF transients, not the true resting voltage) are dropped before
* averaging the rest -- a plain average lets even one or two of those
* skew the result enough to read as a real percent change downstream
* (see the recharge-jump handling in routers/device.py). */
#define BATTERY_TRIM 3
/* The external divider halves the battery voltage (2x200k, per the
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
* pin, inside the 12dB-attenuation ADC range. */
@@ -34,6 +42,11 @@ static const struct {
{ 3300, 5 }, { 3000, 0 },
};
static int int_cmp(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b;
}
static int mv_to_percent(int mv)
{
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
@@ -139,19 +152,17 @@ int battery_read_percent(void)
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
}
int mv_sum = 0;
int mv_samples[BATTERY_SAMPLES];
int samples = 0;
for (int i = 0; i < BATTERY_SAMPLES; i++) {
int value;
if (calibrated) {
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
mv_sum += value;
samples++;
mv_samples[samples++] = value;
}
} else {
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
samples++;
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
}
}
}
@@ -167,7 +178,19 @@ int battery_read_percent(void)
return -1;
}
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
/* Only trim if there's enough left afterward to still be a
* meaningful average -- falls back to a plain average of whatever
* came in on a wake where most reads failed. */
qsort(mv_samples, samples, sizeof(int), int_cmp);
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
int mv_sum = 0;
int kept = 0;
for (int i = trim; i < samples - trim; i++) {
mv_sum += mv_samples[i];
kept++;
}
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
return -1;
+46 -351
View File
@@ -16,7 +16,6 @@
#include "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "combo_button.h"
#include "ota_update.h"
#include "board_antenna.h"
@@ -314,35 +313,6 @@ static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
return true;
}
/* Same as json_extract_uint(), but signed -- e.g. battery_percent's -1
* ("no reading") sentinel. strtoul() would silently wrap a leading '-'
* into a huge unsigned value instead of failing, so this needs its own
* strtol()-based parse rather than reusing json_extract_uint(). */
static bool json_extract_int(const char *json, const char *key, int *out)
{
char needle[48];
snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *pos = strstr(json, needle);
if (pos == NULL) {
return false;
}
pos = strchr(pos, ':');
if (pos == NULL) {
return false;
}
pos++;
while (*pos == ' ') {
pos++;
}
char *end;
long value = strtol(pos, &end, 10);
if (end == pos) {
return false;
}
*out = (int)value;
return true;
}
/* Finds the string value associated with "key" in a small, flat JSON
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
* Same rationale as json_extract_uint() -- not a general parser. Returns
@@ -455,248 +425,35 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
return result;
}
/* GETs the server's /frame/photo-info for the manage-button overlay:
* location/taken_at text (left empty if the server didn't have them --
* e.g. no GPS EXIF to geocode, or no capture date), a share_url built
* from the returned asset_id (same construction pattern as
* run_fetch_cycle()'s management_url), and the last battery percent this
* frame reported (-1 if none yet). The overlay uses that last-known
* value rather than a fresh local reading -- it's needed before this
* photo is composited and pushed to the panel, i.e. before this cycle's
* own reading (taken later, right before it's reported -- see
* frame_client_run()) even exists yet. Any failure (unreachable, no
* current photo, etc.) just leaves all outputs empty/-1 -- the caller
* treats that as "skip these optional overlay regions", not a hard
* error, since the base "scan to manage" QR should still show. */
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
char *share_url, size_t share_url_size, int *battery_percent)
{
location_line1[0] = '\0';
location_line2[0] = '\0';
taken_at[0] = '\0';
share_url[0] = '\0';
*battery_percent = -1;
char url[256];
build_url(url, sizeof(url), cfg, "frame/photo-info");
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
* unlike fetch_frame_config() (always called after the image fetch
* has already warmed the connection, see frame_client_run()), this
* is the *first* network call of the wake cycle whenever the manage
* menu is opened -- same cold-connection latency spike that made
* the short timeout unreliable for /frame/config before, now worse
* with a real TLS handshake on top. Confirmed on hardware: this
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
* rest of the cycle (a fresh connection, but not the *first* one)
* succeeded fine. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return;
}
char body[384];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
json_extract_string(body, "location_line1", location_line1, location_line1_size);
json_extract_string(body, "location_line2", location_line2, location_line2_size);
json_extract_string(body, "taken_at", taken_at, taken_at_size);
json_extract_int(body, "battery_percent", battery_percent);
char asset_id[48];
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
char path[80];
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
build_url(share_url, share_url_size, cfg, path);
}
}
/* GETs the server's /frame/face-labels for the manage-button's escalated
* "level 2" menu -- named-face positions, if Immich has any for the
* current photo. Response is a flattened, fixed-slot shape ("count",
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
* array, read with the same flat-scalar helpers as everywhere else in
* this file instead of needing an actual array parser. Any failure
* (unreachable, malformed response, etc.) just returns 0 -- named faces
* are a "nice to have" addition to the menu, not worth failing it over. */
static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
{
char url[256];
build_url(url, sizeof(url), cfg, "frame/face-labels");
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
* request too, not a warmed-connection reachability check. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return 0;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return 0;
}
char body[768];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
uint32_t count = 0;
json_extract_uint(body, "count", &count);
/* Unsigned compare: casting count to int first let a server-supplied
* value >= 2^31 (still a perfectly ordinary decimal in the JSON) go
* negative, skipping this clamp entirely and driving the loop below
* with the full attacker/server-controlled count -- out[found] is a
* fixed MANAGE_FACE_LABELS_MAX-element caller stack array. */
if (count > (uint32_t)max_labels) {
count = (uint32_t)max_labels;
}
int found = 0;
for (uint32_t i = 0; i < count; i++) {
char key[16];
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
continue;
}
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
uint32_t x;
if (!json_extract_uint(body, key, &x)) {
continue;
}
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
uint32_t y;
if (!json_extract_uint(body, key, &y)) {
continue;
}
out[found].x = (int)x;
out[found].y = (int)y;
found++;
}
return found;
}
typedef struct {
esp_http_client_handle_t client;
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
} http_read_ctx_t;
/* Splices one overlay region's pixels over the real photo bytes in chunk
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
* within the full frame intersects that region's rectangle. Rows/chunks
* outside the region's footprint are left completely untouched.
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
* is exact. */
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
const manage_overlay_region_t *region)
{
int byte_x0 = region->x0 / 2;
int byte_w = region->w / 2;
size_t chunk_end = chunk_start + chunk_len;
for (int row = region->y0; row < region->y0 + region->h; row++) {
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
size_t row_end = row_start + (size_t)byte_w;
size_t lo = row_start > chunk_start ? row_start : chunk_start;
size_t hi = row_end < chunk_end ? row_end : chunk_end;
if (lo >= hi) {
continue;
}
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
}
}
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
}
}
/* Pulls the next chunk straight out of the in-progress HTTP response --
* epd_write_frame() calls this to feed the panel without ever holding
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
* set) as chunks pass through, so the panel driver never needs to know
* an overlay exists at all. */
* the full ~192KB frame in RAM. Just a plain relay: the manage overlay
* (scan-to-manage QR, battery, location/date, share-QR, named face
* labels) is composited server-side now (see server/app/manage_overlay.py),
* baked into the same image bytes as any other render -- this function,
* like the rest of this file, has no idea an overlay exists. */
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
if (n <= 0) {
return 0;
}
if (ctx->overlay != NULL) {
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
}
ctx->stream_pos += (size_t)n;
return (size_t)n;
return n > 0 ? (size_t)n : 0;
}
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons), and streams the
* response directly into the panel, splicing in overlay's pixels (if
* non-NULL) as it streams. Returning non-ESP_OK means the panel was
* never actually refreshed -- epd_display_stream() (see epd7in3e.c)
* refuses to trigger a physical refresh on a short/wrong-size stream,
* so a failure here always leaves the visible screen exactly as it
* was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
const manage_overlay_set_t *overlay)
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
* manage button) appends &manage=1, telling the server to bake its
* overlay into this same response instead of returning the bare
* content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen
* exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{
const char *path = "frame/image";
if (action == FETCH_ADVANCE) {
@@ -707,6 +464,12 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
char url[256];
build_url(url, sizeof(url), cfg, path);
if (manage) {
size_t len = strlen(url);
if (len + strlen("&manage=1") < sizeof(url)) {
strcpy(url + len, "&manage=1");
}
}
esp_http_client_config_t config = {
.url = url,
@@ -733,7 +496,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
}
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
http_read_ctx_t ctx = { .client = client };
uint32_t crc = 0;
err = epd_write_frame(http_read_fn, &ctx, &crc);
@@ -761,8 +524,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
return err;
}
#define MANAGE_MENU_MAX_LEVEL 2
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
#define MANAGE_MENU_TIMEOUT_MS 30000
#define MANAGE_MENU_POLL_MS 150
#define MANAGE_MENU_DEBOUNCE_MS 30
@@ -794,108 +556,40 @@ static bool wait_for_button_press(uint32_t timeout_ms)
return false;
}
/* Builds and shows one level of the manage menu: level 1 is the base
* overlay (management QR + location/date/share-QR wherever the server
* had that data); level 2 adds named-face labels on top. action only
* applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances/-backs. */
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
{
char management_url[256];
build_url(management_url, sizeof(management_url), cfg, "");
char location_line1[32];
char location_line2[32];
char taken_at[32];
/* Wider than the other URL buffers in this file: unlike a fixed path,
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
* worst case ~266 bytes, which a 256-byte buffer could silently
* truncate the token off of (build_url()'s bounds check avoids an
* overflow, but a truncated/dropped token still means the resulting
* request just 401s with no obvious cause). */
char share_url[320];
int battery_percent;
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url),
&battery_percent);
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0;
if (level >= 2) {
face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
}
manage_overlay_content_t content = {
.management_url = management_url,
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
.share_url = share_url[0] != '\0' ? share_url : NULL,
.face_labels = face_labels,
.face_label_count = face_label_count,
.battery_percent = battery_percent,
};
manage_overlay_set_t overlay;
esp_err_t err = manage_overlay_render(&content, &overlay);
if (err != ESP_OK) {
manage_overlay_free(&overlay);
return err;
}
err = fetch_and_display(cfg, action, &overlay);
manage_overlay_free(&overlay);
return err;
}
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
* from there, each further press within 30s escalates one level (up to
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
* already at the max level exits immediately instead of escalating
* further. A 30s timeout at any level also exits. Device stays awake
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
* only if the very first (level 1) render/fetch failed; failures after
* that (escalating, or the final revert) are logged but don't count as
* an overall failure -- something was already shown successfully, which
* was the point of the button. */
/* Runs the manage-button view: fetches once with manage=1 (the server
* bakes its whole overlay -- scan-to-manage QR, battery, location/date/
* share-QR, every named face label, no more RAM-driven cap on how many --
* into the response), shows it, then waits up to 30s for either another
* press or the timeout before reverting to a plain fetch. Device stays
* awake throughout (doesn't sleep the panel or the chip). Returns
* non-ESP_OK only if the manage fetch itself failed; a revert failure
* after that is logged but doesn't count as an overall failure --
* something was already shown successfully, which was the point of the
* button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
{
int level = 1;
esp_err_t err = show_menu_level(cfg, action, level);
esp_err_t err = fetch_and_display(cfg, action, true);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, NULL);
ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, false);
}
for (;;) {
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
break; /* timeout at any level, or a press while already maxed out -- exit */
}
level++;
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break;
}
}
ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
ESP_LOGW(TAG, "Failed to revert manage view (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see
* run_management_menu()). */
* show_management_qr -- the manage view (see run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
if (!show_management_qr) {
return fetch_and_display(cfg, action, NULL);
return fetch_and_display(cfg, action, false);
}
return run_management_menu(cfg, action);
}
@@ -1011,9 +705,10 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
esp_ota_mark_app_valid_cancel_rollback();
/* Read now, not at boot: the photo (and, if shown, the manage
* overlay -- which gets its own battery number from the server's
* last-known value, not a local reading, see fetch_photo_info())
* is already on the panel, so there's no display deadline to beat.
* overlay -- entirely server-composited now, using the server's
* own last-known battery value, not a local reading, see
* server/app/manage_overlay.py) is already on the panel, so
* there's no display deadline to beat.
* Reading here instead of right after waking sidesteps taking the
* ADC sample while the rail's still settling from whatever the
* boot/reset just did, with no need to guess a settle delay --
+7 -6
View File
@@ -33,12 +33,13 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
* deep-sleep until the next refresh.
*
* If show_management_qr is true (the manage button was held), the
* displayed photo gets a small "scan to manage" QR overlay in the
* top-right corner linking to the server's config page, held for 30
* seconds (the device stays awake), then reverted back to the plain
* photo before proceeding to the normal sleep-interval logic. Its
* battery indicator shows the server's last-known reading, not a fresh
* one -- see fetch_photo_info() in frame_client.c.
* request for that cycle carries &manage=1, and the server bakes its
* whole manage overlay (scan-to-manage QR, battery, location/date,
* share-QR, named face labels) directly into the image it returns --
* see server/app/manage_overlay.py; this device is otherwise unaware
* any of that exists, it just displays whatever comes back. Held for 30
* seconds (the device stays awake), then reverted back to a plain fetch
* before proceeding to the normal sleep-interval logic.
*
* Reads the battery (see battery_read_percent()) itself, once, after the
* photo is already on the panel, and reports it to the server on a
-356
View File
@@ -1,356 +0,0 @@
#include <stdlib.h>
#include <string.h>
#include "esp_check.h"
#include "epd7in3e.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
#include "manage_qr_overlay.h"
static const char *TAG = "manage_qr_overlay";
#define QR_MAX_VERSION 10
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
* corner popups, not a full-screen setup step. */
#define QR_MODULE_PX 4
#define PADDING 16
#define QR_TEXT_GAP 8
#define LINE_GAP 4
/* Distance from the panel's edges to each overlay box. Combined with
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
* guarantees x0 is always even -- required so a region's columns land on
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
#define PANEL_MARGIN 20
typedef enum {
CORNER_TOP_LEFT,
CORNER_TOP_RIGHT,
CORNER_BOTTOM_LEFT,
CORNER_BOTTOM_RIGHT,
} overlay_corner_t;
static void position_region(manage_overlay_region_t *region, overlay_corner_t corner)
{
switch (corner) {
case CORNER_TOP_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = PANEL_MARGIN;
break;
case CORNER_TOP_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = PANEL_MARGIN;
break;
case CORNER_BOTTOM_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
case CORNER_BOTTOM_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
}
}
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
int origin_y)
{
int size = qrcodegen_getSize(qrcode);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE;
for (int dy = 0; dy < QR_MODULE_PX; dy++) {
for (int dx = 0; dx < QR_MODULE_PX; dx++) {
epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx,
origin_y + y * QR_MODULE_PX + dy, color);
}
}
}
}
}
/* White-padded box with a QR code encoding payload, plus zero, one, or
* two centered caption lines beneath it (either may be NULL). Used for
* both the top-right "scan to manage" box (two lines) and the
* bottom-left share-link box (no lines). */
static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
uint8_t temp_buffer[QR_BUFFER_LEN];
uint8_t qrcode[QR_BUFFER_LEN];
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
* one line would be 448px wide, too wide for a compact corner box,
* so it's passed in pre-wrapped across two lines instead. */
int text_w = 0;
int text_h = 0;
if (line1 != NULL) {
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
text_w = w1 > w2 ? w1 : w2;
text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
}
int content_w = qr_px > text_w ? qr_px : text_w;
int content_h = qr_px + text_h;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2; /* keep byte-aligned (2px/byte) */
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
y += qr_px;
if (line1 != NULL) {
y += QR_TEXT_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
y += Font24.Height;
}
if (line2 != NULL) {
y += LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* White-padded box with one or two centered lines of text (line2 may be
* NULL) -- used for the top-left location (city + state/country, two
* lines rather than cramming both onto one to keep the box from
* threatening to overlap the top-right QR box) and the bottom-right
* date-taken label (one line). */
static esp_err_t render_text_region(const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
int text_w = w1 > w2 ? w1 : w2;
int text_h = Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
int w = text_w + PADDING * 2;
int h = text_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
if (line2 != NULL) {
y += Font24.Height + LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* Deliberately tighter than PADDING (used for the fixed QR/text corner
* boxes) -- these labels sit right next to a face rather than needing
* generous QR-scanning margin, and there can be several of them
* simultaneously (see MANAGE_FACE_LABELS_MAX's memory-budget note in
* the header). */
#define FACE_LABEL_PADDING 8
#define FACE_LABEL_GAP 4 /* distance from the face's anchor point to the label box */
/* White-padded single-line name label positioned near an arbitrary
* (anchor_x, anchor_y) face position, rather than a fixed corner --
* unlike the four corner regions (always in-bounds by construction),
* this needs real clamping since a face can be anywhere, including near
* an edge. Centered horizontally on the face, placed just below it by
* default, flipped above if there's no room below. */
static esp_err_t render_face_label_region(const char *name, int anchor_x, int anchor_y, manage_overlay_region_t *out)
{
int text_w = (int)strlen(name) * Font24.Width;
int w = text_w + FACE_LABEL_PADDING * 2;
int h = Font24.Height + FACE_LABEL_PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, name, w / 2, FACE_LABEL_PADDING);
int x0 = anchor_x - w / 2;
int y0 = anchor_y + FACE_LABEL_GAP;
if (y0 + h > EPD_HEIGHT) {
y0 = anchor_y - FACE_LABEL_GAP - h; /* no room below -- place above the face instead */
}
if (x0 < 0) {
x0 = 0;
} else if (x0 + w > EPD_WIDTH) {
x0 = EPD_WIDTH - w;
}
if (y0 < 0) {
y0 = 0;
} else if (y0 + h > EPD_HEIGHT) {
y0 = EPD_HEIGHT - h;
}
x0 -= x0 % 2; /* keep byte-aligned (2px/byte) */
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = x0;
out->y0 = y0;
return ESP_OK;
}
/* Battery glyph dimensions -- a static outline (body rectangle + small
* terminal nub on the right), deliberately NOT a fill-level graphic. */
#define BATTERY_ICON_W 44
#define BATTERY_ICON_H 24
#define BATTERY_ICON_STROKE 2
#define BATTERY_NUB_W 6
#define BATTERY_NUB_H 12
#define BATTERY_ICON_TEXT_GAP 8
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
{
for (int y = 0; y < BATTERY_ICON_H; y++) {
for (int x = 0; x < BATTERY_ICON_W; x++) {
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
if (edge) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
}
}
}
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
for (int y = 0; y < BATTERY_NUB_H; y++) {
for (int x = 0; x < BATTERY_NUB_W; x++) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
}
}
}
/* White-padded box with the battery glyph and "NN%" beside it, placed
* directly below an already-positioned anchor region (the top-right
* manage QR box), right-aligned to the anchor's right edge. */
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
manage_overlay_region_t *out)
{
char text[8];
snprintf(text, sizeof(text), "%d%%", percent);
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
int text_w = (int)strlen(text) * Font24.Width;
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
PADDING + (content_h - Font24.Height) / 2);
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = anchor->x0 + anchor->w - w;
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
return ESP_OK;
}
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
{
out->count = 0;
esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT,
&out->regions[out->count]);
if (err != ESP_OK) {
return err;
}
out->count++;
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
/* Anchored below the manage QR box just rendered (regions[0]). */
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
const char *line2 =
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
if (render_text_region(content->location_line1, line2, CORNER_TOP_LEFT, &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->taken_at != NULL && content->taken_at[0] != '\0') {
if (render_text_region(content->taken_at, NULL, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
if (content->share_url != NULL && content->share_url[0] != '\0') {
if (render_qr_region(content->share_url, "SCAN TO", "DOWNLOAD", CORNER_BOTTOM_LEFT,
&out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
int face_count = content->face_label_count;
if (face_count > MANAGE_FACE_LABELS_MAX) {
face_count = MANAGE_FACE_LABELS_MAX;
}
for (int i = 0; content->face_labels != NULL && i < face_count; i++) {
const manage_face_label_t *label = &content->face_labels[i];
if (label->name[0] == '\0') {
continue;
}
if (render_face_label_region(label->name, label->x, label->y, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
return ESP_OK;
}
void manage_overlay_free(manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
free(overlay->regions[i].buf);
overlay->regions[i].buf = NULL;
}
overlay->count = 0;
}
-61
View File
@@ -1,61 +0,0 @@
#pragma once
#include <stdint.h>
#include "esp_err.h"
/* 5 fixed regions (manage QR, battery indicator, location, date, share
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
* capped small deliberately, not arbitrarily -- each label is its own
* malloc'd buffer, and the fixed regions alone already use a meaningful
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
#define MANAGE_FACE_LABELS_MAX 4
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
typedef struct {
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
int w, h; /* pixel dimensions (w is always even) */
} manage_overlay_region_t;
typedef struct {
manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS];
int count;
} manage_overlay_set_t;
typedef struct {
char name[16];
int x, y; /* anchor point (bottom-center of the face), panel pixel coordinates */
} manage_face_label_t;
typedef struct {
const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */
const char *location_line1; /* top-left text, line 1 (city); NULL/empty skips this region */
const char *location_line2; /* top-left text, line 2 (state/country); NULL/empty is fine if line1 is set */
const char *taken_at; /* bottom-right text; NULL/empty skips this region */
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
} manage_overlay_content_t;
/**
* Renders the manage-button overlay: always a "scan to manage" QR in the
* top-right corner, plus whichever of location_line1/taken_at/share_url
* are non-NULL/non-empty in their own corners (top-left, bottom-right,
* bottom-left respectively), plus one region per entry in face_labels
* (positioned near that face rather than a fixed corner -- see
* render_face_label_region() in the .c file for the clamping logic).
* Each region is its own separately malloc'd small buffer (not a full
* EPD_FRAME_BYTES frame). A failure rendering the top-right region fails
* the whole call; a failure rendering any other region just skips that
* region and keeps going. Caller must call manage_overlay_free() on out
* regardless of the return value (out->count reflects however many
* regions were actually populated).
*/
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out);
/** Frees every populated region's buffer in overlay. */
void manage_overlay_free(manage_overlay_set_t *overlay);
+1 -1
View File
@@ -1 +1 @@
1.2.3
1.3.0
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
**/__pycache__/
.venv/
*.egg-info/
data/
render-service/node_modules/
.git/
+50 -6
View File
@@ -2,18 +2,62 @@ FROM python:3.12-slim
WORKDIR /app
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
# would have no named zones to resolve without this -- ZoneInfo() would
# raise for anything other than "UTC".
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
# tzdata/fonts in their own layer, kept separate from the much larger
# Node.js/npm layers below -- see those layers' own comments for why
# they're split up the way they are. tzdata: python:3.12-slim doesn't
# include it by default, so the zoneinfo database backing the web UI's
# "Timezone" setting (used by "Quiet hours") would have no named zones
# to resolve without this -- ZoneInfo() would raise for anything other
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's
# render-service/ (own README there) needs something to render
# whiteboard text with.
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Node.js: whiteboard frame mode's render-service/ runs as a second
# process in this same container rather than a separate compose service
# -- it's a lightweight, stateless, localhost-only sidecar with nothing
# worth independently scaling or restarting. NodeSource's setup script is
# used instead of Debian bookworm's own apt Node package, which is both
# older than jsdom's minimum (20.19+) and inconsistently available.
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged
# again in this same RUN (not a later one; Docker layers are immutable,
# so removing them in a *different* instruction wouldn't shrink this
# one's actual pushed size) so their bytes don't end up in the image at
# all, only nodejs's.
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get purge -y --auto-remove curl gnupg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# render-service/'s dependencies installed as several separate layers
# rather than one `npm install` covering all of them -- a from-scratch
# push of this image once hit Cloudflare's payload-size limit on a
# single blob/layer upload (the registry sits behind it), and splitting
# a big layer into several smaller ones is the direct fix for exactly
# that failure mode, independent of anything about the registry itself.
# --no-save: package.json already fully declares these (with the exact
# same version pins used here) as the single source of truth for what
# this service depends on -- these calls are just about *when* each one
# gets installed for layer-size reasons, not re-deciding what's needed.
COPY render-service/package.json ./render-service/package.json
WORKDIR /app/render-service
RUN npm install --omit=dev --no-save express@^5.2.1 && npm cache clean --force
RUN npm install --omit=dev --no-save jsdom@^29.1.1 && npm cache clean --force
RUN npm install --omit=dev --no-save @excalidraw/[email protected] && npm cache clean --force
RUN npm install --omit=dev --no-save @resvg/[email protected] && npm cache clean --force
WORKDIR /app
COPY render-service/server.js ./render-service/server.js
COPY app ./app
COPY start.sh .
RUN chmod +x start.sh
EXPOSE 8420
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
CMD ["./start.sh"]
+85 -9
View File
@@ -121,9 +121,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
this response may grow.
- `GET /frame/photo-info` -- location/date overlay text for the manage
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
share link and 302s to it; scoped to the photo currently showing or
queued on *this* frame only.
- `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
share link covering every photo widget's currently-showing photo on
*this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`.
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser.
@@ -200,12 +202,70 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album.
- Auth in one breath: browsers use sessions (+CSRF), devices use
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
credential for pre-multi-frame firmware. `/frame/share` stays scoped
to photos this frame is actually showing or has queued, not any
Immich asset ID someone might guess -- a second layer a leaked device
token alone wouldn't bypass.
per-frame tokens (`?id=` + `?token=`), the manage QR and the
scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` survives only as the migration credential for
pre-multi-frame firmware.
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
never vendored or modified, so this project's own code stays under its
own license; LGPL's copyleft terms apply to that library itself, not
to code that merely links against it dynamically.
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
subscription) wraps the `caldav` PyPI package. `caldav` itself is
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
copyleft in this project's dependency tree, and the one whose
network-use clause is written specifically for server applications
like this one (not just "don't vendor/modify it," which was enough
reasoning for the LGPL dependency above). Taking this on was an
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small
Node.js sidecar using Excalidraw's own real export code
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
sidecar runs as a **second process inside this same container**
(`Dockerfile` installs Node, `start.sh` launches it in the background
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
Python process -- not a second docker-compose service, since it's
lightweight, stateless, and has nothing worth independently scaling or
restarting. License check (after getting burned once already in this
same file, on the CalDAV dependency below, into checking transitive
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
every one of its own runtime dependencies, `@resvg/resvg-js`
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
a real `npm install`/`docker build`** -- this project's dev environment
has no Node.js/npm, only network access to the npm registry API (used
to verify the above and pick real, current dependency versions). See
`render-service/README.md` for exactly what is and isn't verified.
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
(used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
skipping the codepoint. `app/calendar_render.py` draws emoji runs with
a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
at `app/fonts/OFL.txt`), the one deliberate exception to this project's
usual "no new font/icon assets" default elsewhere in calendar_render.py
-- there's no way to hand-draw arbitrary emoji with primitives the way
the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
fine through the panel's own Floyd-Steinberg dithering; a deployment
whose Pillow/FreeType wasn't built with embedded color bitmap support
falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
instead of crashing or rendering nothing. Color glyphs are only stored
at one embedded bitmap size (109px), so they're rasterized once at
that size and scaled down to the target row height rather than drawn
directly like normal vector text.
- The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel).
@@ -245,3 +305,19 @@ CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --host 0.0.0.0 --po
`--host 0.0.0.0` matters here: without it, uvicorn defaults to
`127.0.0.1` (localhost-only), which the ESP32 can't reach over the LAN.
The Docker image already binds `0.0.0.0` by default.
## Running tests
```
pip install -r requirements-dev.txt
pytest
```
Runs against a fresh temp SQLite database (`tests/conftest.py` sets
`DATABASE_URL` before anything imports `app.db`), with every table wiped
and reseeded (frame #1 + server settings, same as a real fresh install)
between tests -- no Docker, Node, or a real Immich/CalDAV/WebDAV server
needed; a few tests spin up small local HTTP servers as fixtures to
stand in for those. Also runs as its own job in
`.gitea/workflows/server-docker-build.yml`, gating the image build/push
-- a failing test suite blocks the push, not just decorates it.
+225
View File
@@ -0,0 +1,225 @@
"""CalDAV account support: discovering which calendars an account exposes,
and fetching one calendar's events or tasks -- the second way (alongside
calendar_feed.py's single-file ICS subscription) a user can link a
calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale,
Baikal, ...). Task lists (VTODO collections) are CalDAV-only -- a plain
ICS subscription doesn't meaningfully have one -- see fetch_tasks.
Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON
LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0,
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
the strongest copyleft license in this project's dependency tree, and the
one whose network-use clause is specifically written for server
applications like this one. This was an explicit, informed call by the
project owner to accept that exposure rather than hand-roll a CalDAV
client -- see the server README's Notes section. Anyone redistributing
this project (as opposed to just self-hosting it) should reread that
tradeoff for their own situation.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py. Event parsing/expansion reuses
icalendar + recurring_ical_events directly (rather than trusting each
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
calendar and an ICS subscription behave identically once fetched.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime
import caldav
import icalendar
import recurring_ical_events
logger = logging.getLogger(__name__)
HTTP_TIMEOUT_S = 15
class CalDavError(Exception):
"""Discovery or fetch failed -- network, auth, or an unexpected
server response. Raised loudly; callers (Settings' discover
endpoint, calendar_feed.merge_events) decide what to do. Wraps
whatever the caldav package/its transport raised, since that
exception hierarchy isn't something call sites should need to know
about directly."""
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
every calendar in this account. base_url is the server's CalDAV
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
Nextcloud) -- the caller supplies it directly, same idiom as the
plain ICS subscription URL."""
try:
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendars = client.principal().calendars()
except Exception as e:
raise CalDavError(str(e)) from e
result = []
for cal in calendars:
try:
display_name = cal.get_display_name() or cal.name
except Exception:
display_name = None
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
return result
def fetch_calendar_events(calendar_url: str, username: str, password: str,
window_start: date, window_end: date) -> list[dict]:
"""One CalDAV calendar's events in [window_start, window_end] -- same
event dict shape as calendar_feed.fetch_source_events (no
"owner_display_name"; the caller adds that).
Deliberately does NOT use the calendar-query REPORT's server-side
time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
that corner case underspecified and real servers disagree on it
(the caldav package's own docs warn "servers often behave
differently when presented with a search request"; confirmed here
too, once against a real server, as a calendar whose events just
silently never came back despite discovery/auth both working
fine). Instead this fetches every event in the calendar unfiltered
(get_events() is a plain "list VEVENTs" REPORT with no time-range
element -- the much more universally-supported case) and does 100%
of the date-window filtering/recurrence-expansion client-side via
icalendar + recurring_ical_events, exactly like calendar_feed.py
already does for plain ICS feeds. Heavier per-fetch (the whole
calendar, not just the window) but far more reliable."""
try:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.get_events()
except Exception as e:
raise CalDavError(str(e)) from e
events: list[dict] = []
for obj in objects:
try:
ical = icalendar.Calendar.from_ical(obj.data)
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
except Exception as e: # one malformed resource shouldn't blank the whole calendar
logger.warning("Could not parse a CalDAV event from %s: %s", calendar_url, e)
continue
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime)
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events
def fetch_tasks(calendar_url: str, username: str, password: str,
completed_since: datetime | None = None) -> list[dict]:
"""Outstanding VTODOs from one CalDAV task list, plus -- when
completed_since is given -- ones completed at or after that cutoff
(see routers/common.py's get_or_refresh_tasks_for_widget, which
passes "now - 24h" when TaskWidgetConfig.show_completed is on;
None, the default, means completed tasks are dropped entirely, the
original behavior). {"summary", "due" (ISO date/datetime string or
None), "completed_at" (ISO datetime string, or None for an
outstanding task)}, ... . Outstanding tasks sort first (by due date,
no-due-date last), any included completed ones after (most recently
completed first).
Fetches every task including completed ones and filters/sorts
client-side rather than trusting get_todos()'s own
include_completed/sort_keys server-side filtering, same reasoning as
fetch_calendar_events not trusting the time-range REPORT filter --
a simpler filter than a time range, but not worth re-litigating
which server-side filters are reliable one at a time."""
try:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.get_todos(include_completed=True)
except Exception as e:
raise CalDavError(str(e)) from e
outstanding: list[dict] = []
completed: list[dict] = []
for obj in objects:
try:
ical = icalendar.Calendar.from_ical(obj.data)
except Exception as e: # one malformed resource shouldn't blank the whole list
logger.warning("Could not parse a CalDAV task from %s: %s", calendar_url, e)
continue
for component in ical.walk("VTODO"):
status = str(component.get("STATUS") or "NEEDS-ACTION").upper()
summary = str(component.get("SUMMARY") or "(untitled)")
if status == "COMPLETED":
completed_prop = component.get("COMPLETED")
completed_dt = completed_prop.dt if completed_prop is not None else None
if completed_since is None or completed_dt is None or completed_dt < completed_since:
continue
completed.append({"summary": summary, "due": None, "completed_at": completed_dt.isoformat()})
else:
due = component.get("DUE")
outstanding.append({
"summary": summary,
"due": due.dt.isoformat() if due is not None else None,
"completed_at": None,
})
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
completed.sort(key=lambda t: t["completed_at"], reverse=True)
return outstanding + completed
@dataclass
class TaskSource:
"""One task list to merge in -- CalDAV only, no ICS variant (a plain
ICS subscription has no VTODO collection to speak of).
owner_display_name tags every task pulled from this source so a
merged checklist can show whose task is whose; color_index (2-5,
into image_pipeline.DEFAULT_PALETTE_RGB) is this list's manually
pinned color, or None for calendar_render.py's auto-cycle-by-owner-
name fallback -- see models.FrameTaskList."""
owner_display_name: str
url: str
username: str
password: str
color_index: int | None = None
def merge_tasks(sources: list[TaskSource], completed_since: datetime | None = None) -> tuple[list[dict], str]:
"""Fetches each source independently -- one broken list never blanks
another's tasks. Returns (merged_tasks, fetch_summary); fetch_summary
is "" when every source succeeded, else "N of M task lists
unavailable" (same no-naming-names posture as calendar_feed.
merge_events). No cross-list duplicate collapsing (unlike
merge_events) -- a task synced to two lists at once is rare enough,
and lower-stakes than a duplicated calendar event, not to be worth
the same de-dup machinery."""
merged: list[dict] = []
failures = 0
for source in sources:
try:
tasks = fetch_tasks(source.url, source.username, source.password, completed_since=completed_since)
except CalDavError:
failures += 1
continue
for task in tasks:
merged.append({
**task,
"owner_display_name": source.owner_display_name,
"color_index": source.color_index,
})
outstanding = [t for t in merged if t["completed_at"] is None]
completed = [t for t in merged if t["completed_at"] is not None]
outstanding.sort(key=lambda t: (t["due"] is None, t["due"] or ""))
completed.sort(key=lambda t: t["completed_at"], reverse=True)
summary = f"{failures} of {len(sources)} task lists unavailable" if failures else ""
return outstanding + completed, summary
+155
View File
@@ -0,0 +1,155 @@
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
get_or_refresh_calendar_events) supply plain CalendarSource values, not
ORM objects, so this module stays testable against fixture .ics text with
no database or app involved.
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
to get right (see its own docs), not worth reinventing. It's LGPL-3.0 (an
ordinary runtime pip dependency, never vendored/modified -- see the
server README's Notes section for why that doesn't put this project's own
code under LGPL terms).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
import httpx
import icalendar
import recurring_ical_events
from . import caldav_client
HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
CHECK_INTERVAL_S = 20 * 60 # don't refetch/reparse any feed more often than this
# How far back/forward each merge-fetch expands recurring events. Households
# look back far less than they plan ahead, hence the asymmetry. Browsing
# outside this window (calendar_browse_offset) just yields an empty view,
# not an error -- self-heals on the next normal wake regardless.
EXPAND_WINDOW_PAST_DAYS = 30
EXPAND_WINDOW_FUTURE_DAYS = 200
class CalendarFetchError(Exception):
"""One feed was unreachable, not valid ICS, or too large. Raised by
fetch_source_events(); merge_events() is what catches this per-source
so one broken feed can't blank out another's events."""
def fetch_source_events(url: str, window_start: date, window_end: date) -> list[dict]:
"""One feed: download, parse, expand recurrences within
[window_start, window_end]. Raises CalendarFetchError on any problem
-- network, malformed ICS, or an oversized response."""
try:
with httpx.stream("GET", url, timeout=HTTP_TIMEOUT_S, follow_redirects=True) as resp:
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > FETCH_MAX_BYTES:
raise CalendarFetchError(f"Feed exceeds {FETCH_MAX_BYTES} bytes")
chunks.append(chunk)
body = b"".join(chunks)
except httpx.HTTPError as e:
raise CalendarFetchError(str(e)) from e
try:
cal = icalendar.Calendar.from_ical(body)
occurrences = recurring_ical_events.of(cal).between(window_start, window_end)
except Exception as e: # icalendar/recurring_ical_events raise a mix of ValueError-family exceptions
raise CalendarFetchError(f"Could not parse ICS feed: {e}") from e
events = []
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime) # date, not datetime -- VALUE=DATE
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events
@dataclass(frozen=True)
class CalendarSource:
"""One calendar to merge in: either a plain ICS subscription (kind
"ics", url is the feed itself) or one CalDAV collection (kind
"caldav", url is the calendar's own URL, username/password its
account credentials) -- see caldav_client.py. owner_display_name
tags every event pulled from this source so a merged agenda can show
whose event is whose. color_index (2-5, into
image_pipeline.DEFAULT_PALETTE_RGB) is this calendar's manually
pinned color, or None to fall back on calendar_render.py's old
auto-cycle-by-owner-name behavior -- see models.FrameCalendar."""
owner_display_name: str
kind: str
url: str
username: str = ""
password: str = ""
color_index: int | None = None
def merge_events(
sources: list[CalendarSource], window_start: date, window_end: date
) -> tuple[list[dict], str]:
"""Fetches each source independently -- one broken feed never blanks
another's events. Returns (merged_time_sorted_events, fetch_summary);
fetch_summary is "" when every source succeeded, else "N of M
calendars unavailable" (never *which* source -- naming whose feed is
down to everyone who looks at a shared household display is a bigger
overshare than the outage itself).
Events sharing the exact same (summary, start, end, all_day) across
different calendars -- e.g. a shared family event synced onto more
than one person's calendar -- collapse into one entry rather than
showing as duplicate rows. Every merged event carries a "sources"
list ([{"owner_display_name", "color_index"}, ...], length 1 for an
ordinary non-duplicated event) that calendar_render.py draws a
color indicator per entry of, so a collapsed event still visibly
shows every calendar it came from."""
merged: list[dict] = []
by_key: dict[tuple, dict] = {}
failures = 0
for source in sources:
try:
if source.kind == "caldav":
events = caldav_client.fetch_calendar_events(
source.url, source.username, source.password, window_start, window_end
)
else:
events = fetch_source_events(source.url, window_start, window_end)
except (CalendarFetchError, caldav_client.CalDavError):
failures += 1
continue
for event in events:
source_entry = {"owner_display_name": source.owner_display_name, "color_index": source.color_index}
key = (event["summary"], event["start"], event["end"], event["all_day"])
existing = by_key.get(key)
if existing is None:
event["sources"] = [source_entry]
by_key[key] = event
merged.append(event)
else:
existing["sources"].append(source_entry)
merged.sort(key=lambda e: e["start"])
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
return merged, summary
+938
View File
@@ -0,0 +1,938 @@
"""Renders calendar frame mode's three views (agenda/week/month), and the
separate standalone tasks widget (see models.TaskWidgetConfig -- a task
list used to be a calendar-widget-only week-view slot, split out into
its own widget type so it isn't tied to a calendar's view/footprint),
into the panel's packed format, following image_pipeline.
render_placeholder's own precedent: build an RGB canvas with
ImageDraw/ImageFont, then the same _quantize/_transpose_and_pack every
other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time)
events into one, see _event_colors/_draw_color_bar below.
"""
from __future__ import annotations
import calendar as calendar_module
import io
import re
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
_apply_manage_overlay,
_quantize,
_transpose_and_pack,
draw_text,
logical_render_size,
)
from .weather import weather_category
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
"week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
# Was a light gray, but that dithers away to near-invisible once quantized
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
RULE = (0, 0, 0)
# Fallback for any event whose calendar has no manually pinned color
# (event["color_index"] is None): cycled per distinct owner_display_name
# so a merged multi-person calendar can still visually tell whose event
# is whose -- the panel's own non-black/white ink colors, skipping
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
# the page's text/background.
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
"""One color per contributing calendar (event["sources"] -- see
calendar_feed.merge_events, which collapses events sharing the exact
same title/time across different calendars into one entry with
several sources, e.g. a shared family event synced onto more than
one person's calendar). Usually just one color; more than one is
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
_draw_color_bar. Each source's own manually pinned color
(FrameCalendar.color_index -- see routers/api_widgets.py's
api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
blue; a source with no color pinned falls back to the old
auto-cycle-by-owner-name behavior. owners_seen is shared across every
event/source in a render so that cycle stays consistent view-wide."""
sources = event.get("sources") or [
{"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")}
]
colors = []
for source in sources:
color_index = source.get("color_index")
if color_index is not None:
palette = palette_rgb or DEFAULT_PALETTE_RGB
colors.append(tuple(palette[color_index]))
continue
owner_display_name = source.get("owner_display_name")
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)])
return colors
def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
colors: list[tuple[int, int, int]], radius: int) -> None:
"""One rounded bar for a single-source event, or that same overall
footprint split into equal-width side-by-side segments -- one per
contributing calendar -- for a deduplicated shared event (see
_event_colors/calendar_feed.merge_events). Splitting rather than
e.g. concentric rings keeps every color equally "thick and bold" at
a glance, the same design goal a single pinned color already has."""
if len(colors) == 1:
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
return
seg_w = (x1 - x0) / len(colors)
for i, color in enumerate(colors):
seg_x0 = round(x0 + i * seg_w)
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried
(often UTC), but display/bucketing needs to happen in the frame's own
timezone."""
dt = datetime.fromisoformat(event["start"])
if event["all_day"]:
return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
return dt.astimezone(tz)
def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
on_day = [e for e in events if _local_date(e, tz) == day]
on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
return on_day
def _local_date(event: dict, tz: ZoneInfo) -> date:
start = _event_start(event, tz)
return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()
def _add_months(d: date, months: int) -> date:
total = d.month - 1 + months
year = d.year + total // 12
month = total % 12 + 1
day = min(d.day, calendar_module.monthrange(year, month)[1])
return date(year, month, day)
def _fmt_time(dt: datetime) -> str:
text = dt.strftime("%I:%M %p").lstrip("0")
return text if text else "12:00 AM"
def _fmt_task_due(due: str | None) -> str:
""""2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks
only need a compact reminder of when they're due, not the precision
an event's own start/end time gets."""
if not due:
return ""
try:
dt = datetime.fromisoformat(due)
except ValueError:
return ""
d = dt.date() if isinstance(dt, datetime) else dt
return d.strftime("%b %-d")
# ImageFont.load_default() (used for everything else in this module --
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
# not supported". So event titles get drawn with two fonts: the normal
# text font for everything else, and one of these for actual emoji runs
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
#
# Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
# glyphs, which the panel's own Floyd-Steinberg dithering turns into a
# recognizable (if slightly speckled) color rendering rather than a flat
# monochrome shape -- confirmed by actually rendering a test agenda row
# through the real quantizer, not just theorizing about it. Its one
# real quirk: CBDT stores glyphs at a single embedded bitmap size
# (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that
# size and scaled down to the target row height, unlike normal vector
# text which draws directly at whatever size is asked for.
#
# NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment
# whose Pillow/FreeType wasn't built with embedded color bitmap support
# -- confirmed working locally, but that's a build-time detail this
# project doesn't control everywhere it might run, so a rendering
# failure falls back instead of showing nothing/crashing.
_COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf"
_MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
_COLOR_EMOJI_NATIVE_SIZE = 109
@lru_cache(maxsize=1)
def _color_emoji_font() -> ImageFont.FreeTypeFont | None:
try:
return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE)
except Exception:
return None
@lru_cache(maxsize=None)
def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size)
@lru_cache(maxsize=512)
def _emoji_glyph(run_text: str, target_h: int) -> Image.Image:
"""One emoji run (consecutive emoji collapse into a single run, see
_split_emoji_runs) as an RGBA image target_h tall, ready to
alpha-composite onto the canvas. Tries color first, falls back to
monochrome (rendered directly at target_h, since that font is
vector) if the color font failed to load or this Pillow/FreeType
build can't decode its embedded bitmaps. Cached -- the same emoji
recurs across a household's events, and rasterizing+scaling isn't
free."""
color_font = _color_emoji_font()
if color_font is not None:
try:
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
raw_w = max(1, round(probe.textlength(run_text, font=color_font)))
tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0))
ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True)
scale = target_h / _COLOR_EMOJI_NATIVE_SIZE
return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS)
except Exception:
pass # this deployment's Pillow can't render embedded color bitmaps -- fall back
mono_font = _mono_emoji_font(target_h)
bbox = mono_font.getbbox(run_text)
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font)
glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0))
glyph.paste((0, 0, 0, 255), (0, 0), mask)
return glyph
# Matches runs of actual emoji base characters (the standard Unicode
# emoji blocks -- stable ranges even as new individual emoji get added
# within them, so this doesn't need updating as emoji sets grow).
_EMOJI_PATTERN = re.compile(
"["
"\U0001F1E6-\U0001F1FF" # regional indicator symbols (flag emoji)
"\U0001F300-\U0001F5FF" # misc symbols & pictographs
"\U0001F600-\U0001F64F" # emoticons
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
"\U00002600-\U000026FF" # misc symbols (☀☂☕ etc.)
"\U00002700-\U000027BF" # dingbats (✂✈✉ etc.)
"]+"
)
_EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})")
# Codepoints with no meaningful standalone glyph once color/ligature
# context is dropped: skin-tone modifiers (this is monochrome -- no
# color to modify), the variation selector that just requests emoji
# presentation, and the zero-width joiner used to fuse multiple emoji
# into one combined glyph. That fusion (e.g. the "family" emoji from
# four base emoji + 3 ZWJs) needs OpenType ligature substitution
# (raqm/harfbuzz), which Pillow only does with a specific, non-default
# build -- not something to depend on. Stripping the ZWJ instead means a
# ZWJ sequence just draws as its individual base glyphs side by side
# (four separate people instead of one family glyph) -- a real fallback,
# not a crash or tofu.
_EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]")
def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
"""text -> [(run, is_emoji), ...], modifier/joiner codepoints
dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
plain-text characters into the other."""
cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text)
parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p]
return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts]
def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
text_font: ImageFont.ImageFont, max_width: int, fill: tuple[int, int, int] = FG) -> None:
"""Draws `text` left-to-right, switching between text_font (normal
characters) and an emoji glyph image (actual emoji runs, per
_split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
tofu box. Truncates with "..." once max_width is exceeded -- unlike
_truncate_to_width this can't binary-search a single font's metrics
across mixed fonts/images, so it works run-by-run instead (and can't
partially truncate an emoji run the way it can a text run -- one
that doesn't fit just isn't drawn). Fine for the short single-line
strings this draws (event/task titles), not meant as a general
rich-text layout engine. `fill` only affects text runs -- emoji
glyphs are already their own color."""
x, y = xy
cursor = x
# A little taller than text_font's own size so glyphs don't look
# cramped next to it; the -2 paste offset below roughly centers that
# against the surrounding text's row -- tuned by eye against a real
# rendered agenda row, not derived from font metrics.
emoji_h = text_font.size + 6
for run_text, is_emoji in _split_emoji_runs(text):
remaining = max_width - (cursor - x)
if remaining <= 0:
break
if is_emoji:
glyph = _emoji_glyph(run_text, emoji_h)
if glyph.width <= remaining:
img.paste(glyph, (round(cursor), y - 2), glyph)
cursor += glyph.width
else:
break
else:
run_w = draw.textlength(run_text, font=text_font)
if run_w <= remaining:
draw_text(img, (round(cursor), y), run_text, text_font, fill)
cursor += run_w
else:
draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining),
text_font, fill)
break
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
"""Pixel-width-aware truncation (unlike device.py's char-count
_truncate, tuned for a fixed firmware font at a fixed size) -- this
module draws at several different sizes, so truncation has to
measure the actual font/size in play. Still uses `draw.textlength`
for measurement (identical metrics to draw_text's own bbox), just
doesn't paint anything."""
if draw.textlength(text, font=font) <= max_width:
return text
ellipsis = "..."
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
lo = mid
else:
hi = mid - 1
return text[:lo] + ellipsis if lo else ellipsis
# --- Size tiers ---------------------------------------------------------
#
# A calendar widget can now be placed at any grid footprint (see
# app/grid.py), not just the full panel -- these three discrete tiers
# (chosen by nearest-fit against the target box's pixel area) drive font
# sizes/margins instead of continuously scaling a layout that was tuned
# by eye for the full ~800x480 panel, which would risk ugly proportions
# at odd in-between sizes. Area-based (not width/height-based) so the
# same footprint tiers the same regardless of landscape/portrait target
# box shape.
_TIER_LARGE_AREA = 280_000 # near/at a full 800x480 panel (384,000px^2)
_TIER_MEDIUM_AREA = 120_000 # roughly a half-panel split
def _size_tier(target_w: int, target_h: int) -> str:
area = target_w * target_h
if area >= _TIER_LARGE_AREA:
return "large"
if area >= _TIER_MEDIUM_AREA:
return "medium"
return "small"
def _month_view_fits(target_w: int, target_h: int) -> bool:
"""Month view needs real width to keep 7 columns' day numbers and
density dots legible -- below the "small" size tier that stops being
true, so _build falls back to agenda view instead of drawing an
unreadable grid."""
return _size_tier(target_w, target_h) != "small"
# --- Weather strip, agenda/today & tomorrow/week views only (never
# month -- see _BUILDERS/_build) --------------------------------------
def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]:
"""[{"label", "code", "high", "low", "category"}, ...] for every
configured city that has a cached forecast for this specific date --
weather_cities is routers/common.py's get_or_refresh_weather() cache
shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}]."""
if not weather_cities:
return []
key = day.isoformat()
entries = []
for city in weather_cities:
d = (city.get("days") or {}).get(key)
if d is None:
continue
# Just the city name on-panel ("Portland", not the full
# disambiguated "Portland, Oregon, United States") -- that fuller
# form matters for telling apart geocoder candidates when adding
# a city (see weather.geocode_city), not for a compact display row.
entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"],
"category": weather_category(d["code"])})
return entries
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one black pass slightly larger than
the shapes, then the same shapes again in white on top. Overlapping
ellipses each drawn with their own `outline=` would leave visible
seams where they cross; this double-draw trick sidesteps that
entirely regardless of how the lobes overlap."""
stroke = 2
lobes = [
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=BG)
draw.rectangle(base, fill=BG)
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
"""A small hand-drawn glyph for one weather category -- no custom
font/icon asset, same hand-primitives-only approach the rest of this
module uses (colored rectangles for owner indicators, density dots
for month view)."""
if category == "clear":
# Kept within a ~1.1r visual radius overall (rays included) to
# match _draw_cloud's own footprint -- _draw_weather_row lays
# icons out assuming each one stays roughly within icon_r of its
# center, and the first entry in a row sits flush against the
# region's own left margin, so any icon that draws wider than
# that pokes out past it with nothing to visually connect to.
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
fill=FG, width=3)
return
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
if category == "partly_cloudy":
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
_draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.5 + i * (r * 0.45)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.6, 0, 0.6):
x = cx + dx * r
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
elif category == "snow":
for dx in (-0.6, 0, 0.6):
x, y = cx + dx * r, cloud_cy + r * 0.9
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
elif category == "thunderstorm":
x, y = cx, cloud_cy + r * 0.5
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
fill=FG, width=2)
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True) -> int:
"""Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities --
same graceful-degradation approach month view takes with density
dots). Returns the row height consumed (0 if there was nothing to
draw, so callers can skip reserving space entirely)."""
if not entries:
return 0
unit_suffix = "F" if units == "fahrenheit" else "C"
row_h = icon_r * 2 + 8
x = x0
drew_any = False
for entry in entries:
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
label = f"{entry['label']} {temps}" if show_labels else temps
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
if drew_any and x + entry_w > x0 + max_w:
break
cx, cy = x + icon_r, y0 + icon_r
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w
drew_any = True
return row_h + 6
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
weather_units: str = "fahrenheit") -> None:
"""Draws one day's header + weather strip (if any) + event rows
within `region` (x0, y0, w, h) -- factored out of _build_agenda so
the today-and-tomorrow view (_build_today_tomorrow) can stack two of
these vertically without duplicating the row-layout/truncation
logic. Weather is drawn above the event list -- eating into the same
row budget the event count is truncated against, exactly like the
header/rule above it already does."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - MARGIN * 2
header = day.strftime("%A, %B ") + str(day.day)
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
break
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
prefix = f"{time_str} "
draw_text(img, (text_x0 + 18, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font)
_draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"],
body_font, text_w - 18 - prefix_w)
y += row_h
def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
palette_rgb: list | None = None, title: str = "Tasks") -> None:
"""A simple checklist filling `region` (x0, y0, w, h) -- a header
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
default; the only widget type with its own on-panel title, since
it's the only one where "which list is this" isn't obvious from its
content the way a calendar/photo/whiteboard's is), then a color bar
(reusing _event_colors/_draw_color_bar as-is: a task dict's
top-level owner_display_name/color_index is exactly _event_colors'
single-source fallback shape, since caldav_client.merge_tasks
doesn't cross-list-dedup tasks into a "sources" list the way
merge_events dedups events) + checkbox glyph + due date (if any) +
summary per task, same header/rule/row-cap/truncation shape as
_draw_agenda_day's event list so the standalone tasks widget (see
_build_tasks) reads as the same consistent design as everything
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
so a task summary with emoji in it renders the same way an event
title's does.
Outstanding tasks get an empty checkbox; completed ones (only ever
present when TaskWidgetConfig.show_completed is on -- see
caldav_client.fetch_tasks' completed_since) get a filled one and
muted text, no due-date prefix (irrelevant once done)."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - MARGIN * 2
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
return
owners_seen: list[str] = []
for i, task in enumerate(tasks):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
break
done = task.get("completed_at") is not None
colors = _event_colors(task, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
box = body_font.size - 6
box_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5
if done:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
else:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2)
text_x = box_x + box + 10
due_str = None if done else _fmt_task_due(task.get("due"))
prefix = f"{due_str} " if due_str else ""
if prefix:
draw_text(img, (text_x, y), prefix, body_font, MUTED)
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG)
y += row_h
# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
# full size doesn't fit a narrow column, and a narrower box is exactly
# when a smaller font (rather than truncating to "Wednesday...") keeps
# the header actually informative.
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = []
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically (below each other
rather than side-by-side -- narrower than tall doesn't leave enough
width per day for the event-row text at smaller sizes). browse_offset
shifts the whole two-day window together, same "days" unit
_build_agenda already uses, so NEXT/BACK behaves identically across
both views."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
owners_seen: list[str] = []
for i in range(2):
section_y0 = i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
# Vertical layout's base (title, body, weather) sizes, before the
# per-day-count reduction below -- same three tiers as every other view.
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
# Horizontal layout's (header, chip, weather) sizes.
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}
def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
_draw_agenda_day the same way _build_today_tomorrow does, just for
an arbitrary day count instead of a hardcoded 2).
At the default 7 days, the view anchors to week_start (a fixed
weekday, "start on the most recent Monday") exactly like before --
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_widgets.py's api_widget_config_save)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date()
if days == 7:
days_since_start = (today.weekday() - week_start) % 7
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
owners_seen: list[str] = []
if layout == "vertical":
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = ImageFont.load_default(size=max(14, title_base - days))
body_font = ImageFont.load_default(size=max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // days
for i in range(days):
section_y0 = i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
day = week_first_day + timedelta(days=i)
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units)
return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_font = ImageFont.load_default(size=header_size)
chip_font = ImageFont.load_default(size=chip_size)
weather_font = ImageFont.load_default(size=weather_size)
col_w = (target_w - MARGIN * 2) // days
header_h = 44
for col in range(days):
day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# _draw_weather_row) -- typically one city, no label (the column
# itself makes which day it's for obvious; a city name wouldn't fit
# anyway). Never more than that -- this is already the tight view.
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
break
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
if event["all_day"]:
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
else:
prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
draw_text(img, (x0 + 16, y), prefix, chip_font)
prefix_w = draw.textlength(prefix, font=chip_font)
_draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"],
chip_font, col_w - 20 - prefix_w)
y += row_h
return img
# Only "large"/"medium" in practice -- _build falls back to agenda view
# below the "small" tier (see _month_view_fits) -- but keyed defensively
# by tier rather than a bare bool so a future tier addition can't
# silently fall through to a KeyError here.
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
header_font = ImageFont.load_default(size=header_size)
day_font = ImageFont.load_default(size=day_size)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
col_w = (target_w - MARGIN * 2) // 7
header_h = 28
grid_top = MARGIN + header_h
row_h = (target_h - MARGIN - grid_top) // len(weeks)
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names):
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
owners_seen: list[str] = []
dot_r = 6
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
text_color = FG if in_month else MUTED
if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
dot_y = y0 + row_h - dot_r * 2 - 6
for i, event in enumerate(day_events[:4]):
# First contributing calendar's color only, even for a
# deduplicated shared event -- month view is density
# dots, not a place to also show which calendars a
# shared event came from (see _event_colors).
event_color = _event_colors(event, owners_seen, palette_rgb)[0]
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
dot_x += dot_r * 2 + 5
if len(day_events) > 4:
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
return img
_BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week,
"month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
effective_view = "agenda"
if effective_view == "agenda":
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, week_start_offset)
elif effective_view == "month":
# Never given weather -- no room for it at typical month-cell
# size, same reasoning that already keeps this view to density
# dots instead of literal event text (see _build_month's own
# docstring). Colors are still passed through, though -- that's
# a different concern (legibility of individual events) than
# needing a whole extra strip of content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
if fetch_summary:
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str,
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> bytes:
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
invariant every other renderer honors. weather_cities is
routers/common.py's get_or_refresh_weather() cache, or None/[] to
omit the weather strip entirely (also always omitted for view ==
"month")."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str,
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
# --- Standalone tasks widget (split out of the old calendar-widget-only
# week-view task list -- see models.TaskWidgetConfig) -----------------
_TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks") -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title)
return img
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks") -> bytes:
"""Renders the tasks widget full-panel to the panel's packed format.
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
every other renderer honors."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks") -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+31 -1
View File
@@ -16,7 +16,7 @@ from typing import Iterator
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from .models import Frame
from .models import WIDGET_CONFIG_MODELS, Frame, Widget
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
@@ -86,3 +86,33 @@ def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
db.refresh(frame)
yield frame
db.commit()
@contextmanager
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
"""Same lock/refresh/commit dance as frame_locked, additionally
resolving and refreshing the widget's own per-type config row
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig/
TaskWidgetConfig, see models.WIDGET_CONFIG_MODELS). Deliberately
still locks at *frame* granularity -- the exact same per-frame
threading.Lock frame_locked
uses, not a separate per-widget lock -- simplest, avoids a new class
of multi-lock deadlock bugs, and this project's actual concurrency
needs are tiny (a handful of users per household frame).
threading.Lock is not reentrant: a caller executing several widget
actions in one pass (e.g. a button press assigned multiple
(widget, action) pairs, see routers/device.py) MUST call this once
per action, sequentially, never nested inside an outer
frame_locked/widget_locked span for the same frame -- nesting would
deadlock instantly, not just misbehave."""
with frame_locked(db, frame_id) as frame:
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame_id:
raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
config = db.get(config_model, widget_id)
if config is None:
raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
db.refresh(config)
yield frame, widget, config
+44 -31
View File
@@ -1,6 +1,6 @@
"""Maps named faces (from Immich's own face recognition/People feature)
onto their position in the final rendered 800x480 frame, for the
manage-button overlay's escalated "who's in this photo" menu level.
onto their position in the final rendered frame, for the manage-button
overlay's named-face labels (see manage_overlay.py, which draws them).
No face detection or recognition happens here or anywhere else in this
project -- Immich's GET /api/faces?id={assetId} already returns each
@@ -15,42 +15,56 @@ import io
from PIL import Image, ImageOps
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size, logical_to_native
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size
# Small caps, not arbitrary: each label is its own malloc'd overlay
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
# four existing fixed corner regions already use a meaningful chunk of
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
# Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A
# photo with a dozen named people would just be visual clutter regardless
# of what's rendering it.
MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape") -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
800x480 panel pixel space at each named face's bottom-center point.
orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the
logical-space image before it's rotated into native panel space, so
no rotation happens here (contrast with the old firmware-side
version, which drew post-rotation and needed logical_to_native).
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and display_mode/orientation must
match the settings that were active then -- otherwise the placement
and rotation computed here won't match what's actually on screen.
computed here won't match what's actually on screen.
The placement math runs in logical (pre-rotation) space, matching
render_frame()'s composition step (see image_pipeline._placement_transform,
shared so the two can't drift apart); each anchor is then rotated
into native panel coordinates via logical_to_native(), since the
firmware draws labels in native space.
`region` is (x0, y0, w, h): where in the logical canvas the photo
actually landed, if not the whole thing -- e.g. a photo widget placed
in one corner of the panel rather than full-screen (see
routers/common.py's build_manage_content, which passes each photo
widget's own placement rect) -- without this a label would be placed
as if the photo filled the entire canvas, landing well off where the
widget actually is. None (the default) means the photo fills the
whole logical canvas.
The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two
can't drift apart).
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
return []
logical_w, logical_h = logical_render_size(orientation)
if region is None:
logical_w, logical_h = logical_render_size(orientation)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else:
region_x0, region_y0, target_w, target_h = region
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
scale_x, scale_y, offset_x, offset_y = _placement_transform(
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
fitted.width, fitted.height, target_w, target_h, display_mode, faces
)
labels = []
@@ -65,17 +79,16 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
bottom_y = face["boundingBoxY2"] * img_scale_y
frame_x = center_x * scale_x + offset_x
frame_y = bottom_y * scale_y + offset_y
# Relative to the region's own origin first (matches
# _placement_transform's target_w/target_h space), then shifted
# into full-canvas coordinates.
region_x = center_x * scale_x + offset_x
region_y = bottom_y * scale_y + offset_y
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
if not (0 <= region_x <= target_w and 0 <= region_y <= target_h):
continue # this face got cropped out of the region entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
labels.append({"name": name, "x": native_x, "y": native_y})
labels.append({"name": face["person"]["name"],
"x": int(region_x + region_x0), "y": int(region_y + region_y0)})
return labels
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010-2013, Anton Koovit ([email protected]), with Reserved Font Name 'Arvo'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010 The Crimson Text Project Authors (https://github.com/googlefonts/Crimson)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2013 Google LLC
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
"""Snap-to-grid placement math for widgets (see models.Widget) -- pure,
no I/O, no ORM.
The grid is defined relative to the panel's long/short axis, not
landscape/portrait specifically, so it stays valid across
image_pipeline.logical_render_size(orientation)'s genuine width/height
swap for portrait (not just a rotation applied at the very end) --
landscape orientations are GRID_LONG columns x GRID_SHORT rows, portrait
orientations are GRID_SHORT columns x GRID_LONG rows, same cell size
either way. Changing a frame's orientation therefore invalidates any
existing widget layout (an 8x5 arrangement isn't valid on a 5x8 grid) --
callers are expected to reset to one full-panel widget on an orientation
change, not try to remap coordinates.
"""
from __future__ import annotations
GRID_LONG = 8
GRID_SHORT = 5
# Per-widget-type minimum grid footprint (cols, rows) -- enforced both in
# the placement UI and server-side (routers/api_widgets.py). A calendar
# widget crammed into 1x1 would be illegible regardless of size-tier
# scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary
# without truncating on every row.
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1),
"calendar": (3, 2),
"whiteboard": (2, 2),
"tasks": (2, 2),
"static": (1, 1),
"text": (2, 1),
}
Rect = tuple[int, int, int, int] # (x, y, w, h)
def grid_dims(orientation: str) -> tuple[int, int]:
"""(cols, rows) for this orientation."""
if orientation in ("portrait", "portrait_flipped"):
return GRID_SHORT, GRID_LONG
return GRID_LONG, GRID_SHORT
def full_panel_rect(orientation: str) -> Rect:
"""The single full-panel widget rect for this orientation -- what a
frame gets reset to whenever its layout can't carry over (initial
migration backfill, an orientation change)."""
cols, rows = grid_dims(orientation)
return (0, 0, cols, rows)
def in_bounds(orientation: str, rect: Rect) -> bool:
cols, rows = grid_dims(orientation)
x, y, w, h = rect
return x >= 0 and y >= 0 and w > 0 and h > 0 and x + w <= cols and y + h <= rows
def meets_minimum(widget_type: str, rect: Rect) -> bool:
min_w, min_h = MIN_FOOTPRINT.get(widget_type, (1, 1))
_, _, w, h = rect
return w >= min_w and h >= min_h
def overlaps(a: Rect, b: Rect) -> bool:
ax, ay, aw, ah = a
bx, by, bw, bh = b
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
def find_open_rect(orientation: str, existing: list[Rect], w: int, h: int) -> Rect | None:
"""First w x h rect that's in-bounds and doesn't overlap any of
`existing`, scanning row-major (top-left first) -- used when creating
a widget without an explicit placement (see routers/api_widgets.py),
so adding one from a type picker doesn't require the caller to find
empty space itself first. None if no such rect fits anywhere."""
cols, rows = grid_dims(orientation)
for y in range(rows - h + 1):
for x in range(cols - w + 1):
candidate = (x, y, w, h)
if not any(overlaps(candidate, other) for other in existing):
return candidate
return None
def cell_to_pixels(orientation: str, panel_w: int, panel_h: int, rect: Rect) -> tuple[int, int, int, int]:
"""Grid rect -> pixel rect in logical (pre-rotation) canvas space --
against image_pipeline.logical_render_size(orientation)'s own
(panel_w, panel_h), the same space every renderer already composes
in before the final orientation transpose."""
cols, rows = grid_dims(orientation)
cell_w = panel_w / cols
cell_h = panel_h / rows
x, y, w, h = rect
px, py = round(x * cell_w), round(y * cell_h)
# Snap the far edge to the next cell boundary rather than compounding
# per-cell rounding error across w/h -- keeps adjacent widgets'
# shared edge pixel-exact instead of leaving a stray gap/overlap.
px2, py2 = round((x + w) * cell_w), round((y + h) * cell_h)
return (px, py, px2 - px, py2 - py)
+172 -26
View File
@@ -4,11 +4,34 @@ from __future__ import annotations
import io
from PIL import Image, ImageEnhance, ImageOps
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
# PIL's TrueType rendering antialiases by default (graduated gray edge
# pixels). Those survive straight into _quantize's Floyd-Steinberg
# dithering, which -- confirmed visually -- turns them into scattered
# colored speckles along every glyph edge once forced onto the panel's 6
# colors, since a mid-gray input has no close palette match and the
# diffused error bounces between whichever colors are nearest. Drawing
# through a thresholded bilevel mask instead keeps every edge pure
# black/white, which _quantize then reproduces exactly (both are already
# palette colors, nothing to dither). Shared by every module that draws
# text before quantization (this file's render_placeholder,
# calendar_render.py, manage_overlay.py).
_TEXT_MASK_THRESHOLD = 110
def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.ImageFont,
fill: tuple[int, int, int] = (0, 0, 0)) -> None:
bbox = font.getbbox(text)
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, fill=255, font=font)
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
# How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native
@@ -197,6 +220,13 @@ DISPLAY_MODE_LABELS = {
DEFAULT_DISPLAY_MODE = "crop_faces"
LETTERBOX_BG = (255, 255, 255)
# Static-image widget only offers a subset of DISPLAY_MODES -- no face
# detection for an uploaded image, so "crop_faces" (which silently falls
# back to crop_fill anyway, see compose_into) would just be a confusing
# duplicate entry in that dialog's dropdown.
STATIC_DISPLAY_MODES = ["crop_fill", "stretch_fill", "letterbox"]
DEFAULT_STATIC_DISPLAY_MODE = "crop_fill"
def _placement_transform(
img_width: int, img_height: int, target_w: int, target_h: int,
@@ -222,26 +252,37 @@ def _placement_transform(
return scale_x, scale_y, -left * scale_x, -top * scale_y
def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, target_h: int,
display_mode: str) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode into an arbitrary
target_w x target_h box -- returns an RGB image, before enhancement or
quantization. See render_frame for what each display_mode does.
_compose() is the common case of this (target = the full panel, at
logical_render_size(orientation)); this more general form also backs
calendar_render.py's agenda photo-inlay, which composes into just a
sub-region of the panel instead of the whole thing."""
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if display_mode == "stretch_fill":
return fitted.resize((target_w, target_h), Image.LANCZOS)
if display_mode == "letterbox":
scale = min(target_w / fitted.width, target_h / fitted.height)
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
canvas = Image.new("RGB", (target_w, target_h), LETTERBOX_BG)
canvas.paste(resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
return canvas
if display_mode == "crop_faces" and faces:
box = _face_aware_crop_box(fitted.width, fitted.height, target_w, target_h, faces)
return fitted.crop(box).resize((target_w, target_h), Image.LANCZOS)
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
image at logical_render_size(orientation), before enhancement or
quantization. See render_frame for what each display_mode does."""
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if display_mode == "stretch_fill":
return fitted.resize((logical_w, logical_h), Image.LANCZOS)
if display_mode == "letterbox":
scale = min(logical_w / fitted.width, logical_h / fitted.height)
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
canvas.paste(resized, ((logical_w - new_w) // 2, (logical_h - new_h) // 2))
return canvas
if display_mode == "crop_faces" and faces:
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
return compose_into(source, faces, *logical_render_size(orientation), display_mode)
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
@@ -292,10 +333,25 @@ def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
return bytes(out)
def _apply_manage_overlay(img: Image.Image, manage: dict | None) -> Image.Image:
"""Composites the manage-button overlay (scan-to-manage QR, battery,
location/date/share-QR, named face labels) onto an already-composed,
already-enhanced image, if requested -- see manage_overlay.compose().
Local import: manage_overlay is an optional, occasionally-used
concern (only /frame/*?manage=1 requests need it), same reasoning
render_placeholder already applies to its own `import qrcode`."""
if manage is None:
return img
from . import manage_overlay
return manage_overlay.compose(img, **manage)
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes:
"""Fits `source` to the panel's resolution, applies color/contrast
enhancement, quantizes it to the 6-color palette, and packs 2
pixels/byte the way epd7in3e.c expects. Always returns exactly
@@ -318,22 +374,82 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
see Frame.palette_rgb) -- None uses the default.
`manage` is a dict of manage_overlay.compose()'s kwargs (management_url,
battery_percent, location_lines, taken_at, share_url, face_labels), or
None to skip it -- see routers/device.py's build_manage_content(),
which callers pass this straight through from. Applied after
enhancement, before quantization, so the overlay's pure black/white
graphics aren't affected by color/contrast boost.
"""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes:
"""The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run
the same single shared pipeline over the result." Not a
restructuring: the calendar mode's old photo-inlay feature already
pasted a second, independently-composed image onto the canvas before
`_enhance`/`_quantize` ran exactly once over the whole thing -- this
just generalizes that from a fixed 1-2 region split to an arbitrary
list.
Each region is (rect, image): rect is (x, y, w, h) in *logical*
(pre-rotation) canvas space -- the same space logical_render_size(
orientation) describes, and what app/grid.py's cell_to_pixels()
produces -- and image is an already-composed RGB image exactly w x h
in size (e.g. from compose_into() for a photo/whiteboard widget, or
calendar_render's own builder for a calendar widget). Regions are
expected not to overlap (see models.Widget's docstring on why) --
this function doesn't enforce that itself, callers/the placement API
do, since by the time rendering happens it's too late to do anything
but paste in whatever order they're given (later entries would just
paint over earlier ones).
Quantizing/dithering the *whole* composited canvas once, rather than
each region separately before pasting, is what keeps a 6-color
e-ink panel's dithering pattern consistent across a widget boundary
instead of showing a visible seam where two independently-dithered
regions meet.
as_png=True returns a normal browser-viewable PNG in logical (upright)
orientation instead of packed native-panel bytes, same convention as
render_preview_png -- used for the web UI's live "how it's displaying"
thumbnail."""
logical_w, logical_h = logical_render_size(orientation)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions:
canvas.paste(region_img.convert("RGB"), (x, y))
fitted = _enhance(canvas, color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png:
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
return _transpose_and_pack(quantized, orientation)
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes:
"""Identical composition/enhancement/quantization pipeline as
render_frame, but returned as a normal browser-viewable PNG in
logical (upright, as-the-frame-actually-hangs) orientation rather
than packed native-panel bytes and rotation -- what the web UI's
"how it will look on the frame" preview shows."""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
@@ -341,19 +457,24 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
orientation: str = "landscape", palette_rgb: list | None = None,
manage: dict | None = None, as_png: bool = False) -> bytes:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
instructions instead of an error screen and never error-loops."""
from PIL import ImageDraw, ImageFont
instructions instead of an error screen and never error-loops.
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
yet."""
margin = 24
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img)
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
title_font = ImageFont.load_default(size=34)
body_font = ImageFont.load_default(size=24)
max_text_w = logical_w - margin * 2
qr_img = None
if qr_url:
@@ -368,23 +489,48 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
scale = max(1, target // raw.width)
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
# Word-wrap each input line to the panel's actual width (portrait is
# much narrower than landscape -- a line written assuming ~800px
# would otherwise run straight off the edge) before laying anything
# out, so wrapped sub-lines count toward the vertical centering below.
def wrap(text: str, font) -> list[str]:
words = text.split()
if not words:
return [text]
out, current = [], words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if draw.textlength(candidate, font=font) <= max_text_w:
current = candidate
else:
out.append(current)
current = word
out.append(current)
return out
# Vertical layout: text block, then QR under it, centered as a group.
line_heights = []
for i, line in enumerate(lines):
font = title_font if i == 0 else body_font
bbox = draw.textbbox((0, 0), line, font=font)
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
for sub_line in wrap(line, font):
bbox = draw.textbbox((0, 0), sub_line, font=font)
line_heights.append((sub_line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
gap = 14
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
y = max(20, (logical_h - total_h) // 2)
for line, font, w, h in line_heights:
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
draw_text(img, ((logical_w - w) // 2, y), line, font)
y += h + gap
if qr_img:
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png:
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
return _transpose_and_pack(quantized, orientation)
+54
View File
@@ -0,0 +1,54 @@
"""Decodes an arbitrary uploaded file (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF/...)
into a plain RGB PIL image, for the static-image widget (see routers/
api_widgets.py's api_widget_static_upload, app/widgets/static_image.py).
The result is stored (as PNG bytes) rather than the original upload, so
render() never needs to re-run PDF/GIF decoding on every panel refresh --
this module only runs once, at upload time.
PDF decoding uses pypdfium2 (Google's PDFium bindings -- BSD-3-Clause/
Apache-2.0, no copyleft exposure) rather than a GPL/AGPL alternative
like PyMuPDF, per CLAUDE.md's copyleft-dependency convention (a check
that only applies to copyleft/unclear licenses -- this one's plainly
permissive, so no explicit flag was needed here)."""
from __future__ import annotations
import io
import pypdfium2 as pdfium
from fastapi import HTTPException
from PIL import Image, UnidentifiedImageError
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # generous for a single image/PDF page; stops an accidental huge upload
# ~144 DPI off a PDF's 72-DPI native unit -- comfortably above the panel's
# own 800x480, without ballooning render time/memory on a poster-sized page.
PDF_RENDER_SCALE = 2.0
def decode_upload(data: bytes) -> Image.Image:
"""Raises HTTPException(400) for anything that isn't a recognizable
image or PDF. Detects PDF by magic bytes, not the client-supplied
filename/content-type (neither is trustworthy). A PDF renders only
its first page -- there's no "which page" concept for a single-image
widget."""
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(400, f"File is too large (max {MAX_UPLOAD_BYTES // (1024 * 1024)}MB)")
if data.startswith(b"%PDF-"):
return _decode_pdf(data)
try:
img = Image.open(io.BytesIO(data))
img.load()
except UnidentifiedImageError:
raise HTTPException(400, "Not a recognizable image or PDF file") from None
return img.convert("RGB")
def _decode_pdf(data: bytes) -> Image.Image:
try:
pdf = pdfium.PdfDocument(data)
if len(pdf) == 0:
raise HTTPException(400, "PDF has no pages")
bitmap = pdf[0].render(scale=PDF_RENDER_SCALE)
except pdfium.PdfiumError as e:
raise HTTPException(400, f"Could not read PDF: {e}") from e
return bitmap.to_pil().convert("RGB")
+5 -5
View File
@@ -80,10 +80,10 @@ class ImmichClient:
resp.raise_for_status()
return resp.json()
def create_share_link(self, asset_id: str, expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link for a single
asset, expiring expires_in_s seconds from now, and returns its
public URL. Used by the manage-button overlay's share QR --
def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link covering one or
more assets, expiring expires_in_s seconds from now, and returns
its public URL. Used by the manage-button overlay's share QR --
created lazily (only when someone actually scans it), not when
the button's pressed, so the expiry clock starts when it's
actually used."""
@@ -93,7 +93,7 @@ class ImmichClient:
headers=self._headers,
json={
"type": "INDIVIDUAL",
"assetIds": [asset_id],
"assetIds": list(asset_ids),
"expiresAt": expires_at,
"allowUpload": False,
"allowDownload": True,
+7 -2
View File
@@ -4,7 +4,10 @@ for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/:
device.py the firmware-facing /frame/* protocol (paths frozen)
api_frames.py the web UI's JSON API, /api/frames/{id}/...
frame_pages.py the per-frame Photos/Configuration/Stats pages
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
api_layouts.py named, user-owned saved layouts, /api/layouts,
/api/frames/{id}/layouts
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
Storage is SQLite via models.py/db.py; migration.py imports a
@@ -30,7 +33,7 @@ from .auth import (
)
from .db import SessionLocal
from .models import Frame
from .routers import api_frames, device, frame_pages, manage, pages
from .routers import api_frames, api_layouts, api_widgets, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
@@ -45,6 +48,8 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
app.include_router(api_frames.router)
app.include_router(api_widgets.router)
app.include_router(api_layouts.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
+240
View File
@@ -0,0 +1,240 @@
"""Composites the manage-button overlay -- "scan to manage" QR, battery,
location/date-taken, share-QR, named face labels -- server-side, onto an
already-composed image (any mode: a photo, or a calendar view), before
quantization. Replaces what used to be firmware/main/manage_qr_overlay.c
generating and positioning all of this on-device.
Corner/spacing constants below are plain Python now, not a protocol
contract with firmware -- adjustable here without touching anything else.
Uses the same toolkit image_pipeline.render_placeholder already does
(PIL ImageDraw/ImageFont, the qrcode library), just doing more with it.
"""
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
PADDING = 16
QR_TEXT_GAP = 8
LINE_GAP = 4
PANEL_MARGIN = 20
QR_TARGET_PX = 180
TITLE_FONT_SIZE = 22
BODY_FONT_SIZE = 20
BATTERY_ICON_W = 40
BATTERY_ICON_H = 22
BATTERY_ICON_STROKE = 2
BATTERY_NUB_W = 5
BATTERY_NUB_H = 10
BATTERY_ICON_TEXT_GAP = 8
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
FACE_LABEL_PADDING = 8
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
def _font(size: int) -> ImageFont.ImageFont:
return ImageFont.load_default(size=size)
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
scale = max(1, target_px // raw.width)
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
"""(width, height) of `lines` stacked with LINE_GAP between them, at
`font` -- the box _draw_text_box below will need."""
w = 0
h = 0
for i, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font)
w = max(w, bbox[2] - bbox[0])
h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0)
return w, h
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
center_x: int, top: int) -> None:
y = top
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0]
draw_text(img, (center_x - w // 2, y), line, font)
y += (bbox[3] - bbox[1]) + LINE_GAP
def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str],
corner: str) -> tuple[int, int, int, int]:
"""White-padded box with a QR code and centered caption lines below
it, placed in one of the panel's four corners. Returns (x0, y0, w, h)
-- callers that need to anchor something else relative to this box
(the battery, below the manage QR) use it instead of recomputing the
same geometry a second time."""
qr_img = _qr_image(url)
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
content_w = max(qr_img.width, text_w)
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
w = content_w + PADDING * 2
h = content_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption:
_draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
return x0, y0, w, h
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
"""White-padded box with centered text lines, placed in one of the
panel's four corners."""
font = _font(BODY_FONT_SIZE)
text_w, text_h = _text_box(draw, lines, font)
w = text_w + PADDING * 2
h = text_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
img_w, img_h = img_size
box_w, box_h = box_size
if corner == "top-left":
return PANEL_MARGIN, PANEL_MARGIN
if corner == "top-right":
return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN
if corner == "bottom-left":
return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
# carries the "how worried should I be" signal, not just the number next
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
# project (not tied to a frame's own configured alert threshold, since
# this glyph has to make sense with no configuration at all).
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
if percent <= 15:
return _BATTERY_LOW
if percent <= 40:
return _BATTERY_MEDIUM
return _BATTERY_HIGH
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None:
"""Battery glyph (now actually filled to `percent`, not just a static
outline -- easy now that this renders server-side instead of being a
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
given anchor box (the manage QR box) -- a sensible default position,
not a constraint anything else has to route around; move this call
site's arguments to place it anywhere else instead."""
font = _font(BODY_FONT_SIZE)
text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
text_w = draw.textlength(text, font=font)
content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w
content_h = max(font.size, BATTERY_ICON_H)
w = int(content_w + PADDING * 2)
h = int(content_h + PADDING * 2)
x0 = anchor_x0 + anchor_w - w
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
icon_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
if fill_x1 > inner_x0:
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
width=BATTERY_ICON_STROKE)
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
fill=(0, 0, 0))
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
text, font)
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
"""White-padded name label centered under an arbitrary (anchor_x,
anchor_y) point, flipped above if there's no room below, clamped to
stay fully on-panel -- unlike the four corner boxes (always in-bounds
by construction), a face can be anywhere, including near an edge."""
font = _font(BODY_FONT_SIZE)
text_w = draw.textlength(name, font=font)
bbox = draw.textbbox((0, 0), name, font=font)
text_h = bbox[3] - bbox[1]
w = int(text_w + FACE_LABEL_PADDING * 2)
h = int(text_h + FACE_LABEL_PADDING * 2)
img_w, img_h = img.size
x0 = anchor_x - w // 2
y0 = anchor_y + FACE_LABEL_GAP
if y0 + h > img_h:
y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead
x0 = max(0, min(x0, img_w - w))
y0 = max(0, min(y0, img_h - h))
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
location_lines: tuple[str, str] | None = None, taken_at: str | None = None,
share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image:
"""Draws the manage overlay onto a copy of `image` (RGB, any mode's
already-composed/enhanced logical-space canvas) and returns it.
management_url's "scan to manage" box always shows; everything else
is optional and simply omitted when not given -- battery_percent
None or out of 0-100 skips the battery box, location_lines/taken_at/
share_url empty/None skip their own box, face_labels empty skips
those."""
img = image.copy()
draw = ImageDraw.Draw(img)
qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right")
if battery_percent is not None and 0 <= battery_percent <= 100:
_draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h)
if location_lines and location_lines[0]:
lines = [line for line in location_lines if line]
_draw_text_box(img, draw, lines, "top-left")
if taken_at:
_draw_text_box(img, draw, [taken_at], "bottom-right")
if share_url:
_draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left")
for label in face_labels or []:
if label.get("name"):
_draw_face_label(img, draw, label["name"], label["x"], label["y"])
return img
+809 -3
View File
@@ -15,11 +15,23 @@ import secrets
import shutil
import time
from sqlalchemy import select, text
from sqlalchemy import inspect, select, text
from . import config
from . import config, grid
from .db import SessionLocal, engine
from .models import Base, BatteryLog, Frame, ServerSettings
from .models import (
Base,
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
logger = logging.getLogger(__name__)
@@ -80,6 +92,538 @@ def _migration_6(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
def _migration_7(conn) -> None:
"""Calendar frame mode: a personal ICS subscription per user
(users.calendar_ics_url), an explicit per-(user,frame) opt-in into a
frame's merged calendar (user_frames.calendar_included, default off
-- linking to a frame does not auto-include your calendar there),
and the frame-level view/inlay/browse-offset/cache settings calendar
mode needs (see calendar_feed.py, calendar_render.py,
routers/device.py's RENDERERS["calendar"]). Every new column has a
behavior-preserving default -- no existing frame's behavior changes
until its mode is actually switched to "calendar"."""
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
def _migration_8(conn) -> None:
"""Configurable week-start day for calendar mode's week/month views
(0=Monday..6=Sunday, matching Python's date.weekday()/calendar.Calendar
convention exactly -- no translation needed at render time). Default 0
(Monday) matches calendar_render.py's previous hardcoded behavior, so
this is a no-op for every existing frame until changed."""
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start INTEGER NOT NULL DEFAULT 0"))
def _migration_9(conn) -> None:
"""CalDAV support alongside the plain ICS subscription (see
caldav_client.py), and the frame_calendars table that replaces
user_frames.calendar_included now that one account (CalDAV) can
expose more than one calendar -- see models.py's FrameCalendar.
Existing single-calendar opt-ins are carried forward as "ics" rows
before the old column is dropped, so nobody's frame goes silently
calendar-less after this migration."""
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text(
"CREATE TABLE frame_calendars ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
))
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
def _migration_10(conn) -> None:
"""Optional weather strip for calendar mode (agenda/today & tomorrow/
week views -- never month, see calendar_render.py's _BUILDERS).
Multiple cities per frame (calendar_weather_cities), each geocoded
once via weather.py's Open-Meteo lookup (no API key) and their daily
forecasts refreshed on their own throttle, same shape idiom as
calendar_checked_at/calendar_cached_events. Off by default -- no
existing frame's render changes until its Calendar tab turns it on."""
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_enabled INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cities TEXT"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
def _migration_11(conn) -> None:
"""Manual per-calendar color choice (frame_calendars.color_index,
2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
Green). calendar_render.py's event color bar/dot used to auto-cycle
through those same four colors in whatever order calendars happened
to appear; this lets a household pin a specific one instead so it
stays stable and recognizable. NULL (the default) keeps the old
auto-cycle behavior -- no existing frame's render changes until
someone actually picks a color."""
conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))
def _migration_12(conn) -> None:
"""Week view flexibility: a configurable day count (2-10, default 7
-- the original fixed behavior) and a horizontal/vertical layout
choice, plus an optional CalDAV task list that takes the space of
one day slot when enabled (see calendar_render.py's _build_week/
_draw_tasks). Every new column has a behavior-preserving default --
no existing frame's render changes until its Calendar tab touches
one of these."""
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_days INTEGER NOT NULL DEFAULT 7"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_calendar_key TEXT"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_tasks_cached TEXT"))
def _migration_13(conn) -> None:
"""A day-count-relative start offset for the week view
(calendar_week_start_offset), used instead of calendar_week_start's
fixed-weekday anchor once the view isn't a literal 7-day week --
"start on the most recent Monday" stops meaning much for e.g. a
5-day view. Default 0 (starts today) is a behavior-preserving no-op
until someone changes the day count away from 7."""
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
def _migration_14(conn) -> None:
"""Whiteboard frame mode: generic WebDAV credentials per user
(webdav_username/password, plus webdav_reuse_caldav_creds as a
convenience when it's the same Nextcloud account as an already-
configured CalDAV one -- see models.py's User docstring), and the
frame-level whiteboard source (whiteboard_user_id/url) + rendered-
PNG cache (see webdav_client.py, whiteboard.py,
routers/device.py's RENDERERS["whiteboard"]). Every new column has a
behavior-preserving default -- no existing frame's render changes
until its mode is actually switched to "whiteboard"."""
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
def _migration_15(conn) -> None:
"""Optional starting folder for the whiteboard file-picker (see
models.py's User.webdav_base_url docstring) -- purely a browsing
convenience, never used for actual fetch/render."""
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_base_url TEXT NOT NULL DEFAULT ''"))
def _migration_16(conn) -> None:
"""Widget system: a frame can now hold N independently placed/sized
widgets (photos/calendar/whiteboard) instead of exactly one mode-wide
renderer -- see models.py's Widget/PhotoWidgetConfig/
CalendarWidgetConfig/WhiteboardWidgetConfig/FrameButtonAction,
app/grid.py, app/widgets/.
This migration only creates the new (empty) tables -- it does NOT
backfill a widget per existing frame here. That backfill (reading
each frame's current mode/settings to build a widget that reproduces
its exact current display, including the calendar_photo_inlay ->
two-widgets special case) is real per-mode branching logic that's
much less error-prone written as typed ORM object construction than
as hand-written column-by-column SQL -- see _ensure_widgets_backfilled,
called unconditionally at the end of run_migrations() for both this
upgrade path AND the from-scratch _ensure_frame_one() path, so both
produce the same default-widget invariant from one place rather than
two separately-maintained ones. Every existing frame is briefly
widget-less between this migration and that call within the same
startup, not across restarts -- nothing reads these tables yet at
that point regardless."""
conn.execute(text(
"CREATE TABLE widgets ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"widget_type TEXT NOT NULL, "
"x INTEGER NOT NULL, "
"y INTEGER NOT NULL, "
"w INTEGER NOT NULL, "
"h INTEGER NOT NULL, "
"sort_order INTEGER NOT NULL DEFAULT 0, "
"created_at REAL NOT NULL DEFAULT 0.0)"
))
conn.execute(text("CREATE INDEX ix_widgets_frame ON widgets (frame_id)"))
conn.execute(text(
"CREATE TABLE photo_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"album_id TEXT NOT NULL DEFAULT '', "
"photo_order TEXT NOT NULL DEFAULT 'sequential', "
"display_mode TEXT NOT NULL DEFAULT 'crop_faces', "
"queue_target_len INTEGER NOT NULL DEFAULT 20, "
"current_asset_id TEXT NOT NULL DEFAULT '', "
"current_asset_set_at REAL NOT NULL DEFAULT 0.0, "
"queue TEXT NOT NULL DEFAULT '[]', "
"queue_cursor INTEGER NOT NULL DEFAULT 0, "
"history TEXT NOT NULL DEFAULT '[]', "
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]')"
))
conn.execute(text(
"CREATE TABLE calendar_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"view TEXT NOT NULL DEFAULT 'agenda', "
"week_start INTEGER NOT NULL DEFAULT 0, "
"browse_offset INTEGER NOT NULL DEFAULT 0, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached_events TEXT, "
"fetch_summary TEXT NOT NULL DEFAULT '', "
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
"weather_cities TEXT, "
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
"weather_cached TEXT, "
"week_days INTEGER NOT NULL DEFAULT 7, "
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"tasks_calendar_key TEXT, "
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
"tasks_cached TEXT)"
))
conn.execute(text(
"CREATE TABLE whiteboard_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"url TEXT NOT NULL DEFAULT '', "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached_image BLOB)"
))
conn.execute(text(
"CREATE TABLE frame_button_actions ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"button TEXT NOT NULL, "
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
"action TEXT NOT NULL, "
"sort_order INTEGER NOT NULL DEFAULT 0, "
"created_at REAL NOT NULL DEFAULT 0.0)"
))
conn.execute(text(
"CREATE INDEX ix_frame_button_actions_frame_button ON frame_button_actions (frame_id, button, sort_order)"
))
def _migration_17(conn) -> None:
"""Splits the calendar widget's old week-view-only task list out into
its own standalone widget type (see models.TaskWidgetConfig,
app/widgets/tasks.py) -- a task list is no longer tied to a
calendar's view or footprint, and can be placed/sized on its own.
Every calendar_widget_configs row that still has a task source
configured gets a new sibling `tasks` widget carrying that source
over, auto-placed in whatever open grid space is left on its frame
(same find_open_rect logic a manual "add widget" uses; if truly none
is left, the source is dropped and logged -- rare enough, and with
no interactive way to ask during a boot-time migration, that this is
an acceptable edge case). calendar_widget_configs then drops its now
-dead tasks_* columns -- this project's usual same-migration-drop
convention (see docs/widgets.md's Known Gaps for the one deliberate,
much-larger-blast-radius exception)."""
conn.execute(text(
"CREATE TABLE task_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"calendar_key TEXT, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached TEXT)"
))
rows = conn.execute(text(
"SELECT cwc.widget_id, w.frame_id, f.orientation, "
"cwc.tasks_user_id, cwc.tasks_calendar_key, cwc.tasks_checked_at, cwc.tasks_cached "
"FROM calendar_widget_configs cwc "
"JOIN widgets w ON w.id = cwc.widget_id "
"JOIN frames f ON f.id = w.frame_id "
"WHERE cwc.tasks_calendar_key IS NOT NULL"
)).mappings().all()
skipped = 0
now = time.time()
for row in rows:
existing = conn.execute(text(
"SELECT x, y, w, h FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).all()
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(row["orientation"], [tuple(r) for r in existing], min_w, min_h)
if rect is None:
skipped += 1
continue
x, y, w, h = rect
max_sort = conn.execute(text(
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).scalar()
result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) "
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)"
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
"sort_order": max_sort + 1, "created_at": now})
new_widget_id = result.lastrowid
conn.execute(text(
"INSERT INTO task_widget_configs (widget_id, user_id, calendar_key, checked_at, cached) "
"VALUES (:widget_id, :user_id, :calendar_key, :checked_at, :cached)"
), {"widget_id": new_widget_id, "user_id": row["tasks_user_id"],
"calendar_key": row["tasks_calendar_key"], "checked_at": row["tasks_checked_at"],
"cached": row["tasks_cached"]})
if skipped:
logger.warning(
"%d calendar widget(s) had a task list configured but no open grid space for a "
"standalone tasks widget -- their task source was dropped", skipped
)
# Rebuild calendar_widget_configs without the now-dead tasks_*
# columns -- SQLite can't drop tasks_user_id directly (it's part of
# an FK constraint), same situation frame_calendars hit in
# _migration_9, same rebuild-create-copy-drop-rename fix.
conn.execute(text(
"CREATE TABLE calendar_widget_configs_new ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"view TEXT NOT NULL DEFAULT 'agenda', "
"week_start INTEGER NOT NULL DEFAULT 0, "
"browse_offset INTEGER NOT NULL DEFAULT 0, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached_events TEXT, "
"fetch_summary TEXT NOT NULL DEFAULT '', "
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
"weather_cities TEXT, "
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
"weather_cached TEXT, "
"week_days INTEGER NOT NULL DEFAULT 7, "
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
"week_start_offset INTEGER NOT NULL DEFAULT 0)"
))
conn.execute(text(
"INSERT INTO calendar_widget_configs_new "
"(widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
"week_days, week_layout, week_start_offset) "
"SELECT widget_id, view, week_start, browse_offset, checked_at, cached_events, fetch_summary, "
"weather_enabled, weather_units, weather_cities, weather_checked_at, weather_cached, "
"week_days, week_layout, week_start_offset "
"FROM calendar_widget_configs"
))
conn.execute(text("DROP TABLE calendar_widget_configs"))
conn.execute(text("ALTER TABLE calendar_widget_configs_new RENAME TO calendar_widget_configs"))
def _migration_18(conn) -> None:
"""A tasks widget can now merge more than one person's CalDAV task
list, checkbox-included with an optional pinned color each -- same
multi-source shape calendar widgets already have (models.
FrameCalendar), rather than the single user_id/calendar_key pair
migration 17 gave TaskWidgetConfig when tasks first became their own
widget type. Also adds show_completed (see caldav_client.
fetch_tasks' completed_since -- off by default, so this migration
changes no widget's on-panel appearance by itself).
Each task_widget_configs row's existing single source, if any,
carries forward as that widget's first frame_task_lists row
(included) before the now-dead user_id/calendar_key columns are
dropped -- same "carry forward the old single opt-in as a row before
dropping the column" shape _migration_9 used for frame_calendars."""
conn.execute(text(
"CREATE TABLE frame_task_lists ("
"id INTEGER PRIMARY KEY, "
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_task_lists_unique ON frame_task_lists (widget_id, user_id, calendar_key)"
))
conn.execute(text(
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
"SELECT widget_id, user_id, calendar_key, 1 FROM task_widget_configs "
"WHERE calendar_key IS NOT NULL AND user_id IS NOT NULL"
))
conn.execute(text(
"CREATE TABLE task_widget_configs_new ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached TEXT, "
"show_completed INTEGER NOT NULL DEFAULT 0)"
))
conn.execute(text(
"INSERT INTO task_widget_configs_new (widget_id, checked_at, cached) "
"SELECT widget_id, checked_at, cached FROM task_widget_configs"
))
conn.execute(text("DROP TABLE task_widget_configs"))
conn.execute(text("ALTER TABLE task_widget_configs_new RENAME TO task_widget_configs"))
def _migration_19(conn) -> None:
"""Optional custom on-panel name for a tasks widget (see
calendar_render._draw_tasks), replacing the default "Tasks" header
-- the only widget type with its own on-panel title at all, since
it's the only one where "which list is this" isn't already obvious
from its content. "" (the default) keeps the old hardcoded text, so
this changes no existing widget's appearance by itself. Plain
column add, no FK/index involved -- no rebuild-table dance needed
(unlike task_widget_configs' two previous migrations)."""
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN name TEXT NOT NULL DEFAULT ''"))
def _migration_20(conn) -> None:
"""New widget type: a static image widget shows whatever single
image (or a PDF's first page) the user last uploaded (see
app/image_upload.py, routers/api_widgets.py's api_widget_static_
upload) -- no live upstream to poll, unlike every other widget type.
Raw CREATE TABLE, not Base.metadata.create_all (this migration
originally used create_all -- switched retroactively once it turned
out to matter): create_all creates every table declared in Base.
metadata that's missing, not just this migration's own new one, so
it would just as happily create text_widget_configs (a LATER
migration's model, once TextWidgetConfig existed in models.py) years
before migration 21 gets a turn -- and then migration 21's own
CREATE TABLE collides with the one create_all already snuck in. Same
fix, same reasoning as migration 21's own comment about migration
22's ALTER TABLE -- see that one for the fuller explanation."""
conn.execute(text(
"CREATE TABLE static_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"image BLOB, "
"original_filename TEXT NOT NULL DEFAULT '', "
"uploaded_at REAL NOT NULL DEFAULT 0.0, "
"display_mode TEXT NOT NULL DEFAULT 'crop_fill')"
))
def _migration_21(conn) -> None:
"""New widget type: a text widget shows user-authored rich text (see
app/text_content.py, app/widgets/text.py, models.TextWidgetConfig)
-- another no-live-upstream type like migration 20's static image.
Raw CREATE TABLE (not Base.metadata.create_all, unlike migration 20's
static_widget_configs) because migration 22 adds a column to this
same table right after -- create_all always reflects models.py's
CURRENT shape, so replaying the full migration chain on an old
database would have it already include that later column and
collide with migration 22's ALTER TABLE. Same reason migration 17's
task_widget_configs CREATE TABLE is raw SQL rather than create_all,
ahead of migration 19's ALTER TABLE ADD COLUMN name."""
conn.execute(text(
"CREATE TABLE text_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"content TEXT, "
"font_size INTEGER NOT NULL DEFAULT 28, "
"align TEXT NOT NULL DEFAULT 'left', "
"background_color TEXT NOT NULL DEFAULT '#ffffff')"
))
def _migration_22(conn) -> None:
"""Adds a font family choice to the text widget (app/widgets/text.py's
FONT_FAMILIES) alongside its existing font_size -- both whole-widget
settings, not per-run. "sans" (Noto Sans) matches the column default
so existing text widgets keep rendering in the same font they always
have."""
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN font_family TEXT NOT NULL DEFAULT 'sans'"))
def _migration_23(conn) -> None:
"""New feature: named, user-owned saved layouts (see models.
SavedLayout/SavedLayoutWidget/SavedLayoutSource/
SavedLayoutButtonAction, routers/api_layouts.py) -- a snapshot of a
frame's widget arrangement a user can capture and later apply to any
frame they control whose grid matches, instead of manually rebuilding
it widget by widget.
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
migration 20/21's own comments: create_all always reflects models.py's
CURRENT shape, so replaying the full chain on an old database could
collide with a later migration's ALTER TABLE on one of these same
tables."""
conn.execute(text(
"CREATE TABLE saved_layouts ("
"id INTEGER PRIMARY KEY, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"name TEXT NOT NULL, "
"cols INTEGER NOT NULL, "
"rows INTEGER NOT NULL, "
"created_at REAL NOT NULL DEFAULT 0.0, "
"updated_at REAL NOT NULL DEFAULT 0.0)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_saved_layouts_user_name ON saved_layouts (user_id, name)"
))
conn.execute(text(
"CREATE TABLE saved_layout_widgets ("
"id INTEGER PRIMARY KEY, "
"saved_layout_id INTEGER NOT NULL REFERENCES saved_layouts(id) ON DELETE CASCADE, "
"widget_type TEXT NOT NULL, "
"x INTEGER NOT NULL, y INTEGER NOT NULL, w INTEGER NOT NULL, h INTEGER NOT NULL, "
"sort_order INTEGER NOT NULL DEFAULT 0, "
"config TEXT NOT NULL DEFAULT '{}', "
"image BLOB)"
))
conn.execute(text(
"CREATE INDEX ix_saved_layout_widgets_layout ON saved_layout_widgets (saved_layout_id)"
))
conn.execute(text(
"CREATE TABLE saved_layout_sources ("
"id INTEGER PRIMARY KEY, "
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
"kind TEXT NOT NULL, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE INDEX ix_saved_layout_sources_widget ON saved_layout_sources (saved_layout_widget_id)"
))
conn.execute(text(
"CREATE TABLE saved_layout_button_actions ("
"id INTEGER PRIMARY KEY, "
"saved_layout_widget_id INTEGER NOT NULL REFERENCES saved_layout_widgets(id) ON DELETE CASCADE, "
"button TEXT NOT NULL, "
"action TEXT NOT NULL, "
"sort_order INTEGER NOT NULL DEFAULT 0)"
))
conn.execute(text(
"CREATE INDEX ix_saved_layout_button_actions_widget ON saved_layout_button_actions (saved_layout_widget_id)"
))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -87,6 +631,23 @@ MIGRATIONS = [
(4, _migration_4),
(5, _migration_5),
(6, _migration_6),
(7, _migration_7),
(8, _migration_8),
(9, _migration_9),
(10, _migration_10),
(11, _migration_11),
(12, _migration_12),
(13, _migration_13),
(14, _migration_14),
(15, _migration_15),
(16, _migration_16),
(17, _migration_17),
(18, _migration_18),
(19, _migration_19),
(20, _migration_20),
(21, _migration_21),
(22, _migration_22),
(23, _migration_23),
]
@@ -114,6 +675,8 @@ def run_migrations() -> None:
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
_ensure_frame_one()
_ensure_server_settings()
_ensure_widgets_backfilled()
_ensure_frame_calendars_rekeyed()
def new_device_token() -> str:
@@ -218,3 +781,246 @@ def _ensure_server_settings() -> None:
if db.get(ServerSettings, 1) is None:
db.add(ServerSettings(id=1))
db.commit()
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
return PhotoWidgetConfig(
widget_id=widget_id,
album_id=frame.album_id,
order=frame.order,
display_mode=frame.display_mode,
queue_target_len=frame.queue_target_len,
current_asset_id=frame.current_asset_id,
current_asset_set_at=frame.current_asset_set_at,
queue=list(frame.queue),
queue_cursor=frame.queue_cursor,
history=list(frame.history),
excluded_asset_ids=list(frame.excluded_asset_ids),
)
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
return CalendarWidgetConfig(
widget_id=widget_id,
view=frame.calendar_view,
week_start=frame.calendar_week_start,
browse_offset=frame.calendar_browse_offset,
checked_at=frame.calendar_checked_at,
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
fetch_summary=frame.calendar_fetch_summary,
weather_enabled=frame.calendar_weather_enabled,
weather_units=frame.calendar_weather_units,
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
weather_checked_at=frame.calendar_weather_checked_at,
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
week_days=frame.calendar_week_days,
week_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset,
# tasks_* deliberately not carried over -- see
# _task_config_and_list_from_frame, a sibling standalone widget
# now, not part of this config.
)
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
"""Only ever called for a frame whose legacy calendar_tasks_* columns
(see Frame's own docstring on those -- a dead pre-widget-system
field set, same status as calendar_photo_inlay below) still carry a
configured source -- i.e. a database jumping straight from before
the widget system existed to after tasks became their own
multi-list widget type in a single upgrade, skipping both
intermediate periods where it would have lived on
CalendarWidgetConfig (_migration_17's extraction) and then a
single-source TaskWidgetConfig (_migration_18's extraction) instead.
Reproduces the same shape those two migrations arrive at directly:
a bare cache-state config plus one included FrameTaskList row."""
cfg = TaskWidgetConfig(
widget_id=widget_id,
checked_at=frame.calendar_tasks_checked_at,
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
)
task_list = FrameTaskList(
widget_id=widget_id,
user_id=frame.calendar_tasks_user_id,
calendar_key=frame.calendar_tasks_calendar_key,
included=True,
)
return cfg, task_list
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
return WhiteboardWidgetConfig(
widget_id=widget_id,
user_id=frame.whiteboard_user_id,
url=frame.whiteboard_url,
checked_at=frame.whiteboard_checked_at,
cached_image=frame.whiteboard_cached_image,
)
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is
(see app/widgets/ for the actual action registry, built in a later
phase) -- reproduces each mode's exact old button behavior for the
one auto-migrated widget, so upgrading changes nothing about what the
physical buttons do until someone deliberately reassigns them."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons already meant "check now" before this migration (see
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
in one upgrade (see _task_config_and_list_from_frame) --
frame.calendar_tasks_* is the dead legacy field set otherwise.
Requires both calendar_key and user_id (FrameTaskList.user_id is
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
Auto-placed in whatever open space is left after the widget(s) above
it in _backfill_frame_widgets claimed theirs, same find_open_rect
logic a manual "add widget" uses; silently dropped (logged) if none
fits, same as this migration having nowhere else to put it either."""
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame.id
)
return
x, y, w, h = rect
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
sort_order=next_sort_order, created_at=time.time())
db.add(task_widget)
db.flush()
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
db.add(cfg)
db.add(task_list)
def _backfill_frame_widgets(db, frame: Frame) -> None:
cols, rows = grid.grid_dims(frame.orientation)
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
if mode == "calendar" and frame.calendar_photo_inlay:
# Reproduces the old fixed 50/50 inlay split as two independent
# widgets instead of silently dropping half of what the frame was
# showing -- see models.py's CalendarWidgetConfig docstring on why
# "photo inlay" isn't a widget-system concept anymore otherwise.
half = cols // 2
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
db.add_all([cal_widget, photo_widget])
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
return
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush()
if mode == "photos":
db.add(_photo_config_from_frame(frame, widget.id))
elif mode == "calendar":
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
def _ensure_widgets_backfilled() -> None:
"""Every frame needs at least one Widget once the widget system is
live -- runs unconditionally after every startup (both a from-scratch
_ensure_frame_one() install and an existing-install upgrade past
_migration_16 land here) and is a no-op for any frame that already
has one. Builds a widget that reproduces the frame's current mode/
settings/state exactly, so upgrading never changes what a frame
displays or what its physical buttons do on its own."""
with SessionLocal() as db:
for frame in db.scalars(select(Frame)).all():
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
if has_widget is not None:
continue
_backfill_frame_widgets(db, frame)
db.commit()
def _ensure_frame_calendars_rekeyed() -> None:
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
hold more than one independent calendar widget (see the widget
system), each with its own included-calendars set, so "included on
this frame" no longer means anything unambiguous (see
models.FrameCalendar). Existing rows attach to their frame's calendar
widget if it has one; rows for a frame with no calendar widget at all
are dropped -- they were already-dormant settings for content
nothing ever actually displayed (the Calendar tab stayed reachable
and savable even while a frame's old `mode` was "photos"), not real
live configuration.
Deliberately NOT a numbered migration: this needs each frame's
calendar widget to already exist to know what to re-key against, and
those widget rows aren't created by a schema migration at all --
they come from _ensure_widgets_backfilled() above, which (like this
function) runs unconditionally after every startup rather than being
tracked by schema_version. Running this as a numbered migration
would execute it *before* that backfill during a real upgrade (the
numbered-migration loop runs first, see run_migrations), silently
dropping every row -- caught by test_migrations.py actually exercising
the raw-SQL upgrade path instead of the fresh-install create_all()
shortcut every other test in that file takes.
Runs unconditionally after every startup, like _ensure_widgets_
backfilled; a no-op the moment frame_calendars is already
widget_id-shaped (every fresh install, and any existing install
after its first run past this code) -- SQLite can't ALTER a column's
FK target or drop a column that's part of an index/FK constraint, so
when it isn't a no-op this is the standard SQLite "rebuild" pattern:
create the new-shape table, copy matching rows across (joining to
find each row's calendar widget), drop the old table, rename the new
one into place."""
inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
if "widget_id" in columns:
return
with engine.begin() as conn:
conn.execute(text(
"CREATE TABLE frame_calendars_new ("
"id INTEGER PRIMARY KEY, "
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"INSERT INTO frame_calendars_new (widget_id, user_id, calendar_key, calendar_label, included, color_index) "
"SELECT w.id, fc.user_id, fc.calendar_key, fc.calendar_label, fc.included, fc.color_index "
"FROM frame_calendars fc "
"JOIN widgets w ON w.frame_id = fc.frame_id AND w.widget_type = 'calendar'"
))
conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text("ALTER TABLE frame_calendars_new RENAME TO frame_calendars"))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (widget_id, user_id, calendar_key)"
))
+552 -3
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import time
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -48,6 +48,45 @@ class User(Base):
# email -- see routers/device.py's frame_battery) go here; blank = no
# email configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="")
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
# frame only pulls this user's events in once they've also added it
# on that frame's own Calendar tab (FrameCalendar below).
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
# plain ICS subscription above -- see caldav_client.py. calendar_url
# is the server's CalDAV entry point the user pasted in, not any one
# calendar's own URL; the individual calendars it exposes are
# discovered and cached below.
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
# [{"href", "display_name"}, ...] from the last successful
# caldav_client.discover_calendars() call, refreshed by Settings'
# "Discover calendars" button -- NULL until discovery has ever
# succeeded. This is what a frame's Calendar tab offers the user to
# add, without hitting the CalDAV server on every page load.
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# WebDAV credentials for whiteboard frame mode (see webdav_client.py,
# whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
# webdav_reuse_caldav_creds is a convenience for the common case
# where it IS the same Nextcloud account as calendar_caldav_*: skip
# re-entering the same username/password, since Nextcloud's CalDAV
# and general-file-WebDAV both sit under the one account. Doesn't
# try to be clever and derive the reuse automatically -- an explicit
# opt-in, same as everywhere else in this project defaults features
# off rather than silently inferring them.
webdav_username: Mapped[str] = mapped_column(String, default="")
webdav_password: Mapped[str] = mapped_column(String, default="")
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
# Optional starting folder for the whiteboard dialog's file-picker
# (see routers/api_widgets.py's api_widget_whiteboard_browse) --
# purely a convenience for browsing to a file rather than typing its
# full URL.
# Never used for fetching/rendering itself, which always uses the
# frame's own saved whiteboard_url regardless of whether this is set.
webdav_base_url: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
@@ -82,8 +121,9 @@ class Frame(Base):
# the migrated legacy frame until its device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam for future calendar/canva modes -- only
# "photos" is registered today (see routers/device.py RENDERERS).
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
# and routers/common.py FRAME_MODES).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
@@ -139,6 +179,105 @@ class Frame(Base):
# original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
# which day week/month views start their grid on.
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
# Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
# How many periods (unit depends on calendar_view: days/weeks/months)
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
# (non-button) /frame/image request, and whenever calendar_view
# itself changes -- a stale offset means something different in a
# different view's units.
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
# Throttled merge-fetch cache (see routers/common.py's
# get_or_refresh_calendar_events) -- same shape as the
# firmware_update_checked_at/firmware_gitea_latest_version pattern
# below. One shared cache for every included user's merged events,
# not per-user.
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
# calendars unavailable" -- never names which user's feed failed, a
# shared household display shouldn't call out a specific person's
# outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# Optional weather strip, agenda/today & tomorrow/week views only --
# never month, there's no room (see calendar_render.py's _BUILDERS).
# Off by default.
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
# weather.geocode_city() when added from the Calendar tab.
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Throttled per-city forecast cache (see routers/common.py's
# get_or_refresh_weather) -- same shape idiom as
# calendar_checked_at/calendar_cached_events above.
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Week view: how many days to show (2-10, default 7 -- the original
# fixed behavior) and whether they're laid out as side-by-side
# columns or stacked bands (see calendar_render.py's _build_week).
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
# Only used when calendar_week_days != 7 -- calendar_week_start's
# fixed-weekday anchor ("start on the most recent Monday") stops
# making sense once the view isn't a literal calendar week, so a
# non-7-day view instead starts this many days from today (0 =
# starts today, negative = starts in the past, positive = starts in
# the future). Ignored (calendar_week_start governs instead) at the
# default 7 days, so this has no effect until someone actually
# changes the day count.
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# Optional task list, week view only -- takes the space of one day
# slot rather than adding an extra one (see calendar_render.py's
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
# something a plain ICS subscription meaningfully has); source is
# one specific linked user's own CalDAV calendar, same
# owner-controls-their-own-data permission split as FrameCalendar.
# calendar_tasks_user_id
# SET NULL on the user's deletion clears the source rather than
# leaving a dangling reference (checked_at isn't reset by that, but
# the next refresh attempt finds no source and just returns []).
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
# already filtered to outstanding (not-completed) tasks and sorted
# by due date -- see caldav_client.fetch_tasks.
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
# whiteboard_user_id may point the frame at their own account, since
# it's their credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
# account-has-several-calendars case, a WebDAV account doesn't need
# a picker step here since the user already knows which one file).
whiteboard_url: Mapped[str] = mapped_column(String, default="")
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
# BLOB rather than the JSON columns the rest of this cache-pattern
# family uses, since this is binary image data, not JSON-shaped.
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
@@ -202,6 +341,416 @@ class UserFrame(Base):
created_at: Mapped[float] = mapped_column(Float, default=time.time)
class FrameCalendar(Base):
"""One calendar included on one frame -- calendar_key is "ics" (the
owner's single calendar_ics_url) or "caldav:<href>" (one of the
owner's CalDAV collections; href matches an entry in
User.calendar_caldav_calendars). Replaces the old single
UserFrame.calendar_included boolean now that a CalDAV account can
expose more than one calendar.
A row only ever gets created by its own owner (adding a calendar to
a frame is each person's own data-sharing choice, not something a
frame's controller decides on their behalf) -- but once it exists,
ANY user linked to the frame may flip included back to False, muting
a calendar they'd rather not see on a shared display even though
they don't own it. Only the owner may flip it back to True. See
routers/api_widgets.py's api_widget_calendar_select.
Keyed by widget_id, not frame_id -- a frame can hold more than one
independent calendar widget (see Widget), each with its own included-
calendars set; "included on this frame" stopped being unambiguous
the moment that became possible (see migration.py's
_ensure_frame_calendars_rekeyed, which re-keyed this table)."""
__tablename__ = "frame_calendars"
id: Mapped[int] = mapped_column(primary_key=True)
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
# Snapshot label for display -- so the list still reads sensibly even
# if the owner's CalDAV account later stops offering this calendar.
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
# Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
# Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
# text/background) pinning this calendar's events to a specific
# panel color rather than calendar_render.py's old owner-name
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
# owner may set this -- see routers/api_widgets.py's api_widget_calendar_color.
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_calendars_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
class FrameTaskList(Base):
"""One CalDAV task list included on one tasks widget -- calendar_key
is "caldav:<href>" (an entry in User.calendar_caldav_calendars; no
"ics" variant, unlike FrameCalendar -- a plain ICS subscription has
no VTODO collection). Same owner-controls-their-own-data shape as
FrameCalendar in every other respect: a row is only ever created by
its own owner, but any user linked to the frame may flip included
back to False, and only the owner may flip it back to True or set
color_index. See routers/api_widgets.py's api_widget_task_list_select/
api_widget_task_list_color."""
__tablename__ = "frame_task_lists"
id: Mapped[int] = mapped_column(primary_key=True)
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_task_lists_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
class Widget(Base):
"""One placed/sized content item on a frame's panel -- the unit the
widget system replaces the old single Frame.mode with (see
app/grid.py for the grid this x/y/w/h is measured in, and app/widgets/
for the widget_type -> render/action dispatch registry). Widgets never
overlap (enforced server-side in routers/api_widgets.py), which is
what keeps compositing simple: no z-order, no blending, just N
independent regions pasted onto one shared canvas before a single
shared dither/quantize pass (see image_pipeline.render_panel).
widget_type selects which of the three per-type extension tables below
(PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig) holds
this widget's actual settings/state -- a 1:1 relational split rather
than one wide table with every type's columns, matching how
FrameCalendar/BatteryLog are already their own tables in this
codebase rather than crammed onto Frame."""
__tablename__ = "widgets"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
h: Mapped[int] = mapped_column(Integer)
# Display/tie-break ordering only (e.g. listing widgets in a UI) --
# NOT a z-order, since widgets never overlap. Named sort_order, not
# order, to sidestep the SQL-keyword dance Frame.order needed
# (mapped to a differently-named column) -- nothing outside this
# table needs to match a specific attribute name here.
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
class PhotoWidgetConfig(Base):
"""One photo widget's settings + queue state. Attribute names match
Frame's old photo-queue columns exactly (down to `order`'s same
photo_order column-name dodge) -- app/photo_queue.py's 5 functions
are duck-typed against these exact names (never isinstance-checked
against Frame), so they port unchanged onto this table."""
__tablename__ = "photo_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
class CalendarWidgetConfig(Base):
"""One calendar widget's settings + cached-fetch state -- the same
fields that used to live as calendar_* columns directly on Frame,
minus calendar_photo_inlay (dropped: arbitrary widget placement
subsumes what a fixed 50/50 inlay split did, so it's not a special
case anymore, just place a photo widget alongside) and minus
tasks_* (also dropped: split out into its own standalone widget
type, see TaskWidgetConfig, so a task list isn't tied to a
calendar's week view/footprint anymore). "Included calendars" is
its own table (FrameCalendar), widget_id-keyed so each calendar
widget on a frame has its own independent set."""
__tablename__ = "calendar_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
view: Mapped[str] = mapped_column(String, default="agenda")
week_start: Mapped[int] = mapped_column(Integer, default=0)
browse_offset: Mapped[int] = mapped_column(Integer, default=0)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
fetch_summary: Mapped[str] = mapped_column(String, default="")
weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
weather_units: Mapped[str] = mapped_column(String, default="fahrenheit")
weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
class TaskWidgetConfig(Base):
"""One tasks widget's settings + cached-fetch state -- split out of
CalendarWidgetConfig (which used to carry these as tasks_* columns,
a week-view-only task list bolted onto a calendar widget) so a task
list can be placed and sized on its own, independent of any
calendar's view/footprint. No separate "enabled" flag -- unlike the
old bolted-on version, the widget's mere presence on the grid is the
on/off switch, same as every other widget type.
Which task lists feed this widget lives in FrameTaskList, not here
-- a widget can merge more than one person's list, mirroring
CalendarWidgetConfig/FrameCalendar exactly (this used to be a single
user_id/calendar_key pair here, one list only; migration 18 carried
each widget's existing single source forward as its first
FrameTaskList row when splitting this out)."""
__tablename__ = "task_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
# Shown on-panel in place of the default "Tasks" header (see
# calendar_render._draw_tasks) -- "" keeps the default. The only
# widget type with its own on-panel title at all, since it's the
# only one where "which list is this" isn't already obvious from
# its content the way a calendar/photo/whiteboard's is.
name: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due", "completed_at" (ISO date/datetime strings or
# None), "owner_display_name", "color_index"}, ...] -- the merged
# multi-list result, same general shape as CalendarWidgetConfig.
# cached_events. See caldav_client.merge_tasks.
cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Also include tasks completed in the last 24h (drawn checked-box +
# muted, see calendar_render._draw_tasks) rather than just
# outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
class WhiteboardWidgetConfig(Base):
"""One whiteboard widget's source + rendered-PNG cache -- the same
fields that used to live as whiteboard_* columns directly on Frame."""
__tablename__ = "whiteboard_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
url: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text
instead of an uploaded image. content is never raw HTML: the
dialog's contenteditable innerHTML is parsed server-side (see
app/text_content.py, the sanitization boundary) into this plain
run structure at save time, so render() (app/widgets/text.py) never
re-parses/sanitizes HTML on every panel refresh, and the dialog never
re-injects stored HTML via innerHTML when reopened.
[[{"text","bold","italic","underline","color","bg"}, ...], ...] --
outer list is paragraphs (line breaks), inner list is styled runs
within that paragraph. color/bg are "#rrggbb" or null (falls back to
black text / no highlight). NULL (not just []) means never
configured, matching StaticWidgetConfig.image's None-vs-empty
convention for "not configured yet"."""
__tablename__ = "text_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
content: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Base point size for the whole block -- render() shrinks this down
# (never up) to fit the widget's actual box; per-run font size isn't
# supported, only the bold/italic/underline/color/bg style flags are
# per-run (see app/text_content.py) -- keeps the wrap/shrink-to-fit
# layout in app/widgets/text.py to one size per render pass.
font_size: Mapped[int] = mapped_column(Integer, default=28)
# A key into app/widgets/text.py's FONT_FAMILIES, also whole-widget
# not per-run (see font_size above for why).
font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
class StaticWidgetConfig(Base):
"""One static-image widget's uploaded content + display settings --
unlike every other widget type, this one has no live upstream to
poll (Immich/CalDAV/WebDAV): the "source" is whatever the user last
uploaded (see routers/api_widgets.py's api_widget_static_upload,
app/image_upload.py), decoded once at upload time into plain RGB PNG
bytes so app/widgets/static_image.py's render() never re-runs
PDF/GIF decoding on every panel refresh."""
__tablename__ = "static_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
original_filename: Mapped[str] = mapped_column(String, default="")
uploaded_at: Mapped[float] = mapped_column(Float, default=0.0)
# Same DISPLAY_MODES vocabulary as PhotoWidgetConfig.display_mode,
# minus crop_faces -- no face detection for an uploaded image (see
# image_pipeline.STATIC_DISPLAY_MODES).
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
# widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup.
WIDGET_CONFIG_MODELS: dict[str, type] = {
"photos": PhotoWidgetConfig,
"calendar": CalendarWidgetConfig,
"whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig,
"static": StaticWidgetConfig,
"text": TextWidgetConfig,
}
class FrameButtonAction(Base):
"""One (widget, action) binding for one of a frame's two physical
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
"advance"}. A button can have several of these (sort_order gives
execution order); on a press, every row for that (frame, button) runs
-- see routers/device.py's frame_advance/frame_back. Deliberately
unconstrained about which widget/action pairs with which button (the
user's own idea for resolving "what does NEXT even mean with several
widgets on screen": let them assign literally anything to either
button, including mismatched combinations, rather than the server
guessing a sensible default)."""
__tablename__ = "frame_button_actions"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
button: Mapped[str] = mapped_column(String) # "next" | "back"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
action: Mapped[str] = mapped_column(String) # e.g. "advance", "back", "check_now" -- see app/widgets/
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
)
class SavedLayout(Base):
"""A named snapshot of one frame's widget arrangement (types,
placement, per-widget settings, button assignments) -- owned by a
*user*, not a frame, so it can be applied to any frame that user
controls whose grid matches (see docs/widgets.md's "Saved layouts").
cols/rows is the grid.grid_dims(orientation) the snapshot was taken
at -- an 8x5 (landscape-class) layout isn't meaningful on a 5x8
(portrait-class) frame, same reasoning as grid.py's own orientation-
change note.
Saving again with a name that already exists for this user
overwrites that layout's snapshot in place (see routers/
api_layouts.py's api_layout_save) rather than erroring or quietly
creating a second layout with the same name -- the "named save slot"
behavior people expect."""
__tablename__ = "saved_layouts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String)
cols: Mapped[int] = mapped_column(Integer)
rows: Mapped[int] = mapped_column(Integer)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
updated_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
Index("ix_saved_layouts_user_name", "user_id", "name", unique=True),
)
class SavedLayoutWidget(Base):
"""One captured widget's type/placement/settings within a
SavedLayout -- the snapshot analogue of Widget plus its per-type
config row, minus anything that's runtime/cache state rather than an
authored setting (a photo widget's current queue position, a
calendar's fetch cache, a whiteboard's rendered-image cache, etc.)
-- see api_layouts.LAYOUT_CONFIG_FIELDS for the exact per-type field
allowlist. `config` holds every JSON-safe captured setting; `image`
is only ever populated for a static-image widget's uploaded bytes
(its own BLOB column rather than folding base64 into the JSON, same
reasoning as StaticWidgetConfig.image itself)."""
__tablename__ = "saved_layout_widgets"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_id: Mapped[int] = mapped_column(ForeignKey("saved_layouts.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String)
x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer)
h: Mapped[int] = mapped_column(Integer)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
config: Mapped[dict] = mapped_column(JSON, default=dict)
image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
__table_args__ = (Index("ix_saved_layout_widgets_layout", "saved_layout_id"),)
class SavedLayoutSource(Base):
"""One included calendar/task-list source captured on a calendar or
tasks SavedLayoutWidget -- the snapshot analogue of FrameCalendar/
FrameTaskList. `kind` ("calendar" | "task") distinguishes which,
since both shapes are otherwise identical and sharing one table
avoids a near-duplicate SavedLayoutTaskSource table."""
__tablename__ = "saved_layout_sources"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_widget_id: Mapped[int] = mapped_column(
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
)
kind: Mapped[str] = mapped_column(String)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
calendar_label: Mapped[str] = mapped_column(String, default="")
included: Mapped[bool] = mapped_column(Boolean, default=True)
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (Index("ix_saved_layout_sources_widget", "saved_layout_widget_id"),)
class SavedLayoutButtonAction(Base):
"""One (button, action) binding captured for one SavedLayoutWidget --
the snapshot analogue of FrameButtonAction. References the captured
widget directly rather than a frame_id/widget_id pair (neither
exists until the layout is applied) so applying can remap it onto
whichever new Widget row that captured widget becomes -- see
routers/api_layouts.py's api_layout_apply."""
__tablename__ = "saved_layout_button_actions"
id: Mapped[int] = mapped_column(primary_key=True)
saved_layout_widget_id: Mapped[int] = mapped_column(
ForeignKey("saved_layout_widgets.id", ondelete="CASCADE")
)
button: Mapped[str] = mapped_column(String)
action: Mapped[str] = mapped_column(String)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (Index("ix_saved_layout_button_actions_widget", "saved_layout_widget_id"),)
class PendingClaim(Base):
"""A claim submitted before the frame's first check-in (the user beat
the device to the server after provisioning). Attached automatically
+27 -13
View File
@@ -85,11 +85,21 @@ def _top_up(cfg: Frame, assets: list[dict]) -> None:
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
def advance_forced(cfg: Frame, assets: list[dict], frame: Frame) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used by the explicit next-photo
action (POST /frame/advance) and by get_current() once the refresh
interval has elapsed -- always mutates cfg."""
interval has elapsed -- always mutates cfg.
`frame` is a separate reference to the owning Frame, for fields that
stay frame-level rather than moving onto a photo widget's own config
(currently just stats_photos_displayed) -- once a photo widget's
queue state lives on its own PhotoWidgetConfig row rather than
directly on Frame (see models.py), `cfg` and `frame` stop being the
same object; every existing caller today still passes the same Frame
for both, which is also why this stays a required (not optional)
param -- no implicit "guess which Frame owns this" fallback to get
wrong later."""
if cfg.current_asset_id:
# Recorded regardless of *why* this advance happened (a manual
# next-press or the timer just elapsing) -- back should be able
@@ -105,7 +115,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
# Refill back up to queue_target_len now that current_asset_id has
# changed -- otherwise the queue is left one short until the *next*
# advance, since the pop above consumes one of the items _top_up just
@@ -113,7 +123,7 @@ def advance_forced(cfg: Frame, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
def back_forced(cfg: Frame, assets: list[dict], frame: Frame) -> bool:
"""Unconditionally moves to the previously-current photo, the mirror
image of advance_forced() -- pops the most recent entry off history,
pushes the photo it's replacing onto the front of queue (so pressing
@@ -122,7 +132,7 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
since). Returns whether it actually moved -- False (history empty or
entirely stale) is a no-op, callers should still just display
whatever's current rather than treating it as an error. Used by the
back-photo button (POST /frame/back)."""
back-photo button (POST /frame/back). See advance_forced() on `frame`."""
valid_ids = {a["id"] for a in assets}
while cfg.history:
previous_id = cfg.history.pop()
@@ -132,12 +142,12 @@ def back_forced(cfg: Frame, assets: list[dict]) -> bool:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
return True
return False
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str, frame: Frame) -> bool:
"""Permanently excludes asset_id from this frame's rotation (see the
module docstring) -- doesn't touch Immich, just this frame's own
selection. Scrubs it out of queue and history too, so it can't
@@ -146,10 +156,10 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
*not* through advance_forced(), since that would record the removed
photo in history, and going back to a photo you just explicitly
removed doesn't make sense. Returns whether the current photo
changed as a result."""
changed as a result. See advance_forced() on `frame`."""
if asset_id not in cfg.excluded_asset_ids:
cfg.excluded_asset_ids.append(asset_id)
cfg.stats_photos_removed += 1
frame.stats_photos_removed += 1
cfg.queue = [a for a in cfg.queue if a != asset_id]
cfg.history = [a for a in cfg.history if a != asset_id]
@@ -167,7 +177,7 @@ def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
cfg.current_asset_id = remaining[0] if remaining else ""
cfg.current_asset_set_at = time.time()
cfg.stats_photos_displayed += 1
frame.stats_photos_displayed += 1
_top_up(cfg, assets)
return True
@@ -180,7 +190,7 @@ def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
_top_up(cfg, assets)
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bool = False) -> bool:
"""Time-based, idempotent path used by GET /frame/image. Advances only
if the current photo is unset/invalid or refresh_interval_s has
elapsed since it was set. Returns whether it changed anything, so the
@@ -190,6 +200,10 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
ahead, while a wake that lands after the interval has elapsed still
advances exactly once, even after a long time offline.
refresh_interval_s is read off `frame`, not `cfg` -- it's a device
wake-cadence setting shared by the whole panel, not something that
becomes per-widget (see advance_forced() on the cfg/frame split).
in_quiet_hours suppresses *only* the elapsed-time trigger -- an
unset/invalid current photo still gets picked regardless, since
showing nothing is worse than showing something even at 3am. This
@@ -200,9 +214,9 @@ def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) ->
window (see main.py's _effective_refresh_interval_s)."""
valid_ids = {a["id"] for a in assets}
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
time_elapsed = (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
stale = needs_pick or (time_elapsed and not in_quiet_hours)
if not stale:
return False
advance_forced(cfg, assets)
advance_forced(cfg, assets, frame)
return True
+21 -1
View File
@@ -5,7 +5,7 @@ Frame ORM model satisfy it."""
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
# Populated once from the OS's zoneinfo database (installed via the
@@ -33,6 +33,14 @@ def _zoneinfo(name: str) -> ZoneInfo:
return ZoneInfo("UTC")
def local_date(cfg) -> date:
"""`date.today()` in cfg.timezone (falls back to UTC for an
unrecognized zone, same as _zoneinfo) -- what calendar mode's "today"
anchor and browse-offset both key off of, so every part of that
feature agrees on what day it is for a given frame."""
return datetime.now(_zoneinfo(cfg.timezone)).date()
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
@@ -106,6 +114,18 @@ def in_quiet_hours(cfg) -> bool:
return in_quiet
def quiet_span_s(cfg) -> int:
"""Seconds per day quiet hours keeps the device asleep -- 0 when
disabled. Used by common.py's battery-remaining estimate to turn a
per-wake battery cost into a wall-clock duration: quiet hours cuts
how many wakes happen per day without changing what any one wake
costs, so it belongs in the wakes-per-day math, not the per-wake
rate itself."""
if not cfg.quiet_hours_enabled:
return 0
return _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end)
def max_expected_gap_s(cfg) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
+186 -234
View File
@@ -1,52 +1,40 @@
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/...
Per-widget settings (album, calendar view/inclusion, whiteboard source,
etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/
{widget_id}/... -- split out once a frame could hold more than one
widget of the same type.
Auth: session-only (require_frame_view for reads, require_frame_control
for mutations -- the "take control" soft lock). The limited manage-QR
surface lives separately under /api/m/ (routers/manage.py), and device
traffic under /frame/* (routers/device.py).
Config saves are PARTIAL updates: each page's form posts only its own
fields (the old single Settings form split across the Photos and
Configuration tabs), so every field is optional and only provided ones
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
the page JS -- an absent field means "not this form's field", never
"unchecked".
Config saves are PARTIAL updates: only provided fields are touched.
Checkboxes are sent explicitly as "true"/"false" strings by the page JS
-- an absent field means "not this form's field", never "unchecked".
"""
from __future__ import annotations
import logging
import time
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..image_pipeline import (
DEFAULT_DISPLAY_MODE,
DISPLAY_MODES,
PALETTE_LABELS,
hex_to_rgb,
render_preview_png,
)
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
fetch_source_and_faces,
immich_client_for,
immich_creds,
list_assets,
require_configured,
)
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png
logger = logging.getLogger(__name__)
@@ -54,21 +42,34 @@ router = APIRouter()
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
def _valid_repo_url(url: str) -> bool:
"""The frame will periodically fetch from this URL on its own (see
gitea_releases.py) and, with auto-update on, install whatever it
finds -- unlike a one-off manual firmware upload, that's a standing
trust relationship, so it's worth rejecting obviously-wrong input at
save time rather than only failing later at fetch time. http(s) only
-- no file://, no other schemes."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
cols x rows (see grid.grid_dims) -- landscape and portrait use a
transposed grid (8x5 vs 5x8), so an existing placement is often
literally out of bounds on the new grid, not just visually wrong.
There's no sensible coordinate remap between two differently-shaped
grids, so instead: keep whichever widget was first by placement
order, resized to fill the new full panel, and delete the rest --
cascading to their own config rows and any FrameButtonAction
bindings via ondelete="CASCADE" (see models.py). The frontend is
expected to confirm this with the user before submitting an
orientation change (see frame_config.js) -- this always executes
unconditionally once called, same posture as every other
confirm-on-the-client / act-unconditionally-on-the-server action in
this codebase."""
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
).all()
if not widgets:
return
keep, *rest = widgets
for widget in rest:
db.delete(widget)
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
@router.get("/api/frames/{frame_id}/albums")
@@ -86,11 +87,7 @@ def api_albums(frame: Frame = Depends(require_frame_view)):
@router.post("/api/frames/{frame_id}/config")
def api_config_save(
name: str | None = Form(None),
album_id: str | None = Form(None),
order: str | None = Form(None),
refresh_interval_s: int | None = Form(None),
display_mode: str | None = Form(None),
queue_target_len: int | None = Form(None),
orientation: str | None = Form(None),
quiet_hours_enabled: bool | None = Form(None),
quiet_hours_start: str | None = Form(None),
@@ -107,31 +104,35 @@ def api_config_save(
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Partial update of frame-wide settings only -- per-widget settings
(album, calendar view/inclusion, whiteboard source, etc.) live on
routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
since a frame can hold more than one widget of the same type and
"the frame's calendar settings" stopped being unambiguous the moment
that became possible. `mode` and `calendar_photo_inlay` are no
longer accepted here either: mode no longer governs anything (a
frame's widgets do), and photo inlay has no widget-system equivalent
(place an independent photo widget alongside instead). All three are
harmless no-ops if an old cached page still POSTs them -- FastAPI
silently ignores form fields with no matching parameter.
An actual orientation *change* resets the frame's widget layout (see
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
if album_id is not None and album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo
# and queue don't mean anything in the new album's context.
cfg.current_asset_id = ""
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
if order is not None:
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
if refresh_interval_s is not None:
cfg.refresh_interval_s = max(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if display_mode is not None:
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
if queue_target_len is not None:
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
if new_orientation != cfg.orientation:
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
cfg.orientation = new_orientation
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
@@ -142,7 +143,7 @@ def api_config_save(
cfg.timezone = timezone
if firmware_update_repo_url is not None:
stripped = firmware_update_repo_url.strip()
if stripped and not _valid_repo_url(stripped):
if stripped and not valid_http_url(stripped):
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
cfg.firmware_update_repo_url = stripped
if firmware_auto_update is not None:
@@ -168,6 +169,7 @@ def api_config_save(
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -200,65 +202,144 @@ def api_stats(frame: Frame = Depends(require_frame_view)):
}
@router.get("/api/frames/{frame_id}/queue")
def api_queue(
@router.get("/api/frames/{frame_id}/status")
def api_status(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Device liveness + control-lock info -- frame-level facts (battery,
last-seen, firmware, who has control), not tied to any particular
widget. Powers static/device_status_bar.js, shown on every per-frame
page regardless of which widgets that frame has. Used to piggyback on
the photo queue endpoint (back when a frame had at most one widget,
always photos-shaped); split out once that stopped being true, so the
status bar isn't blank on a frame with no photo widget."""
user = require_user_api(request, db)
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
snapshot = {
"current_asset_id": cfg.current_asset_id,
"queue": list(cfg.queue),
"last_seen": cfg.last_seen,
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
"firmware_version": cfg.device_firmware_version,
"firmware_available": cfg.firmware_available_version,
"battery_percent": cfg.battery_percent,
"battery_as_of": cfg.battery_as_of,
"battery_estimate_s": battery_estimate_s(cfg),
"controller_id": cfg.controlled_by_user_id,
"controller": (
(cfg.controlled_by.display_name or cfg.controlled_by.username)
if cfg.controlled_by
else None
),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
now = time.time()
overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
return {
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"control": {
"controller": snapshot["controller"],
"you": snapshot["controller_id"] == user.id,
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id,
},
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
),
"firmware_version": snapshot["firmware_version"] or None,
"firmware_available": snapshot["firmware_available"] or None,
"last_seen": frame.last_seen or None,
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
"firmware_version": frame.device_firmware_version or None,
"firmware_available": frame.firmware_available_version or None,
"battery": (
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
if snapshot["battery_percent"] >= 0
else None
{"percent": frame.battery_percent, "as_of": frame.battery_as_of}
if frame.battery_percent >= 0 else None
),
"battery_estimate_s": snapshot["battery_estimate_s"],
"battery_estimate_s": battery_estimate_s(frame, db),
},
}
@router.get("/api/frames/{frame_id}/preview")
def api_frame_preview(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""A small PNG of exactly what the frame is currently displaying --
the same widget compositor /frame/image uses (see routers/device.py's
render_frame_preview_png), just handed back upright and unpacked for
the dashboard header's live thumbnail instead of the device's packed
native format. Not cached: cheap enough for an on-demand header image,
and each widget's own render is already idempotent between a device's
real wakes (see photo_queue.get_current, calendar widget's browse
reset), so an extra read here doesn't skip or duplicate anything."""
png = render_frame_preview_png(db, frame, request)
return Response(content=png, media_type="image/png")
BUTTONS = ("next", "back")
@router.get("/api/frames/{frame_id}/buttons")
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""Everything the button-assignment UI needs in one call: every
widget on the frame with the actions its type supports (see
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
ordered list of (widget, action) bindings.
Includes each widget's placement (x/y/w/h) and the frame's grid
dimensions -- two widgets of the same type otherwise look identical
in the assignment UI's dropdowns (both just say "Photos"); the
client derives a position label ("top-left" etc.) from this to tell
them apart, the same way you'd tell them apart by eye on the Layout
canvas."""
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
widget_options = [
{
"id": w.id,
"widget_type": w.widget_type,
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
"actions": [
{"action": action, "label": label}
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
],
}
for w in widgets
]
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
for button in BUTTONS:
rows = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
.order_by(FrameButtonAction.sort_order)
).all()
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
return result
class ButtonActionItem(BaseModel):
widget_id: int
action: str
class ButtonActionsRequest(BaseModel):
actions: list[ButtonActionItem]
@router.put("/api/frames/{frame_id}/buttons/{button}")
def api_buttons_save(
button: str, body: ButtonActionsRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces the whole ordered action list for one button in a single
call -- simpler and more atomic than separate add/remove/reorder
endpoints for what's normally a list of one to a handful of entries,
and the UI always has the full list in hand anyway (see
static/frame_config.js)."""
if button not in BUTTONS:
raise HTTPException(404, "No such button")
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
for item in body.actions:
widget = widgets_by_id.get(item.widget_id)
if widget is None:
raise HTTPException(400, f"No such widget: {item.widget_id}")
module = WIDGET_TYPES.get(widget.widget_type)
if module is None or item.action not in module.ACTIONS:
raise HTTPException(
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
)
with frame_locked(db, frame.id):
db.execute(
delete(FrameButtonAction).where(
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
)
)
for i, item in enumerate(body.actions):
db.add(FrameButtonAction(
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
sort_order=i, created_at=time.time(),
))
db.commit()
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute(
@@ -269,135 +350,6 @@ def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = De
return {"log": [[ts, percent] for ts, percent in rows]}
class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/frames/{frame_id}/queue/reorder")
def api_queue_reorder(
body: QueueReorderRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
with frame_locked(db, frame.id) as cfg:
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/queue/promote")
def api_queue_promote(
body: QueuePromoteRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Moves a single photo to the front of the queue -- "Show next".
Unlike reorder, doesn't depend on the client knowing the queue's
exact current order, so it can't fail from staleness."""
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/queue/remove")
def api_queue_remove(
body: QueueRemoveRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Permanently removes a photo from this frame's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
return {"status": "removed"}
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
"""Scoped to what this frame is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails
for arbitrary asset ids in the owner's Immich library, only the
frame's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
try:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
def _current_asset_id(frame: Frame, db: Session) -> str:
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
picks a current photo if none is set yet, otherwise just reads it,
never advances early."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
asset_id = cfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
return asset_id
@router.get("/api/frames/{frame_id}/preview/original")
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the Configuration tab's
before/after comparison."""
asset_id = _current_asset_id(frame, db)
client = immich_client_for(frame)
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
return Response(content=jpeg_bytes, media_type="image/jpeg")
@router.get("/api/frames/{frame_id}/preview/rendered")
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same photo run through this frame's actual saved rendering
pipeline (display mode, palette, color/contrast/dithering) and
exported as a PNG -- the "how it will look on the frame" side of the
comparison. Not a live preview of unsaved slider values; reflects
whatever's currently saved."""
asset_id = _current_asset_id(frame, db)
client = immich_client_for(frame)
source, faces = fetch_source_and_faces(client, frame, asset_id)
png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=frame.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
return Response(content=png, media_type="image/png")
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
+320
View File
@@ -0,0 +1,320 @@
"""Saved layouts: a named snapshot of one frame's widget arrangement
(types, placement, per-widget settings, button assignments) that a user
can capture and later apply to any frame they control whose grid matches
-- see models.SavedLayout and docs/widgets.md's "Saved layouts" section.
Layouts are owned by a *user*, not a frame (SavedLayout.user_id), so
every endpoint here except save/apply/list (which need a frame_id to act
on) is frame-agnostic -- /api/layouts/{id} rather than /api/frames/{fid}/
layouts/{id}, gated purely on "is this your own saved layout" rather than
frame view/control.
Save/apply DO live under /api/frames/{frame_id}/... (require_frame_
control) since they read/replace one specific frame's actual widgets.
"""
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import grid
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..models import (
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
SavedLayout,
SavedLayoutButtonAction,
SavedLayoutSource,
SavedLayoutWidget,
User,
WIDGET_CONFIG_MODELS,
Widget,
)
router = APIRouter()
MAX_LAYOUT_NAME_LEN = 60
# Per-widget-type allowlist of config columns that are an actual authored
# *setting* (captured/restored by a saved layout) as opposed to runtime/
# cache state (a photo widget's current queue position, a calendar's
# fetch cache, a whiteboard's rendered-image cache, ...) which a layout
# deliberately leaves out -- applying a layout should feel like a fresh
# widget of that type with these settings pre-filled, not a resurrection
# of stale queue/cache state from whenever it was saved. "static"
# excludes `image` on purpose -- that BLOB lives on SavedLayoutWidget.image
# instead (see its own model docstring).
LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
"calendar": (
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
"week_days", "week_layout", "week_start_offset",
),
"tasks": ("name", "show_completed"),
"static": ("display_mode", "original_filename"),
"text": ("content", "font_size", "font_family", "align", "background_color"),
"whiteboard": ("user_id", "url"),
}
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
SOURCE_MODELS: dict[str, tuple[type, str]] = {
"calendar": (FrameCalendar, "calendar"),
"tasks": (FrameTaskList, "task"),
}
def _layout_summary(db: Session, layout: SavedLayout, frame: Frame | None = None) -> dict:
widget_count = db.scalar(
select(func.count()).select_from(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id)
)
result = {
"id": layout.id,
"name": layout.name,
"cols": layout.cols,
"rows": layout.rows,
"widget_count": widget_count or 0,
"created_at": layout.created_at,
"updated_at": layout.updated_at,
}
if frame is not None:
cols, rows = grid.grid_dims(frame.orientation)
result["compatible"] = (layout.cols, layout.rows) == (cols, rows)
return result
def _user_owned_layout(db: Session, layout_id: int, user: User) -> SavedLayout:
layout = db.get(SavedLayout, layout_id)
if layout is None or layout.user_id != user.id:
raise HTTPException(404, "No such saved layout")
return layout
@router.get("/api/frames/{frame_id}/layouts")
def api_layouts_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""Every saved layout owned by the calling user (saved layouts are
global to a user, not scoped to this or any other frame) -- includes
a `compatible` flag per layout for whether it can actually be applied
to *this* frame's current grid, so the UI can offer incompatible ones
greyed-out with a reason rather than hiding them outright."""
user = require_user_api(request, db)
layouts = db.scalars(
select(SavedLayout).where(SavedLayout.user_id == user.id).order_by(SavedLayout.name)
).all()
return {"layouts": [_layout_summary(db, layout, frame) for layout in layouts]}
class LayoutSaveRequest(BaseModel):
name: str
def _snapshot_widget_config(widget: Widget, config) -> dict:
fields = LAYOUT_CONFIG_FIELDS.get(widget.widget_type, ())
return {field: getattr(config, field) for field in fields}
def _snapshot_sources(db: Session, widget_id: int, model: type) -> list[dict]:
rows = db.scalars(select(model).where(model.widget_id == widget_id)).all()
return [
{
"user_id": row.user_id, "calendar_key": row.calendar_key, "calendar_label": row.calendar_label,
"included": row.included, "color_index": row.color_index,
}
for row in rows
]
@router.post("/api/frames/{frame_id}/layouts")
def api_layout_save(
body: LayoutSaveRequest, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Snapshots this frame's current widgets (placement + settings,
see LAYOUT_CONFIG_FIELDS), their calendar/task sources, and their
button-action bindings into a named layout owned by the calling
user. Saving again with a name this user already has overwrites that
layout's snapshot in place (see models.SavedLayout's docstring)
rather than erroring or creating a duplicate."""
user = require_user_api(request, db)
name = body.name.strip()
if not name:
raise HTTPException(400, "Name is required")
if len(name) > MAX_LAYOUT_NAME_LEN:
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
now = time.time()
existing = db.execute(
select(SavedLayout).where(SavedLayout.user_id == user.id, SavedLayout.name == name)
).scalar_one_or_none()
if existing is not None:
layout = existing
layout.cols, layout.rows, layout.updated_at = cols, rows, now
db.execute(delete(SavedLayoutWidget).where(SavedLayoutWidget.saved_layout_id == layout.id))
db.flush()
else:
layout = SavedLayout(user_id=user.id, name=name, cols=cols, rows=rows, created_at=now, updated_at=now)
db.add(layout)
db.flush()
snapshot_id_by_widget_id: dict[int, int] = {}
for widget in widgets:
config = db.get(WIDGET_CONFIG_MODELS[widget.widget_type], widget.id)
snapshot = SavedLayoutWidget(
saved_layout_id=layout.id, widget_type=widget.widget_type,
x=widget.x, y=widget.y, w=widget.w, h=widget.h, sort_order=widget.sort_order,
config=_snapshot_widget_config(widget, config),
image=config.image if widget.widget_type == "static" else None,
)
db.add(snapshot)
db.flush()
snapshot_id_by_widget_id[widget.id] = snapshot.id
source_model = SOURCE_MODELS.get(widget.widget_type)
if source_model is not None:
model, kind = source_model
for source in _snapshot_sources(db, widget.id, model):
db.add(SavedLayoutSource(saved_layout_widget_id=snapshot.id, kind=kind, **source))
actions = db.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
for action in actions:
snapshot_id = snapshot_id_by_widget_id.get(action.widget_id)
if snapshot_id is None:
continue
db.add(SavedLayoutButtonAction(
saved_layout_widget_id=snapshot_id, button=action.button, action=action.action,
sort_order=action.sort_order,
))
db.commit()
return _layout_summary(db, layout, frame)
class LayoutRenameRequest(BaseModel):
name: str
@router.patch("/api/layouts/{layout_id}")
def api_layout_rename(layout_id: int, body: LayoutRenameRequest, request: Request, db: Session = Depends(get_db)):
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
name = body.name.strip()
if not name:
raise HTTPException(400, "Name is required")
if len(name) > MAX_LAYOUT_NAME_LEN:
raise HTTPException(400, f"Name must be {MAX_LAYOUT_NAME_LEN} characters or fewer")
conflict = db.execute(
select(SavedLayout).where(
SavedLayout.user_id == user.id, SavedLayout.name == name, SavedLayout.id != layout.id
)
).scalar_one_or_none()
if conflict is not None:
raise HTTPException(400, "You already have a saved layout with that name")
layout.name = name
layout.updated_at = time.time()
db.commit()
return _layout_summary(db, layout)
@router.delete("/api/layouts/{layout_id}")
def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(get_db)):
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
db.delete(layout)
db.commit()
return {"status": "deleted"}
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
def api_layout_apply(
layout_id: int, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces this frame's entire widget arrangement with a saved
layout's -- every current widget (and its own config/sources/button
actions, all ondelete="CASCADE") is deleted first, same "act
unconditionally on the server, confirm on the client" posture as
api_widgets.api_widgets_clear. A source whose owning user account
(or a whiteboard's user_id) no longer exists is silently dropped
rather than left dangling -- config is JSON, not FK-checked, so
nothing enforces that at the storage layer."""
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
cols, rows = grid.grid_dims(frame.orientation)
if (layout.cols, layout.rows) != (cols, rows):
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
snapshots = db.scalars(
select(SavedLayoutWidget)
.where(SavedLayoutWidget.saved_layout_id == layout.id)
.order_by(SavedLayoutWidget.sort_order)
).all()
with frame_locked(db, frame.id):
for widget in db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all():
db.delete(widget)
db.flush()
new_widget_id_by_snapshot_id: dict[int, int] = {}
for snapshot in snapshots:
widget = Widget(
frame_id=frame.id, widget_type=snapshot.widget_type,
x=snapshot.x, y=snapshot.y, w=snapshot.w, h=snapshot.h,
sort_order=snapshot.sort_order, created_at=time.time(),
)
db.add(widget)
db.flush()
new_widget_id_by_snapshot_id[snapshot.id] = widget.id
config = WIDGET_CONFIG_MODELS[snapshot.widget_type](widget_id=widget.id)
for field, value in snapshot.config.items():
if field == "user_id" and value is not None and db.get(User, value) is None:
value = None # that WebDAV account no longer exists
setattr(config, field, value)
if snapshot.widget_type == "static" and snapshot.image is not None:
config.image = snapshot.image
config.uploaded_at = time.time()
db.add(config)
source_model = SOURCE_MODELS.get(snapshot.widget_type)
if source_model is not None:
model, kind = source_model
sources = db.scalars(
select(SavedLayoutSource).where(
SavedLayoutSource.saved_layout_widget_id == snapshot.id, SavedLayoutSource.kind == kind
)
).all()
for source in sources:
if db.get(User, source.user_id) is None:
continue # that account no longer exists
db.add(model(
widget_id=widget.id, user_id=source.user_id, calendar_key=source.calendar_key,
calendar_label=source.calendar_label, included=source.included,
color_index=source.color_index,
))
for snapshot in snapshots:
actions = db.scalars(
select(SavedLayoutButtonAction)
.where(SavedLayoutButtonAction.saved_layout_widget_id == snapshot.id)
.order_by(SavedLayoutButtonAction.sort_order)
).all()
for action in actions:
db.add(FrameButtonAction(
frame_id=frame.id, button=action.button,
widget_id=new_widget_id_by_snapshot_id[snapshot.id], action=action.action,
sort_order=action.sort_order, created_at=time.time(),
))
db.commit()
return {"status": "applied", "widget_count": len(snapshots)}
File diff suppressed because it is too large Load Diff
+578 -40
View File
@@ -5,6 +5,10 @@ from __future__ import annotations
import io
import logging
import os
import statistics
import time
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
@@ -12,18 +16,48 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..image_pipeline import render_frame
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import widget_locked
from ..image_pipeline import logical_render_size
from ..immich_client import ImmichClient
from ..models import Frame
from ..models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
TaskWidgetConfig,
User,
Widget,
WhiteboardWidgetConfig,
)
logger = logging.getLogger(__name__)
FRAME_MODES = ("photos", "calendar", "whiteboard")
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery was recharged
# How many of the most recent reports make up that baseline. A lone noisy
# reading (ADC/regulator glitch -- see firmware/main/battery.c) can still
# dip or spike a single report; comparing against just the one immediately
# previous report meant that a normal reading right after a noisy dip
# looked like a 5%+ jump and falsely registered as a recharge. Comparing
# against the max of the last few reports instead means an actual recharge
# still needs to clear all of them, while a single stray low one doesn't
# get to set the bar.
RECHARGE_LOOKBACK = 3
BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered
MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
# Modified z-score cutoff (Iglewicz & Hoaglin's standard figure) for
# _reject_outlier_drops -- see that function's docstring for why a
# single noisy reading needs rejecting at the per-wake-drop level, not
# just at the recharge-detection level.
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
@@ -51,17 +85,9 @@ def immich_client_for(frame: Frame) -> ImmichClient:
return ImmichClient(url, key)
def require_configured(frame: Frame) -> None:
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
if not frame.album_id:
raise HTTPException(400, "No album configured yet")
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
def list_assets(client: ImmichClient, album_id: str) -> list[dict]:
try:
assets = client.list_album_assets(frame.album_id)
assets = client.list_album_assets(album_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
if not assets:
@@ -69,18 +95,22 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
return assets
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
def fetch_source_and_faces(
client: ImmichClient, display_mode: str, asset_id: str
) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
and (only if display_mode needs it) its detected faces. Used by both
render_asset (device-facing) and the web UI's rendered-preview
endpoint (routers/api_frames.py) so they can't drift apart."""
and (only if display_mode needs it) its detected faces. Used by the
web UI's rendered-preview endpoint (routers/api_widgets.py's
api_widget_preview_rendered). Takes display_mode directly (a photos
widget's own setting, see PhotoWidgetConfig) rather than a whole
Frame -- this function only ever needed that one attribute off it."""
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
faces = None
if frame.display_mode == "crop_faces":
if display_mode == "crop_faces":
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
@@ -91,30 +121,125 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
return Image.open(io.BytesIO(jpeg_bytes)), faces
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
source, faces = fetch_source_and_faces(client, frame, asset_id)
return render_frame(source, faces=faces, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength)
def _avg_wake_interval_s(frame: Frame) -> float:
"""Average wall-clock seconds between wakes: refresh_interval_s
scaled up for however much of each day quiet hours removes from the
wake schedule entirely -- fewer wakes/day, not a cheaper wake. This
is what lets battery_estimate_s convert a per-wake drop rate into a
remaining-time estimate that reacts to both settings immediately,
rather than only after enough new history accumulates under them."""
active_day_s = max(1, 86400 - quiet_hours.quiet_span_s(frame))
interval_s = max(1, frame.refresh_interval_s)
wakes_per_day = max(1, active_day_s // interval_s)
return 86400 / wakes_per_day
def battery_estimate_s(frame: Frame) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
about (too little time observed, or too little drop -- a flat line
extrapolates to garbage)."""
hist = frame.battery_history
if len(hist) < 2:
def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, float]]:
"""Drops (weight, drop_pct) pairs whose drop is a wild outlier
relative to the rest of the recent steps. A single noisy ADC/
regulator glitch (see firmware/main/battery.c) corrupts one of the
two steps around it, whichever way it reads: a glitch that dips low
then recovers makes the step INTO it a spurious huge drop (the step
back out is an increase, already excluded above as a "recharge");
one that spikes high then settles makes the step OUT OF it the
spurious one instead (the step into it is the excluded "recharge").
Either way, one bad reading survives the recharge filter looking
like an ordinary, legitimately huge drop and swings the whole
remaining-time estimate on its own.
Uses a MAD-based modified z-score (robust to a small number of
extreme values in a way a plain mean/stdev z-score isn't -- a single
huge outlier inflates the stdev itself, which just hides the outlier
from a stdev-based test) rather than a fixed percent-point cutoff, so
it adapts to how noisy a given frame's own sensor actually is
instead of guessing one global threshold for every install."""
drops = [drop for _, drop in steps]
median = statistics.median(drops)
abs_devs = [abs(d - median) for d in drops]
mad = statistics.median(abs_devs)
if mad == 0:
# The standard median-based MAD degenerates to exactly 0 as soon
# as more than half the steps share the median exactly -- and
# real battery data is small integer percents, so "most wakes
# cost exactly 1%" ties are the norm, not an edge case. That's
# precisely the shape a single spliced-in glitch among a steady
# discharge rate has (18 steps at "1", one at "26"), so treating
# MAD==0 as "no spread, nothing to reject" would let exactly the
# outlier this function exists for sail straight through. Fall
# back to mean absolute deviation instead, which only reaches 0
# when every single step is identical.
mad = statistics.mean(abs_devs)
if mad == 0:
return steps # every step really is identical -- nothing to reject
kept = [
(weight, drop) for weight, drop in steps
if abs(0.6745 * (drop - median) / mad) <= OUTLIER_MODIFIED_Z_THRESHOLD
]
return kept or steps # never filter down to nothing
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
"""Remaining-time estimate from a recency-weighted average of the
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
rows of the permanent battery_log table -- not just the current
discharge cycle's battery_history, which resets to empty on every
recharge and so often doesn't hold enough signal on its own even
though the frame has plenty of history overall.
Consecutive reports are assumed to be consecutive wakes (firmware
reports battery on every wake while on battery), so each step's
(prev_percent - next_percent) is that wake's cost. A step where
percent went *up* is a recharge, not negative drain, and is skipped
entirely rather than folded in as a weird outlier; a flat step
(0% change) still counts as a real, cheap wake -- excluding those
would systematically overstate the per-wake cost by only counting
the wakes that happened to tick the percentage down. The remaining
steps then get one more pass, _reject_outlier_drops, to catch the
single-noisy-reading case that "percent went up" alone can't (see
that function's docstring). Steps are weighted linearly by recency
(step i of n gets weight i, 1-indexed) so a recent change in usage
pattern shows up quickly instead of being washed out by a long flat
history.
The resulting %/wake rate is then converted to wall-clock time using
the frame's *current* refresh_interval_s and quiet-hours settings
(see _avg_wake_interval_s), not whatever cadence produced the
historical data -- so halving refresh_interval_s roughly halves the
estimate immediately (not exactly halves: quiet hours removes a
fixed wake-free window from every day regardless of interval, which
is the "other things going on" that keeps the scaling sublinear)."""
if frame.battery_percent < 0:
return None
first_ts, first_pct = hist[0]
last_ts, last_pct = hist[-1]
span = last_ts - first_ts
drop = first_pct - last_pct
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
rows = db.execute(
select(BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
.order_by(BatteryLog.ts.desc())
.limit(BATTERY_ESTIMATE_SAMPLE_COUNT)
).scalars().all()
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
return None
rate = drop / span # percent per second
return int(last_pct / rate)
percents = list(reversed(rows)) # chronological order
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
for i in range(1, len(percents)):
prev_pct, next_pct = percents[i - 1], percents[i]
if next_pct > prev_pct:
continue # recharge (or a swap) -- not a discharge sample
steps.append((i, prev_pct - next_pct)) # later steps (larger i) weigh more
if len(steps) < MIN_ESTIMATE_SAMPLES:
return None
steps = _reject_outlier_drops(steps)
weight_total = sum(weight for weight, _ in steps)
if weight_total <= 0:
return None
avg_drop_per_wake = sum(weight * drop for weight, drop in steps) / weight_total
if avg_drop_per_wake <= 0:
return None # flat -- no honest rate to extrapolate
remaining_wakes = frame.battery_percent / avg_drop_per_wake
return int(remaining_wakes * _avg_wake_interval_s(frame))
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
@@ -143,3 +268,416 @@ def shell_context(request, db: Session, user, active_frame: Frame | None = None,
"active_frame": active_frame,
"active_nav": active_nav,
}
def valid_http_url(url: str) -> bool:
"""http(s)-only URL check -- generalized from what was api_frames.py's
frame-specific _valid_repo_url, now shared by two call sites (the
Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
# --- Location/date-taken text for the manage overlay (see build_manage_content) ---
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
def widget_of_type(db: Session, frame: Frame, widget_type: str) -> Widget | None:
"""The frame's first widget of this type, by placement order. Until
the placement UI (a later phase) ships, every frame has at most one
widget per type -- the auto-migrated default -- so callers needing
"the photo widget" / "the calendar widget" / "the whiteboard widget"
for what's still effectively a single-widget-per-type frame use this
rather than querying Widget directly. None if the frame has no widget
of this type."""
return db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == widget_type)
.order_by(Widget.sort_order)
).first()
def photo_widget_config_or_404(db: Session, frame: Frame) -> tuple[Widget, PhotoWidgetConfig]:
"""The frame's photo widget + its config, or a 400 if Immich creds or
an album aren't set up yet. Immich creds are frame/owner-level, but
album_id lives on PhotoWidgetConfig. Shared by api_frames.py and
manage.py, whose photo-related endpoints both need exactly this."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
widget = widget_of_type(db, frame, "photos")
cfg = db.get(PhotoWidgetConfig, widget.id) if widget else None
if widget is None or not cfg.album_id:
raise HTTPException(400, "No album configured yet")
return widget, cfg
def photo_widgets_for_frame(db: Session, frame: Frame) -> list[Widget]:
return db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
.order_by(Widget.sort_order)
).all()
def _primary_photo_widget(db: Session, frame: Frame, photo_widgets: list[Widget]) -> Widget | None:
"""The one photo widget the manage overlay's location/date/share-link
boxes show info for -- unlike face labels (which generalize to every
photo widget on screen, see build_manage_content), there's only one
of each of these fixed panel corners to go around, so with more than
one photo widget some single one has to be picked. Resolution rule:
whichever photo widget the NEXT button's first assigned action
targets, falling back to the first photo widget by placement order
if none is button-assigned."""
if not photo_widgets:
return None
next_actions = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == "next")
.order_by(FrameButtonAction.sort_order)
).all()
photo_widget_ids = {w.id for w in photo_widgets}
for action in next_actions:
if action.widget_id in photo_widget_ids:
return next(w for w in photo_widgets if w.id == action.widget_id)
return photo_widgets[0]
def build_manage_content(db: Session, frame: Frame, request) -> dict:
"""Gathers everything manage_overlay.compose() needs -- what used to
be two separate device-facing endpoints (/frame/photo-info,
/frame/face-labels, both removed -- see the module docstring in
manage_overlay.py) are now just internal calls made here, once,
server-side, since compositing itself also moved server-side.
management_url and battery_percent always apply. location/date/
share-URL come from one "primary" photo widget (see
_primary_photo_widget -- there's only one of each of those fixed
panel corners, so with more than one photo widget on screen some
single one has to be picked); face labels generalize more simply,
since manage_overlay.compose() already takes a flat list and draws
each one independently -- every photo widget's own named faces get
concatenated in, each positioned within that widget's own region
(see face_labels.compute_face_labels' region param) rather than as
if a photo filled the whole panel."""
base = str(request.base_url).rstrip("/")
content: dict = {
"management_url": f"{base}/m/{frame.manage_token}",
"battery_percent": frame.battery_percent,
}
photo_widgets = photo_widgets_for_frame(db, frame)
if not photo_widgets:
return content
primary = _primary_photo_widget(db, frame, photo_widgets)
primary_cfg = db.get(PhotoWidgetConfig, primary.id) if primary else None
if primary_cfg and primary_cfg.current_asset_id:
client = immich_client_for(frame)
try:
asset = client.get_asset(primary_cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning(
"Could not fetch manage-overlay photo info for asset %s: %s", primary_cfg.current_asset_id, e
)
else:
exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif)
# Unlike location/date-taken (a single fixed corner, so tied to the
# one "primary" widget above), the share link covers every photo
# widget's current photo (see manage.manage_share) -- so it only
# needs *some* photo widget to have a current photo, not specifically
# the primary one, and doesn't depend on the EXIF fetch above
# succeeding.
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
panel_w, panel_h = logical_render_size(frame.orientation)
face_labels: list[dict] = []
for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.current_asset_id:
continue
client = immich_client_for(frame)
try:
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
faces = client.get_asset_faces(cfg.current_asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch manage-overlay face info for asset %s: %s", cfg.current_asset_id, e)
continue
if not any((face.get("person") or {}).get("name") for face in faces):
continue # no Immich-identified person on this widget's current photo -- nothing to label
from ..face_labels import compute_face_labels
region = grid.cell_to_pixels(frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h))
face_labels.extend(compute_face_labels(preview_bytes, faces, cfg.display_mode, frame.orientation,
region=region))
if face_labels:
content["face_labels"] = face_labels
return content
def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]:
"""Every calendar included on this calendar widget (FrameCalendar.
included) -- the exact set calendar_feed.merge_events needs. A
calendar_key of "ics" resolves against its owner's calendar_ics_url;
"caldav:<href>" resolves against the href itself, authenticated with
the owner's CalDAV account credentials (see caldav_client.py)."""
rows = db.execute(
select(FrameCalendar, User)
.join(User, User.id == FrameCalendar.user_id)
.where(FrameCalendar.widget_id == widget.id, FrameCalendar.included == True) # noqa: E712
).all()
sources = []
for fc, u in rows:
name = u.display_name or u.username
if fc.calendar_key == "ics":
if u.calendar_ics_url:
sources.append(calendar_feed.CalendarSource(
name, "ics", u.calendar_ics_url, color_index=fc.color_index
))
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
href = fc.calendar_key[len("caldav:"):]
sources.append(calendar_feed.CalendarSource(
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password,
color_index=fc.color_index,
))
return sources
def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget: Widget) -> tuple[list[dict], str]:
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
-- same shape as the Gitea release-check throttle in api_frames.py's
api_firmware_check -- reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py, which this backs). One shared cache for the
whole merged result (every included user's events together), not
per-user -- ICS feeds are small and this refetches at most every ~20
minutes regardless of how many are included, so per-user cache
columns would add bookkeeping for a marginal benefit."""
cfg = db.get(CalendarWidgetConfig, widget.id)
now = time.time()
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_events, cfg.fetch_summary
sources = calendar_sources_for_widget(db, widget)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
)
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached_events = events
locked_cfg.fetch_summary = summary
locked_cfg.checked_at = now
return events, summary
def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Throttled per-city forecast cache (weather.CHECK_INTERVAL_S, much
longer than calendar_feed's -- weather doesn't need to be that
fresh), reading/writing CalendarWidgetConfig (see
app/widgets/calendar.py). [] if weather's off or no cities are
configured. A city whose refetch fails keeps its last-known days
rather than going blank for one bad cycle -- calendar_render.py
would otherwise show a real city as having no forecast at all just
because one refresh hit a network hiccup."""
cfg = db.get(CalendarWidgetConfig, widget.id)
if not cfg.weather_enabled or not cfg.weather_cities:
return []
now = time.time()
if cfg.weather_cached is not None and now - cfg.weather_checked_at < weather.CHECK_INTERVAL_S:
return cfg.weather_cached
previous_days = {c["label"]: c.get("days", {}) for c in (cfg.weather_cached or [])}
result = []
for city in cfg.weather_cities:
try:
days = weather.fetch_daily_forecast(city["latitude"], city["longitude"], cfg.weather_units)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
days = previous_days.get(city["label"], {})
result.append({"label": city["label"], "days": days})
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.weather_cached = result
locked_cfg.weather_checked_at = now
return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
def task_sources_for_widget(db: Session, widget: Widget) -> list[caldav_client.TaskSource]:
"""Every task list included on this tasks widget (FrameTaskList.
included) -- the exact set caldav_client.merge_tasks needs. CalDAV
only (calendar_key is always "caldav:<href>" -- no "ics" variant, a
plain ICS subscription has no VTODO collection), resolved against
the owning user's CalDAV account credentials."""
rows = db.execute(
select(FrameTaskList, User)
.join(User, User.id == FrameTaskList.user_id)
.where(FrameTaskList.widget_id == widget.id, FrameTaskList.included == True) # noqa: E712
).all()
sources = []
for ftl, u in rows:
if not ftl.calendar_key.startswith("caldav:") or not u.calendar_caldav_username:
continue
href = ftl.calendar_key[len("caldav:"):]
sources.append(caldav_client.TaskSource(
u.display_name or u.username, href, u.calendar_caldav_username, u.calendar_caldav_password,
color_index=ftl.color_index,
))
return sources
def get_or_refresh_tasks_for_widget(db: Session, frame: Frame, widget: Widget) -> list[dict]:
"""Throttled multi-list merge-fetch cache (calendar_feed.
CHECK_INTERVAL_S, same cadence as event merging), reading/writing
TaskWidgetConfig (see app/widgets/tasks.py). [] if no list is
included yet. Same posture as get_or_refresh_calendar_events_for_
widget (which this otherwise mirrors closely), not weather's own
per-city stale-cache fallback: a broken list just contributes
nothing to this cycle's merge (logged in fetch_summary) rather than
silently keeping its last-known tasks around."""
cfg = db.get(TaskWidgetConfig, widget.id)
now = time.time()
if cfg.cached is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached
sources = task_sources_for_widget(db, widget)
if not sources:
return []
completed_since = datetime.now(timezone.utc) - timedelta(hours=TASKS_COMPLETED_WINDOW_HOURS) \
if cfg.show_completed else None
tasks, summary = caldav_client.merge_tasks(sources, completed_since=completed_since)
if summary:
logger.warning("Could not refresh tasks for widget %d: %s", widget.id, summary)
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = tasks
locked_cfg.checked_at = now
return tasks
def webdav_creds_for(user: User) -> tuple[str, str] | None:
"""(username, password) for `user`'s WebDAV access -- their own
dedicated webdav_username/password, or (if they opted in)
calendar_caldav_username/password reused from their CalDAV account
(see models.py's User docstring on webdav_reuse_caldav_creds). None
if neither is actually set up."""
if user.webdav_reuse_caldav_creds:
if user.calendar_caldav_username:
return user.calendar_caldav_username, user.calendar_caldav_password
return None
if user.webdav_username:
return user.webdav_username, user.webdav_password
return None
def get_or_refresh_whiteboard_for_widget(
db: Session, frame: Frame, widget: Widget, force: bool = False
) -> bytes | None:
"""Throttled render cache (calendar_feed.CHECK_INTERVAL_S), reading/
writing WhiteboardWidgetConfig (see app/widgets/whiteboard.py) --
None if no whiteboard source is configured, credentials are missing
(e.g. the owning user unlinked their WebDAV/CalDAV account), or the
most recent fetch/render failed and nothing was ever cached yet. A
failure after a previous success keeps showing the last good render
rather than going blank for one bad refresh cycle, same reasoning as
get_or_refresh_weather_for_widget/get_or_refresh_tasks_for_widget.
force=True (the web UI's "Refresh now" button) skips the throttle
entirely -- unlike a device's normal wake, a person clicking a
button means do it right now, not eventually once the cache goes
stale."""
cfg = db.get(WhiteboardWidgetConfig, widget.id)
if not cfg.url or not cfg.user_id:
return None
now = time.time()
if not force and cfg.cached_image is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_image
user = db.get(User, cfg.user_id)
creds = webdav_creds_for(user) if user else None
if creds is None:
return cfg.cached_image
try:
png = whiteboard.fetch_and_render(cfg.url, creds[0], creds[1])
except whiteboard.WhiteboardRenderError as e:
logger.warning("Could not refresh whiteboard for widget %d: %s", widget.id, e)
return cfg.cached_image
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached_image = png
locked_cfg.checked_at = now
return png
+165 -259
View File
@@ -2,37 +2,38 @@
into deployed firmware -- so multi-frame support changes only how the
calling frame is resolved (see auth.require_device), never the paths or
response key names the deployed flat parser depends on
("refresh_interval_s", "firmware_version")."""
("refresh_interval_s", "firmware_version").
manage=1 is the one addition: appended by firmware's manage button to
whichever of these three GET/POST requests it was already about to make
(see firmware/main/frame_client.c's fetch_and_display -- it no longer
does its own overlay fetching/compositing, that's all server-side now,
see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations
import logging
import time
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import mail, photo_queue, quiet_hours
from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..face_labels import compute_face_labels
from ..firmware import firmware_path
from ..image_pipeline import render_placeholder
from ..models import BatteryLog, Frame
from ..image_pipeline import logical_render_size, render_panel, render_placeholder
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
immich_client_for,
immich_creds,
list_assets,
render_asset,
require_configured,
RECHARGE_LOOKBACK,
build_manage_content,
)
logger = logging.getLogger(__name__)
@@ -40,12 +41,13 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
built from the request's own base URL: whatever address the device
reached us at is by definition an address that works on this
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
as_png: bool = False) -> bytes:
"""What an unclaimed or widget-less frame displays instead of real
content -- instructions with a QR, rendered at 200 so the device
treats it as a perfectly normal image and never error-loops. The
URLs are built from the request's own base URL: whatever address the
device reached us at is by definition an address that works on this
network."""
base = str(request.base_url).rstrip("/")
if frame.owner_user_id is None and frame.device_id:
@@ -55,44 +57,124 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
qr_url=claim_url,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
["Almost there!", "Add a widget for this frame at", base],
qr_url=base,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
)
def _frame_configured(frame: Frame) -> bool:
url, key = immich_creds(frame)
return bool(url and key and frame.album_id)
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
as_png: bool = False) -> bytes:
"""The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math) and
hands the results to image_pipeline.render_panel for the single
shared paste/enhance/overlay/quantize/pack pass. Replaces the old
per-mode RENDERERS dict -- a frame can now show several widgets at
once instead of exactly one mode owning the whole panel."""
all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
panel_w, panel_h = logical_render_size(frame.orientation)
regions = []
for widget in all_widgets:
module = WIDGET_TYPES.get(widget.widget_type)
if module is None:
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
px, py, pw, ph = grid.cell_to_pixels(
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
)
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
regions.append(((px, py, pw, ph), img))
return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
)
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
# /frame/image looks up the frame's mode here. Only photos exists today.
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request)
client = immich_client_for(frame)
assets = list_assets(client, frame)
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
is_normal_wake: bool, as_png: bool = False) -> bytes:
"""The top-level "what does this frame show right now" entry point.
An unclaimed frame or one with no widgets yet gets the setup
placeholder (needs `request` for its QR URLs -- only available on the
normal-wake path where a real request is on hand, never on an
advance/back button press); otherwise every widget on it gets
composited via _render_widgets. Individual widgets that are
themselves unconfigured show their own small placeholder within
their own region (see app/widgets/*.py) rather than blanking the
whole panel -- a partially-set-up multi-widget frame still shows
whatever IS configured."""
has_widgets = frame.owner_user_id is not None and (
db.scalars(select(Widget.id).where(Widget.frame_id == frame.id).limit(1)).first() is not None
)
if not has_widgets:
if request is None:
return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
manage=manage, as_png=as_png,
)
return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id)
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
RENDERERS = {
"photos": _render_photos_mode,
}
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
"""The web UI's live "how it's displaying" thumbnail (see
routers/api_frames.py's /preview endpoint) -- same compositor
/frame/image uses, just handed back as a small upright PNG instead of
packed native-panel bytes. Exported from here (rather than
duplicated) since this module already owns the full widget-
compositing pipeline; nothing about the /frame/* paths themselves
changes."""
return _render_frame_content(db, frame, request, manage=None, is_normal_wake=True, as_png=True)
def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
"""Executes every (widget, action) binding assigned to this physical
button, in order -- see models.FrameButtonAction and the button-
assignment UI (a later phase). Each action runs to completion (its
own widget_locked span) before the next one starts -- never nested,
since db.widget_locked's underlying lock isn't reentrant (see its own
docstring) -- a button assigned several actions would deadlock
instantly if this looped any other way. One action failing
unexpectedly doesn't block the others, or the eventual re-render,
from happening -- the user pressed a physical button and expects
*something* to happen even if one of several assigned widgets is
having a bad moment."""
actions = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
.order_by(FrameButtonAction.sort_order)
).all()
for action_row in actions:
widget = db.get(Widget, action_row.widget_id)
if widget is None:
continue
module = WIDGET_TYPES.get(widget.widget_type)
action_fn = module.ACTIONS.get(action_row.action) if module else None
if action_fn is None:
continue
try:
action_fn(db, frame, widget)
except Exception:
logger.exception(
"Button action %r failed for widget %d (frame %d)", action_row.action, widget.id, frame.id
)
@router.get("/frame/config")
@@ -130,54 +212,55 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
return response
def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1"
@router.get("/frame/image")
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
):
"""Returns the frame's current image. For photos mode: idempotent --
only actually advances to the next photo once refresh_interval_s has
elapsed since the current one was set (see app/photo_queue.py) --
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
"""Returns the frame's current image -- every widget on the frame
composited into one panel (see _render_widgets). Each widget's own
render is idempotent in whatever way makes sense for its type (e.g.
a photo widget only actually advances once its own refresh interval
has elapsed, see app/photo_queue.py) -- safe to call as often as the
device wants, including after an unplanned reboot, without skipping
ahead. An unclaimed frame or one with no widgets yet gets a rendered
instruction placeholder (200, not an error) so a fresh device never
error-loops.
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
This is also the "normal wake" that resets any calendar widget's
browse position back to today (see app/widgets/calendar.py)."""
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by
the device's next-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate move forward on whatever widget(s) the NEXT
button is assigned to (see models.FrameButtonAction) -- e.g. the next
photo for a photo widget, or the next day/week/month for a calendar
widget -- then re-renders and returns the whole panel. Used by the
device's next-photo button."""
_run_button_actions(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/advance, for whatever widget(s) the BACK
button is assigned to. A no-op (still 200, unchanged) for any widget
with nothing to go back to. Used by the device's back-photo button."""
_run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
class BatteryReport(BaseModel):
@@ -202,7 +285,12 @@ def frame_battery(
alert_frame_name = ""
with frame_locked(db, frame.id) as locked:
locked.stats_battery_reports += 1
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
# See RECHARGE_LOOKBACK: compared against the max of the last few
# reports, not just the single previous one, so a lone noisy dip
# can't make the next normal reading look like a recharge.
recent = locked.battery_history[-RECHARGE_LOOKBACK:]
recent_max = max((pct for _, pct in recent), default=None)
if recent_max is not None and body.percent >= recent_max + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge -- and let a
@@ -264,185 +352,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
if not path.exists():
raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream")
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@router.get("/frame/photo-info")
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
location = _format_location(exif)
return {
"asset_id": asset_id,
"location_line1": location[0] if location else None,
"location_line2": location[1] if location and location[1] else None,
"taken_at": _format_taken_at(exif),
# Last value this frame itself reported (see /frame/battery) --
# not a fresh reading. Good enough for a glance on the manage
# overlay, and lets the device skip a synchronous ADC read (which
# would otherwise need to happen before the overlay is composited,
# i.e. before the photo it's part of is even pushed to the panel)
# just to render this.
"battery_percent": frame.battery_percent,
}
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on THIS frame --
not any arbitrary Immich asset id -- as a second layer even a leaked
token wouldn't bypass."""
require_configured(frame)
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@router.get("/frame/face-labels")
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
recognition (no detection/recognition happens here, see
app/face_labels.py). Response is a flattened, fixed-slot shape
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
hand-rolled parser can read it with the same flat-scalar helpers it
already has. Empty (count: 0) if no faces are named, or if anything
about fetching them fails -- this is a "nice to have" addition to
the overlay, not worth failing the whole menu over."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
display_mode = locked.display_mode
orientation = locked.orientation
if not asset_id:
return {"count": 0}
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
return {"count": 0}
if not any((face.get("person") or {}).get("name") for face in faces):
return {"count": 0} # skip the extra preview download in the common no-named-faces case
try:
preview_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
result[f"name_{i}"] = label["name"]
result[f"x_{i}"] = label["x"]
result[f"y_{i}"] = label["y"]
return result
+218 -10
View File
@@ -1,26 +1,49 @@
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
Stats tabs, all inside the sidebar app shell. Data loading happens
client-side against /api/frames/{id}/... (routers/api_frames.py); these
routes just authorize and render the scaffold."""
"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
canvas), Configuration, and Stats, all inside the sidebar app shell.
Each widget's own settings (album, calendar view/inclusion, whiteboard
source, etc.) no longer have their own tab/page -- they're a dialog
opened from a gear icon on the widget's box in the Layout canvas (see
static/frame_layout.js), whose content this module also serves (the
/widgets/{widget_id}/dialog route) as a small HTML fragment, not a full
page. Data loading otherwise happens client-side against
/api/frames/{id}/... (routers/api_frames.py, routers/api_widgets.py);
these routes just authorize and render the scaffold."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..image_pipeline import (
DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
palette_to_hex,
)
from ..models import Frame
from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
User,
UserFrame,
WhiteboardWidgetConfig,
Widget,
)
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
from ..widgets import text as text_widget
from .common import shell_context, widget_of_type
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@@ -39,22 +62,207 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
frame = db.get(Frame, frame_id)
photo_widget_id = None
if frame is not None:
photo_widget = widget_of_type(db, frame, "photos")
if photo_widget is not None:
photo_widget_id = photo_widget.id
return _frame_page(
request, db, frame_id, "frame_config.html", "config",
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
display_mode_labels=DISPLAY_MODE_LABELS,
photo_widget_id=photo_widget_id,
)
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
# --- Per-widget config dialog content -------------------------------------
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any widget:
the single ICS subscription (if set) plus every CalDAV calendar last
discovered from Settings' "Discover calendars" button. Doesn't hit
the network -- reads the cached list a user refreshes themselves."""
calendars = []
if user.calendar_ics_url:
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
for c in (user.calendar_caldav_calendars or []):
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
return calendars
def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user calendar list for the calendar dialog's "Included
calendars" section. The viewer's own row lists EVERY calendar they
have available, each with a full add/remove toggle; every other
linked user's row lists ONLY the calendars they've already included
(mute-only for the viewer -- see api_widgets.py's
api_widget_calendar_select: only a calendar's owner may turn it on,
but anyone linked to the frame may turn one off)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameCalendar]] = {}
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(fc.user_id, []).append(fc)
result = []
for u in users:
is_self = u.id == viewer_id
if is_self:
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
calendars = [
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in _user_available_calendars(u)
]
else:
calendars = [
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
for fc in included_by_user.get(u.id, []) if fc.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "calendars": calendars,
})
return result
def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user task-list list for the tasks dialog's "Included
task lists" section -- same shape as _calendar_users_for_widget,
restricted to CalDAV calendars only (no "ics" option: a plain ICS
subscription has no VTODO collection to speak of, see
caldav_client.fetch_tasks)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameTaskList]] = {}
for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(ftl.user_id, []).append(ftl)
result = []
for u in users:
is_self = u.id == viewer_id
available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")]
if is_self:
own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])}
task_lists = [
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in available
]
else:
task_lists = [
{"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True}
for ftl in included_by_user.get(u.id, []) if ftl.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "task_lists": task_lists,
})
return result
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
"""Whose account this widget currently fetches with, for showing
"using <name>'s account" to everyone linked, not just whoever set
it. None if no source is configured."""
if not whiteboard_cfg.user_id or not whiteboard_cfg.url:
return None
user = db.get(User, whiteboard_cfg.user_id)
if user is None:
return None
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
4: "Friday", 5: "Saturday", 6: "Sunday"}
@router.get("/frames/{frame_id}/widgets/{widget_id}/dialog", response_class=HTMLResponse)
def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = Depends(get_db)):
"""The gear-icon dialog's content, dispatched by widget_type -- a
small HTML fragment (no app_base shell/tabs), fetched and injected
into a <dialog> by static/frame_layout.js. Not itself a page a user
would navigate to directly."""
user = current_user(request, db)
if user is None:
raise HTTPException(401, "Not logged in")
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
"display_mode_labels": DISPLAY_MODE_LABELS,
})
if widget.widget_type == "calendar":
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_calendar.html", {
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
"calendar_views": CALENDAR_VIEW_LABELS,
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
})
if widget.widget_type == "tasks":
task_cfg = db.get(TaskWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_tasks.html", {
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
})
if widget.widget_type == "static":
static_cfg = db.get(StaticWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_static.html", {
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
})
if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
"text_font_families": text_widget.FONT_FAMILIES,
})
if widget.widget_type == "whiteboard":
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
viewer_has_webdav_creds = bool(
user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username)
)
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+65 -22
View File
@@ -11,16 +11,22 @@ import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import frame_locked, get_db
from ..models import Frame
from .common import immich_client_for, list_assets, require_configured
from ..db import get_db, widget_locked
from ..models import Frame, PhotoWidgetConfig
from .common import (
immich_client_for,
immich_creds,
list_assets,
photo_widget_config_or_404,
photo_widgets_for_frame,
)
logger = logging.getLogger(__name__)
@@ -46,15 +52,16 @@ def manage_page(manage_token: str, request: Request, db: Session = Depends(get_d
@router.get("/api/m/{manage_token}/queue")
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
assets = list_assets(client, pcfg.album_id)
with frame_locked(db, frame.id) as cfg:
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
photo_queue.sync_queue_length(cfg, assets)
current = cfg.current_asset_id
queue = list(cfg.queue)
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(locked_pcfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
photo_queue.sync_queue_length(locked_pcfg, assets)
current = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
@@ -76,7 +83,8 @@ def manage_promote(
frame: Frame = Depends(require_manage),
db: Session = Depends(get_db),
):
with frame_locked(db, frame.id) as cfg:
photo_widget, _ = photo_widget_config_or_404(db, frame)
with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
@@ -87,29 +95,30 @@ def manage_promote(
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Advances the server-side current photo; the panel itself updates
on the device's next wake (or its next-photo button)."""
require_configured(frame)
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.advance_forced(cfg, assets)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.advance_forced(locked_pcfg, assets, locked_frame)
return {"status": "saved"}
@router.post("/api/m/{manage_token}/back")
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
require_configured(frame)
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as cfg:
photo_queue.back_forced(cfg, assets)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.back_forced(locked_pcfg, assets, locked_frame)
return {"status": "saved"}
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Thumbnails scoped to what this frame is actually showing/queuing --
the manage token must not become a general Immich proxy."""
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
_, pcfg = photo_widget_config_or_404(db, frame)
if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
try:
@@ -117,3 +126,37 @@ def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
@router.get("/frame/share/{manage_token}")
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Creates a 30-minute public Immich share link covering every photo
widget's currently-displayed asset on this frame, and redirects to it
-- what the manage overlay's bottom-left QR code points to. Lazily
created (only when someone actually scans it, not when the manage
button was pressed), so the 30-minute window starts at actual use.
Keyed on this frame's own manage_token, like the rest of this router,
rather than device credentials -- a phone scanning a QR code has no
way to supply the device's ?id=/?token=, which is why this used to
silently fall back to whichever frame happened to still carry the
legacy migration token instead of the frame that was actually
scanned."""
photo_widgets = photo_widgets_for_frame(db, frame)
asset_ids: list[str] = []
for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id)
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
asset_ids.append(cfg.current_asset_id)
if not asset_ids:
raise HTTPException(404, "No photos currently showing on this frame")
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_ids, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
+91 -3
View File
@@ -1,6 +1,6 @@
"""HTML page routes: first-run setup, login/logout, user settings, and
the admin panel. The frame pages themselves stay in main.py (Phase A's
single-frame index) until the Phase D restructure.
the admin panel. The per-frame pages (Photos/Configuration/Stats) live in
routers/frame_pages.py.
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
(checked explicitly) rather than the X-CSRF-Token header the JSON API
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import mail
from .. import caldav_client, mail
from ..auth import (
SESSION_COOKIE,
SESSION_LIFETIME_S,
@@ -30,11 +30,13 @@ from ..auth import (
destroy_session,
get_server_settings,
hash_password,
require_user_api,
users_exist,
verify_password,
)
from ..db import get_db
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
from .common import valid_http_url
logger = logging.getLogger(__name__)
@@ -308,6 +310,23 @@ def _render_claim(request: Request, db: Session, device_id: str, error: str | No
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
):
status, pending_yours = "claimed_yours", False
if frame.device_token_ack:
# The device's captive portal redirects here on EVERY
# (re)provisioning cycle (see wifi_provisioning.c) -- if the
# physical frame was reset/reprovisioned, it no longer has
# the access token this frame row already acknowledged, and
# auth.require_device permanently locks out an id-only
# request once device_token_ack is set (device_id alone,
# unlike the token, isn't secret -- it's shown on the
# frame's own screen/QR). Reopening that handshake window
# here is what "give it a minute to connect" below actually
# depends on: it's safe because landing on this branch
# already requires knowing the device_id (physical/local
# access to the frame) AND being logged in as an owner/
# linked user of it.
frame.device_token_ack = False
db.commit()
logger.info("Frame #%d's device token handshake reopened (re-provisioned)", frame.id)
else:
status, pending_yours = "claimed", False
return templates.TemplateResponse(
@@ -422,6 +441,14 @@ def settings_submit(
email: str = Form(""),
immich_url: str = Form(""),
immich_api_key: str = Form(""),
calendar_ics_url: str = Form(""),
calendar_caldav_url: str = Form(""),
calendar_caldav_username: str = Form(""),
calendar_caldav_password: str = Form(""),
webdav_username: str = Form(""),
webdav_password: str = Form(""),
webdav_reuse_caldav_creds: bool = Form(False),
webdav_base_url: str = Form(""),
current_password: str = Form(""),
new_password: str = Form(""),
db: Session = Depends(get_db),
@@ -441,6 +468,45 @@ def settings_submit(
if immich_api_key.strip():
user.immich_api_key = immich_api_key.strip()
# Unlike the API key, this isn't a secret -- it round-trips visibly in
# the form, so blank means an explicit clear (there needs to be some
# way to actually remove a linked calendar), not "keep existing".
stripped_ics = calendar_ics_url.strip()
if stripped_ics and not valid_http_url(stripped_ics):
error = "Calendar URL must be a plain http:// or https:// URL."
else:
user.calendar_ics_url = stripped_ics
stripped_caldav_url = calendar_caldav_url.strip()
if stripped_caldav_url and not valid_http_url(stripped_caldav_url):
error = "CalDAV URL must be a plain http:// or https:// URL."
else:
if stripped_caldav_url != user.calendar_caldav_url:
# Server (and likely account) changed -- last discovery no
# longer describes what's actually there.
user.calendar_caldav_calendars = None
user.calendar_caldav_checked_at = 0.0
user.calendar_caldav_url = stripped_caldav_url
user.calendar_caldav_username = calendar_caldav_username.strip()
# Blank password field = keep the existing one, same idiom as the
# Immich API key -- a secret that round-trips through HTML is a
# secret in every browser's autofill store.
if calendar_caldav_password.strip():
user.calendar_caldav_password = calendar_caldav_password.strip()
user.webdav_reuse_caldav_creds = webdav_reuse_caldav_creds
user.webdav_username = webdav_username.strip()
if webdav_password.strip():
user.webdav_password = webdav_password.strip()
# Not a secret -- round-trips visibly, so blank is an explicit clear,
# same convention as calendar_ics_url above.
stripped_webdav_base = webdav_base_url.strip()
if stripped_webdav_base and not valid_http_url(stripped_webdav_base):
error = "WebDAV browse root must be a plain http:// or https:// URL."
else:
user.webdav_base_url = stripped_webdav_base
if new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed."
@@ -455,6 +521,28 @@ def settings_submit(
)
@router.post("/api/settings/caldav-discover")
def api_caldav_discover(request: Request, db: Session = Depends(get_db)):
"""Lists the calendars in the CalDAV account already saved on this
user's Settings (not whatever's currently typed in the form but not
yet saved -- same idiom as /api/frames/{id}/albums using the frame's
already-saved Immich creds). Caches the result on the user row so
every frame's Calendar tab can offer it without a live round-trip."""
user = require_user_api(request, db)
if not user.calendar_caldav_url or not user.calendar_caldav_username:
raise HTTPException(400, "Save a CalDAV URL and username first")
try:
calendars = caldav_client.discover_calendars(
user.calendar_caldav_url, user.calendar_caldav_username, user.calendar_caldav_password
)
except caldav_client.CalDavError as e:
raise HTTPException(502, f"Could not discover calendars: {e}") from e
user.calendar_caldav_calendars = calendars
user.calendar_caldav_checked_at = time.time()
db.commit()
return calendars
def _require_admin_page(request: Request, db: Session) -> User:
user = current_user(request, db)
if user is None or not user.is_admin:
+14 -1
View File
@@ -58,8 +58,21 @@
}
})();
// Shared display names for widget_type, everywhere one shows up in the
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image', text: 'Text',
};
function showStatus(ok, message) {
var el = document.getElementById('result');
// While a <dialog> is open, its own .dialog-result container gets the
// message instead of the page-level #result -- otherwise it lands
// behind the dialog's backdrop, invisible until the dialog closes
// (e.g. the widget config dialogs on the Layout tab, see
// frame_layout.js). Falls back to #result for everything else.
var openDialog = document.querySelector('dialog[open]');
var el = (openDialog && openDialog.querySelector('.dialog-result')) || document.getElementById('result');
if (!el) return;
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
el.firstChild.textContent = message;
+8 -5
View File
@@ -1,7 +1,10 @@
// 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_API before this loads.
// 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;
@@ -27,9 +30,9 @@ function renderDeviceStatusBar(device) {
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 can compute a rate (needs 2h+ span and a 2%+
// drop within the current discharge cycle -- see common.py) -- so
// it's clear the number is coming, not that the feature is broken.
// 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',
@@ -51,7 +54,7 @@ function renderDeviceStatusBar(device) {
async function loadDeviceStatusBar() {
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
const resp = await fetch(`${window.FRAME_BASE_API}/status`);
if (!resp.ok) {
return;
}
+240 -8
View File
@@ -1,4 +1,9 @@
// Configuration tab: frame settings + firmware card + take control.
// 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.
@@ -6,11 +11,8 @@
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',
@@ -26,10 +28,31 @@ async function saveConfig() {
}
}
// 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);
@@ -50,7 +73,7 @@ async function takeControl() {
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
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) {
@@ -168,14 +191,22 @@ document.getElementById('palette-reset').addEventListener('click', () => {
});
// ---- 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.FRAME_API}/preview/original?_=${bust}`;
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
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}`;
}
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
const previewRefreshBtn = document.getElementById('preview-refresh');
if (previewRefreshBtn) previewRefreshBtn.addEventListener('click', loadPreview);
loadPreview();
// ---- Battery alerts card ----
@@ -331,3 +362,204 @@ loadFirmwareCheck();
// The server throttles actual Gitea API calls itself, so this poll is
// cheap either way.
setInterval(loadFirmwareCheck, 60000);
// --- Button assignments -----------------------------------------------
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
// api_buttons_get. Each button's list is edited client-side (add/
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
// add/remove endpoints for what's normally a handful of entries, and
// this file already has the full list in hand after any edit.
let buttonsData = null;
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
const BUTTONS = ['next', 'back'];
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
// frame's grid dims -- the same rough position you'd read off the
// Layout canvas by eye, used to tell apart two widgets of the same type
// that would otherwise both just say "Photos".
function widgetPositionLabel(w, grid) {
const cx = w.x + w.w / 2;
const cy = w.y + w.h / 2;
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
if (!horiz && !vert) return 'center';
if (!vert) return horiz;
if (!horiz) return vert;
return `${vert}-${horiz}`;
}
// A single widget of a given type keeps the plain type name ("Photos")
// -- the common case, no need to clutter it. Only widgets sharing a
// type with another widget on the same frame get a number + position
// suffix, numbered in reading order (top-to-bottom, left-to-right).
function buildWidgetNames(widgets, grid) {
const byType = {};
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
const names = {};
Object.values(byType).forEach((group) => {
if (group.length === 1) {
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
return;
}
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
ordered.forEach((w, i) => {
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
});
});
return names;
}
function widgetActionLabel(widgetId, action) {
const w = buttonsData.widgets.find((w) => w.id === widgetId);
if (!w) return `(deleted widget): ${action}`;
const found = w.actions.find((a) => a.action === action);
const actionLabel = found ? found.label : action;
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
}
function renderButtonList(button) {
const list = document.getElementById(`button-actions-${button}`);
const rows = buttonsData[button];
list.innerHTML = '';
if (!rows.length) {
list.innerHTML = '<li class="sub">Nothing assigned -- this button wont do anything.</li>';
return;
}
rows.forEach((row, i) => {
const li = document.createElement('li');
li.className = 'button-action-row';
const span = document.createElement('span');
span.textContent = widgetActionLabel(row.widget_id, row.action);
const controls = document.createElement('span');
controls.className = 'button-action-controls';
const up = document.createElement('button');
up.type = 'button';
up.className = 'icon-btn';
up.textContent = '↑';
up.title = 'Move up';
up.disabled = i === 0;
up.addEventListener('click', () => moveButtonAction(button, i, -1));
const down = document.createElement('button');
down.type = 'button';
down.className = 'icon-btn';
down.textContent = '↓';
down.title = 'Move down';
down.disabled = i === rows.length - 1;
down.addEventListener('click', () => moveButtonAction(button, i, 1));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.textContent = '×';
remove.title = 'Remove';
remove.addEventListener('click', () => removeButtonAction(button, i));
controls.appendChild(up);
controls.appendChild(down);
controls.appendChild(remove);
li.appendChild(span);
li.appendChild(controls);
list.appendChild(li);
});
}
function populateActionSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
actionSel.innerHTML = '';
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
if (!w) return;
w.actions.forEach((a) => {
const opt = document.createElement('option');
opt.value = a.action;
opt.textContent = a.label;
actionSel.appendChild(opt);
});
}
function populateWidgetSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
widgetSel.innerHTML = '';
buttonsData.widgets.forEach((w) => {
const opt = document.createElement('option');
opt.value = w.id;
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
widgetSel.appendChild(opt);
});
populateActionSelect(button);
}
async function saveButtonActions(button) {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button assignments saved.');
} catch (e) {
showStatus(false, e.message);
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
}
}
function moveButtonAction(button, index, delta) {
const rows = buttonsData[button];
const target = index + delta;
if (target < 0 || target >= rows.length) return;
[rows[index], rows[target]] = [rows[target], rows[index]];
renderButtonList(button);
saveButtonActions(button);
}
function removeButtonAction(button, index) {
buttonsData[button].splice(index, 1);
renderButtonList(button);
saveButtonActions(button);
}
function addButtonAction(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
if (!widgetSel.value || !actionSel.value) return;
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
renderButtonList(button);
saveButtonActions(button);
}
async function loadButtons() {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
if (!resp.ok) throw new Error(await apiError(resp));
buttonsData = await resp.json();
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
document.getElementById('button-assign-groups').style.display =
buttonsData.widgets.length ? '' : 'none';
document.getElementById('button-assign-empty-hint').style.display =
buttonsData.widgets.length ? 'none' : '';
BUTTONS.forEach((button) => {
renderButtonList(button);
populateWidgetSelect(button);
});
} catch (e) {
showStatus(false, e.message);
}
}
BUTTONS.forEach((button) => {
document.getElementById(`button-add-widget-${button}`)
.addEventListener('change', () => populateActionSelect(button));
document.getElementById(`button-add-${button}`)
.addEventListener('click', () => addButtonAction(button));
});
loadButtons();
+110
View File
@@ -0,0 +1,110 @@
// Page-header controls shared by every per-frame page (Layout/
// Configuration/Stats): the frame-name pencil-edit, living outside the
// tab structure since it applies regardless of which tab is open.
// Depends on window.FRAME_BASE_API (a stable frame-level base set by
// every page -- unlike window.FRAME_API, which the Layout page's
// widget dialogs repoint to a widget-scoped base while one is open) and
// common.js's showStatus/apiError.
(function () {
var view = document.getElementById('frame-name-view');
var editRow = document.getElementById('frame-name-edit-row');
var pencil = document.getElementById('frame-name-pencil');
var input = document.getElementById('frame-name-input');
var textEl = document.getElementById('frame-name-text');
var saveBtn = document.getElementById('frame-name-save');
var cancelBtn = document.getElementById('frame-name-cancel');
if (!view || !window.FRAME_BASE_API) return;
function openEdit() {
input.value = textEl.textContent.trim();
view.style.display = 'none';
editRow.style.display = 'inline-flex';
input.focus();
input.select();
}
function closeEdit() {
editRow.style.display = 'none';
view.style.display = 'inline-flex';
}
pencil.addEventListener('click', openEdit);
cancelBtn.addEventListener('click', closeEdit);
async function save() {
var name = input.value.trim();
if (!name || name === textEl.textContent.trim()) {
closeEdit();
return;
}
try {
const resp = await fetch(`${window.FRAME_BASE_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
textEl.textContent = name;
closeEdit();
showStatus(true, 'Renamed.');
} catch (e) {
showStatus(false, e.message);
}
}
saveBtn.addEventListener('click', save);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') save();
if (e.key === 'Escape') closeEdit();
});
})();
// Live "how it's displaying" thumbnail. A real composite render (same
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
// poll rather than something tighter like the 10s device-status poll --
// no need to hit Immich/calendar/whiteboard sources that often just for
// a header thumbnail. Click enlarges it in a dialog (which also fetches
// a fresh render); clicking the enlarged image refreshes it again.
(function () {
var thumb = document.getElementById('frame-preview-thumb');
var dialog = document.getElementById('frame-preview-dialog');
var bigImg = document.getElementById('frame-preview-dialog-img');
var closeBtn = document.getElementById('frame-preview-dialog-close');
if (!thumb || !window.FRAME_BASE_API) return;
function previewUrl() {
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
}
function refreshThumb() {
thumb.src = previewUrl();
}
// Opening the dialog (or clicking the big image inside it) fetches a
// fresh render and keeps the header thumb in sync, so this single path
// covers both "enlarge" and the old click-to-refresh behavior.
function refreshBig() {
var url = previewUrl();
bigImg.src = url;
thumb.src = url;
}
thumb.addEventListener('click', function () {
if (!dialog) { refreshThumb(); return; }
refreshBig();
dialog.showModal();
});
refreshThumb();
setInterval(refreshThumb, 60000);
if (dialog && bigImg && closeBtn) {
bigImg.addEventListener('click', refreshBig);
closeBtn.addEventListener('click', function () { dialog.close(); });
// Same backdrop-click-to-close trick as #widget-dialog: a click that
// lands on the dialog element itself (not its content box) means the
// backdrop was hit.
dialog.addEventListener('click', function (e) {
if (e.target !== dialog) return;
var rect = dialog.getBoundingClientRect();
var inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) dialog.close();
});
}
})();
+346
View File
@@ -0,0 +1,346 @@
// Layout tab: drag/resize placement canvas for arranging widgets on the
// panel, like placing widgets on an Android home screen. Pointer events
// (not native HTML5 drag-and-drop, which has known touch
// inconsistencies) drive move/resize; every mutation is re-validated
// server-side (see routers/api_widgets.py) regardless of what this file
// already checked, so after any move/resize/add/remove this just
// reloads the canvas from the server's actual state rather than trusting
// an optimistic update -- simplest way to guarantee the canvas never
// drifts from what a rejected request left in place.
//
// Widget/canvas geometry is computed and applied in *pixels* from JS,
// not CSS percentages/aspect-ratio -- aspect-ratio isn't supported on
// every mobile browser this app gets viewed from, and a percentage
// height on the widget boxes silently collapses to 0 against an
// indeterminate-height ancestor on those browsers (the canvas would
// render with no visible size at all, which is exactly what happened
// before this was pixel-based).
let gridState = null; // last-loaded GET .../widgets response
// WIDGET_LABELS comes from common.js (shared with frame_config.js's
// button-assignment UI).
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
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.');
loadWidgets();
} catch (e) {
showStatus(false, e.message);
}
}
document.getElementById('take-control').addEventListener('click', takeControl);
// Cached each time the canvas is (re)laid out (see layoutCanvas) so drag
// math doesn't re-measure the DOM on every pointermove.
let canvasMetrics = { width: 0, height: 0, cellW: 0, cellH: 0 };
function layoutCanvas() {
if (!gridState) return;
const wrap = document.getElementById('widget-canvas-wrap');
const canvas = document.getElementById('widget-canvas');
const cols = gridState.grid.cols, rows = gridState.grid.rows;
// wrap's own width comes from ordinary CSS (100% of the card, capped
// at max-width) -- only its height is JS-driven, from that measured
// width, to keep the grid's aspect ratio without relying on the CSS
// aspect-ratio property.
const width = wrap.getBoundingClientRect().width;
const height = width * (rows / cols);
wrap.style.height = height + 'px';
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const cellW = width / cols, cellH = height / rows;
canvasMetrics = { width, height, cellW, cellH };
wrap.style.backgroundImage =
`linear-gradient(to right, var(--border) 1px, transparent 1px),` +
`linear-gradient(to bottom, var(--border) 1px, transparent 1px)`;
wrap.style.backgroundSize = `${cellW}px ${cellH}px`;
for (const box of canvas.children) {
positionBox(box, box._rect);
}
}
function positionBox(box, rect) {
box._rect = rect;
const { cellW, cellH } = canvasMetrics;
box.style.left = (rect.x * cellW) + 'px';
box.style.top = (rect.y * cellH) + 'px';
box.style.width = (rect.w * cellW) + 'px';
box.style.height = (rect.h * cellH) + 'px';
}
function startDrag(e, widget, box, isResize) {
e.preventDefault();
box.setPointerCapture(e.pointerId);
const { cellW, cellH } = canvasMetrics;
const startX = e.clientX, startY = e.clientY;
const orig = { x: widget.x, y: widget.y, w: widget.w, h: widget.h };
const cols = gridState.grid.cols, rows = gridState.grid.rows;
const minFootprint = gridState.min_footprint[widget.widget_type] || [1, 1];
let pending = null;
box.classList.add('dragging');
function onMove(ev) {
const dxCells = Math.round((ev.clientX - startX) / cellW);
const dyCells = Math.round((ev.clientY - startY) / cellH);
const next = { ...orig };
if (isResize) {
next.w = Math.max(minFootprint[0], Math.min(cols - orig.x, orig.w + dxCells));
next.h = Math.max(minFootprint[1], Math.min(rows - orig.y, orig.h + dyCells));
} else {
next.x = Math.max(0, Math.min(cols - orig.w, orig.x + dxCells));
next.y = Math.max(0, Math.min(rows - orig.h, orig.y + dyCells));
}
pending = next;
positionBox(box, next);
}
function onUp() {
box.removeEventListener('pointermove', onMove);
box.removeEventListener('pointerup', onUp);
box.classList.remove('dragging');
if (pending && (pending.x !== orig.x || pending.y !== orig.y || pending.w !== orig.w || pending.h !== orig.h)) {
moveWidget(widget.id, pending);
}
}
box.addEventListener('pointermove', onMove);
box.addEventListener('pointerup', onUp);
}
async function moveWidget(id, rect) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rect),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
} finally {
// Reload either way: reverts the box to its real position if the
// move was rejected (e.g. it would've overlapped another widget),
// confirms it otherwise. Simpler and more robust than trying to
// separately handle "revert on failure" vs. "confirm on success".
loadWidgets();
}
}
async function removeWidget(id) {
if (!confirm('Remove this widget? Its own settings will be lost.')) return;
try {
const resp = await fetch(`${window.FRAME_API}/widgets/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Removed.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
async function clearAllWidgets() {
const count = gridState ? gridState.widgets.length : 0;
if (!count) return;
const noun = count === 1 ? 'widget' : 'widgets';
if (!confirm(`Remove all ${count} ${noun} from this frame and start over? Their settings will be lost.`)) return;
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
document.getElementById('clear-all-widgets').addEventListener('click', clearAllWidgets);
async function addWidget(widgetType) {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_type: widgetType }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, `${WIDGET_LABELS[widgetType] || widgetType} widget added.`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets();
}
}
function renderCanvas() {
const canvas = document.getElementById('widget-canvas');
canvas.innerHTML = '';
document.getElementById('widget-canvas-empty-hint').style.display = gridState.widgets.length ? 'none' : '';
document.getElementById('clear-all-widgets').disabled = !gridState.widgets.length;
for (const widget of gridState.widgets) {
const box = document.createElement('div');
box.className = 'widget-box';
box.dataset.widgetType = widget.widget_type;
box._rect = widget;
const label = document.createElement('span');
label.className = 'widget-box-label';
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label);
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'widget-box-settings';
settingsBtn.textContent = '⚙';
settingsBtn.title = `${WIDGET_LABELS[widget.widget_type] || widget.widget_type} settings`;
settingsBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); openWidgetDialog(widget); });
box.appendChild(settingsBtn);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'widget-box-remove';
removeBtn.textContent = '×';
removeBtn.title = 'Remove this widget';
removeBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
removeBtn.addEventListener('click', (e) => { e.stopPropagation(); removeWidget(widget.id); });
box.appendChild(removeBtn);
const handle = document.createElement('div');
handle.className = 'widget-box-resize-handle';
handle.addEventListener('pointerdown', (e) => { e.stopPropagation(); startDrag(e, widget, box, true); });
box.appendChild(handle);
box.addEventListener('pointerdown', (e) => startDrag(e, widget, box, false));
canvas.appendChild(box);
}
layoutCanvas();
}
function renderAddButtons() {
const container = document.getElementById('add-widget-buttons');
container.innerHTML = '';
for (const type of gridState.widget_types) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary';
btn.textContent = `+ ${WIDGET_LABELS[type] || type}`;
btn.addEventListener('click', () => addWidget(type));
container.appendChild(btn);
}
const hint = document.getElementById('add-widget-hint');
hint.textContent = 'A new widget is placed in the first open space that fits it -- drag it afterward to reposition.';
}
async function loadWidgets() {
try {
const resp = await fetch(`${window.FRAME_API}/widgets`);
if (!resp.ok) throw new Error(await apiError(resp));
gridState = await resp.json();
renderCanvas();
renderAddButtons();
renderControlBanner(gridState.control);
} catch (e) {
showStatus(false, e.message);
}
}
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(layoutCanvas, 100);
});
// --- gear-icon dialog: each widget's own settings, fetched as an HTML
// fragment (routers/frame_pages.py's widget_dialog) and injected into a
// single shared <dialog>, rather than a separate page per widget type --
// a frame can now have several widgets of the same type, so "the
// Calendar tab" stopped meaning anything unambiguous.
// widget_dialog_{photos,calendar,whiteboard,tasks}.js each define an
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
// frame_layout.html, since which one runs depends on which widget's gear
// icon was clicked).
const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
};
const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
};
let openDialogWidgetType = null;
async function openWidgetDialog(widget) {
const dialogEl = document.getElementById('widget-dialog');
const bodyEl = document.getElementById('widget-dialog-body');
bodyEl.innerHTML = '<p class="sub">Loading...</p>';
dialogEl.querySelector('.dialog-result').innerHTML = ''; // clear any message left over from a previous dialog
openDialogWidgetType = widget.widget_type;
dialogEl.showModal();
try {
const resp = await fetch(`/frames/${window.FRAME_ID}/widgets/${widget.id}/dialog`);
if (!resp.ok) throw new Error(await apiError(resp));
bodyEl.innerHTML = await resp.text();
// Every dialog script's fetch calls use window.FRAME_API as their
// base -- repointing it at this specific widget (instead of the
// frame-level window.FRAME_BASE_API) is what makes the SAME
// widget_dialog_photos.js/queue.js/etc. code work correctly no
// matter which widget's dialog is currently open. Restored on close.
window.FRAME_API = `${window.FRAME_BASE_API}/widgets/${widget.id}`;
const init = DIALOG_INIT[widget.widget_type];
if (init) init();
} catch (e) {
bodyEl.innerHTML = `<p class="sub">Could not load: ${e.message}</p>`;
}
}
document.getElementById('widget-dialog-close').addEventListener('click', () => {
document.getElementById('widget-dialog').close();
});
// Native <dialog> doesn't close on backdrop click by default -- a click
// that lands outside the dialog's own box (but is still technically
// "on" the dialog element, since the backdrop is part of it) counts as
// a backdrop click.
document.getElementById('widget-dialog').addEventListener('click', (e) => {
const dialogEl = e.currentTarget;
if (e.target !== dialogEl) return; // click landed on dialog content, not the backdrop
const rect = dialogEl.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) dialogEl.close();
});
document.getElementById('widget-dialog').addEventListener('close', () => {
const close = DIALOG_CLOSE[openDialogWidgetType];
if (close) close();
openDialogWidgetType = null;
window.FRAME_API = window.FRAME_BASE_API;
document.getElementById('widget-dialog-body').innerHTML = '';
});
loadWidgets();
-126
View File
@@ -1,126 +0,0 @@
// Photos tab: now-displaying, album picker, and the upcoming grid
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
// template.
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
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.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
renderControlBanner(data.control);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
async function savePhotoSettings() {
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
queue_target_len: document.getElementById('queue_target_len').value,
});
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('load-albums').addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/albums`);
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('photos-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await savePhotoSettings();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('take-control').addEventListener('click', takeControl);
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
setInterval(loadQueue, 10000);
+3 -2
View File
@@ -3,8 +3,9 @@
// machine below (hold-to-arm on touch so page scrolling still works) is
// battle-tested; treat changes with suspicion.
//
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
// loadQueue() global (frame_photos.js) to refetch authoritative state.
// Expects window.FRAME_API = '/api/frames/<id>/widgets/<widget_id>' (set
// by frame_layout.js when the photos dialog opens), and a loadQueue()
// global (widget_dialog_photos.js) to refetch authoritative state.
let upcomingItems = [];
+196
View File
@@ -0,0 +1,196 @@
// Layout tab's "Saved layouts" card: save the current widget arrangement
// (placement, settings, button assignments -- see routers/api_layouts.py)
// under a name, then switch back to it later. Layouts are owned by the
// logged-in user, not this frame, so this always hits window.FRAME_BASE_API
// (the frame-level base) rather than window.FRAME_API, which the widget
// dialog machinery in frame_layout.js temporarily repoints at a specific
// widget while its gear-icon dialog is open.
let savedLayouts = [];
let editingLayoutId = null; // inline rename in progress, same pattern as frame_header.js's name pencil-edit
function renderSavedLayouts() {
const list = document.getElementById('saved-layout-list');
const emptyHint = document.getElementById('saved-layout-empty-hint');
list.innerHTML = '';
emptyHint.style.display = savedLayouts.length ? 'none' : '';
savedLayouts.forEach((layout) => {
const li = document.createElement('li');
li.className = 'saved-layout-row';
if (editingLayoutId === layout.id) {
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 60;
input.value = layout.name;
input.className = 'saved-layout-rename-input';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn-inline';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => renameSavedLayout(layout, input.value));
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn-inline secondary';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => { editingLayoutId = null; renderSavedLayouts(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') renameSavedLayout(layout, input.value);
if (e.key === 'Escape') { editingLayoutId = null; renderSavedLayouts(); }
});
li.appendChild(input);
li.appendChild(saveBtn);
li.appendChild(cancelBtn);
list.appendChild(li);
input.focus();
input.select();
return;
}
const nameWrap = document.createElement('span');
nameWrap.className = 'saved-layout-name';
const nameText = document.createElement('span');
nameText.textContent = layout.name;
nameWrap.appendChild(nameText);
if (!layout.compatible) {
const badge = document.createElement('span');
badge.className = 'saved-layout-badge';
badge.textContent = 'different orientation';
nameWrap.appendChild(badge);
}
li.appendChild(nameWrap);
const controls = document.createElement('span');
controls.className = 'saved-layout-controls';
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'btn-inline';
applyBtn.textContent = 'Apply';
applyBtn.disabled = !layout.compatible;
applyBtn.title = layout.compatible
? `Replace the current arrangement with "${layout.name}"`
: "This layout was saved for a different orientation's grid";
applyBtn.addEventListener('click', () => applySavedLayout(layout));
controls.appendChild(applyBtn);
const renameBtn = document.createElement('button');
renameBtn.type = 'button';
renameBtn.className = 'icon-btn';
renameBtn.textContent = '✎'; // pencil
renameBtn.title = 'Rename';
renameBtn.addEventListener('click', () => { editingLayoutId = layout.id; renderSavedLayouts(); });
controls.appendChild(renameBtn);
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'icon-btn';
deleteBtn.textContent = '×';
deleteBtn.title = 'Delete';
deleteBtn.addEventListener('click', () => deleteSavedLayout(layout));
controls.appendChild(deleteBtn);
li.appendChild(controls);
list.appendChild(li);
});
}
async function loadSavedLayouts() {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`);
if (!resp.ok) throw new Error(await apiError(resp));
savedLayouts = (await resp.json()).layouts;
renderSavedLayouts();
} catch (e) {
showStatus(false, e.message);
}
}
async function saveCurrentLayout() {
const input = document.getElementById('saved-layout-name');
const name = input.value.trim();
if (!name) {
showStatus(false, 'Give this layout a name first.');
return;
}
const existing = savedLayouts.find((l) => l.name === name);
if (existing && !confirm(`You already have a saved layout named "${name}" -- overwrite it with the current arrangement?`)) {
return;
}
try {
const resp = await fetch(`${window.FRAME_BASE_API}/layouts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
input.value = '';
showStatus(true, `Saved layout "${name}".`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadSavedLayouts();
}
}
document.getElementById('save-layout-btn').addEventListener('click', saveCurrentLayout);
document.getElementById('saved-layout-name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') saveCurrentLayout();
});
async function applySavedLayout(layout) {
if (!confirm(`Replace the current arrangement with "${layout.name}"? Widgets not in that layout will be removed.`)) {
return;
}
try {
const resp = await fetch(`${window.FRAME_BASE_API}/layouts/${layout.id}/apply`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, `Applied "${layout.name}".`);
} catch (e) {
showStatus(false, e.message);
} finally {
loadWidgets(); // see frame_layout.js -- reloads the placement canvas
}
}
async function renameSavedLayout(layout, rawName) {
const name = rawName.trim();
if (!name || name === layout.name) {
editingLayoutId = null;
renderSavedLayouts();
return;
}
try {
const resp = await fetch(`/api/layouts/${layout.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Renamed.');
editingLayoutId = null;
} catch (e) {
showStatus(false, e.message);
} finally {
loadSavedLayouts();
}
}
async function deleteSavedLayout(layout) {
if (!confirm(`Delete the saved layout "${layout.name}"? This can't be undone.`)) return;
try {
const resp = await fetch(`/api/layouts/${layout.id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Deleted.');
} catch (e) {
showStatus(false, e.message);
} finally {
loadSavedLayouts();
}
}
loadSavedLayouts();
+45
View File
@@ -0,0 +1,45 @@
// Settings page: "Discover calendars" against the CalDAV account already
// saved on this form (same idiom as widget_dialog_photos.js's Load
// Albums using the frame's already-saved Immich creds) -- so this only
// works after the CalDAV URL/username/password have been saved once.
// Hides the dedicated WebDAV username/password fields while "reuse my
// CalDAV creds" is checked -- they'd be ignored server-side anyway (see
// routers/common.py's webdav_creds_for), no reason to leave them visibly
// editable and implying they still do something.
const reuseCaldavCreds = document.getElementById('webdav_reuse_caldav_creds');
if (reuseCaldavCreds) {
const updateWebdavFieldVisibility = () => {
document.getElementById('webdav-creds-fields').style.display = reuseCaldavCreds.checked ? 'none' : '';
};
reuseCaldavCreds.addEventListener('change', updateWebdavFieldVisibility);
updateWebdavFieldVisibility();
}
const discoverBtn = document.getElementById('caldav-discover');
if (discoverBtn) {
discoverBtn.addEventListener('click', async () => {
const list = document.getElementById('caldav-calendar-list');
discoverBtn.disabled = true;
try {
const resp = await fetch('/api/settings/caldav-discover', { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
const calendars = await resp.json();
list.innerHTML = '';
if (calendars.length === 0) {
list.innerHTML = '<li>No calendars found in this account.</li>';
} else {
for (const c of calendars) {
const li = document.createElement('li');
li.textContent = c.display_name;
list.appendChild(li);
}
}
showStatus(true, `Found ${calendars.length} calendar${calendars.length === 1 ? '' : 's'}.`);
} catch (e) {
showStatus(false, e.message);
} finally {
discoverBtn.disabled = false;
}
});
}
+299 -1
View File
@@ -167,6 +167,8 @@ h2.card-title, summary.card-title {
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
.card-title-row .card-title { margin-bottom: 0; }
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
@@ -203,6 +205,12 @@ details.card .sub { margin-top: 8px; }
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
input[type="color"] {
width: 44px;
height: 34px;
padding: 2px;
cursor: pointer;
}
input[type="range"] {
width: 100%;
margin-top: 8px;
@@ -257,10 +265,117 @@ input:focus, select:focus {
box-shadow: 0 0 0 3px var(--focus-ring);
}
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.button-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
}
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.saved-layout-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
flex-wrap: wrap;
}
.saved-layout-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
.saved-layout-name > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.saved-layout-badge {
font-size: 11px;
padding: 2px 7px;
border-radius: 999px;
background: var(--warn-bg);
color: var(--warn-text);
white-space: nowrap;
}
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.saved-layout-controls .btn-inline { margin: 0; }
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.button-action-add select { width: auto; margin-top: 0; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; }
.richtext-toolbar { display: flex; align-items: center; gap: 4px; margin-top: 12px; flex-wrap: wrap; }
.richtext-btn {
width: auto;
min-width: 32px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.richtext-btn:hover { background: var(--surface); }
.richtext-toolbar-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 4px; }
.richtext-color-label {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: normal;
font-size: 14px;
color: var(--text-muted);
cursor: pointer;
}
.richtext-color-label input[type="color"] {
width: 22px;
height: 22px;
padding: 0;
margin-top: 0;
}
.richtext-editor {
margin-top: 8px;
min-height: 90px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-alt);
color: var(--text);
font-size: 15px;
line-height: 1.5;
}
.richtext-editor:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
.calendar-user-list > li { margin-top: 14px; }
.calendar-user-list > li:first-child { margin-top: 0; }
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.calendar-row { flex-wrap: wrap; }
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
.color-swatch {
width: 20px; height: 20px; padding: 0; margin: 0;
border: 2px solid var(--border); border-radius: 5px;
box-shadow: none; cursor: pointer;
}
.color-swatch.selected { border-color: var(--text); box-shadow: 0 0 0 1.5px var(--text); }
.color-swatch-auto {
width: auto; height: 20px; padding: 0 6px; font-size: 10px; font-weight: 600;
color: var(--text-muted); background: var(--surface-alt);
}
.color-swatch-auto.selected { color: var(--text); }
button {
margin-top: 20px;
padding: 10px 16px;
@@ -276,6 +391,7 @@ button {
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
@@ -304,6 +420,111 @@ button.secondary:hover { background: var(--surface-alt); }
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
#widget-canvas-wrap {
/* Height is set in px by frame_layout.js (measured wrap width * rows/cols)
-- not CSS aspect-ratio, which isn't supported on every mobile browser
this app gets viewed from, and percentage heights on the widget boxes
below would silently collapse to 0 against an indeterminate-height
ancestor if it weren't. Box positions/sizes are likewise set in px by
JS, not CSS percentages, for the same cross-browser reason. */
width: 100%;
max-width: 640px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 10px;
background-color: var(--surface-alt);
background-repeat: repeat;
}
#widget-canvas { position: relative; width: 100%; height: 100%; }
.widget-box {
position: absolute;
box-sizing: border-box;
border: 2px solid var(--accent);
background: var(--surface-alt); /* fallback for browsers without color-mix() support */
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
cursor: grab;
user-select: none;
overflow: hidden;
}
.widget-box.dragging { cursor: grabbing; box-shadow: var(--shadow-hover); z-index: 2; }
.widget-box-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
pointer-events: none;
}
.widget-box-remove, .widget-box-settings {
position: absolute;
top: 4px;
width: 20px;
height: 20px;
padding: 0;
margin: 0;
line-height: 1;
border-radius: 50%;
border: none;
background: var(--overlay);
color: #fff;
font-size: 12px;
cursor: pointer;
}
.widget-box-remove { right: 4px; }
.widget-box-settings { right: 28px; }
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
.widget-box-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
border-right: 3px solid var(--accent);
border-bottom: 3px solid var(--accent);
border-bottom-right-radius: 4px;
}
#widget-dialog {
position: fixed;
margin: auto;
width: min(680px, calc(100vw - 32px));
max-height: min(720px, calc(100vh - 64px));
padding: 24px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-hover);
/* #widget-dialog-body scrolls on its own (min-height: 0 is what lets a
flex child actually shrink/scroll instead of forcing the dialog
past max-height) so .dialog-result stays pinned as a visible footer
regardless of scroll position -- otherwise a save message can land
off-screen below a long form with no visible feedback at all. */
display: flex;
flex-direction: column;
}
#widget-dialog::backdrop { background: var(--overlay); }
#widget-dialog-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
font-size: 18px;
z-index: 1;
}
.dialog-title { margin: 0 40px 16px 0; font-size: 18px; }
#widget-dialog-body { overflow-y: auto; min-height: 0; }
#widget-dialog-body .card { box-shadow: none; }
#widget-dialog-body .card:first-child { margin-top: 0; }
.dialog-result { flex: none; }
.dialog-result:not(:empty) { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
code {
background: var(--surface-alt);
color: var(--text);
@@ -373,7 +594,12 @@ code {
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
/* minmax(0, 1fr), not bare 1fr -- bare 1fr is minmax(auto, 1fr), whose
"auto" minimum lets the track (and everything in it) grow to fit its
widest descendant's min-content size instead of actually shrinking
to the viewport, causing page-wide horizontal overflow/scroll on
narrow screens. Same fix the desktop rule above already applies. */
.layout { grid-template-columns: minmax(0, 1fr); }
}
/* ------------------------------------------------------------------ */
/* App shell: left sidebar (frame list + account nav) + main content. */
@@ -468,6 +694,78 @@ code {
}
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tabs a.tab-disabled { opacity: 0.45; }
.tabs a.tab-disabled:hover { opacity: 0.7; }
.frame-name-view { display: inline-flex; align-items: center; gap: 6px; }
.frame-name-pencil {
background: none;
border: none;
box-shadow: none;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 4px;
margin: 0;
opacity: 0.55;
color: var(--text);
transition: opacity .12s ease, background-color .12s ease;
}
.frame-name-pencil:hover { opacity: 1; background: var(--surface-alt); border-radius: 6px; }
.frame-name-edit { display: inline-flex; align-items: center; gap: 6px; }
.frame-name-edit input {
width: auto;
margin-top: 0;
padding: 5px 8px;
font-size: 15px;
font-weight: 700;
}
.frame-name-edit button { margin-top: 0; }
.frame-preview-thumb {
height: 44px;
width: auto;
max-width: 130px;
object-fit: contain;
vertical-align: middle;
margin-left: 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-alt);
cursor: pointer;
transition: opacity .12s ease;
}
.frame-preview-thumb:hover { opacity: 0.8; }
.frame-preview-dialog {
position: fixed;
margin: auto;
padding: 0;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow-hover);
line-height: 0; /* avoid a baseline gap under the image */
}
.frame-preview-dialog::backdrop { background: var(--overlay); }
.frame-preview-dialog img {
display: block;
max-width: min(90vw, 900px);
max-height: min(85vh, 900px);
width: auto;
height: auto;
border-radius: 14px;
cursor: pointer;
}
#frame-preview-dialog-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
font-size: 18px;
z-index: 1;
}
.control-banner {
display: flex;
+204
View File
@@ -0,0 +1,204 @@
// Calendar widget dialog: view/week-start settings, per-user opt-in,
// weather, and the rendered preview. Not a page-load script --
// frame_layout.js fetches this widget's dialog HTML fragment, injects
// it into the shared <dialog>, points window.FRAME_API at this specific
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
// initCalendarDialog(). Checkboxes are always sent explicitly as
// "true"/"false".
// Week-view-only settings (days/layout/start-offset) only matter when
// View is actually "Week"; "Week starts on" also matters for Month, so
// it gets its own, slightly looser condition. The start-offset row is
// further gated on the day count -- it's meaningless at the default 7
// days, where "Week starts on" governs instead (see
// calendar_render.py's _build_week).
function updateCalendarFieldVisibility() {
const view = document.getElementById('calendar_view').value;
const days = Number(document.getElementById('calendar_week_days').value);
const isWeek = view === 'week';
document.getElementById('calendar-week-start-row').style.display =
(view === 'week' || view === 'month') ? '' : 'none';
document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none';
}
function addWeatherCityRow(label) {
const list = document.getElementById('weather-city-list');
const empty = document.getElementById('weather-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
function initCalendarDialog() {
document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility);
document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility);
updateCalendarFieldVisibility();
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_view: document.getElementById('calendar_view').value,
calendar_week_start: document.getElementById('calendar_week_start').value,
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').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.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// Each calendar's own include/mute toggle -- auto-saves on change, not
// batched into the form above, since it's a data-sharing choice (see
// api_widget_calendar_select), not a widget-wide setting. Works the
// same element for your own calendars (full add/remove) and other
// people's (mute only) -- the server enforces which direction is
// allowed and this just reverts the checkbox with an error message if
// rejected.
document.querySelectorAll('.calendar-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Calendar included on this widget.' : 'Calendar removed from this widget.');
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
// Per-calendar color pin -- owner-only (the server enforces it; these
// buttons only ever render for the viewer's own calendars anyway).
// Clicking the currently-selected swatch again has no special
// "toggle off" behavior -- use the explicit Auto button.
document.querySelectorAll('.calendar-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/calendar-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_weather_enabled: String(document.getElementById('weather_enabled').checked),
calendar_weather_units: document.getElementById('weather_units').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.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity));
document.getElementById('weather-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
}
function closeCalendarDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
+121
View File
@@ -0,0 +1,121 @@
// Photos widget dialog: now-displaying, album picker, order/display-mode
// settings, and the upcoming grid (rendering/drag logic in queue.js).
// Not a page-load script -- frame_layout.js fetches this widget's dialog
// HTML fragment, injects it into the shared <dialog>, points
// window.FRAME_API at this specific widget (/api/frames/{id}/widgets/
// {widget_id}), then calls initPhotosDialog(). closePhotosDialog() stops
// the poll interval when the dialog closes, same "expects window.
// FRAME_API + a global loadQueue()" contract queue.js has always had.
let photosPollTimer = null;
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
if (!currentEl) return; // dialog closed mid-flight
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
async function savePhotoSettings() {
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
queue_target_len: document.getElementById('queue_target_len').value,
order: document.getElementById('order').value,
display_mode: document.getElementById('display_mode').value,
});
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));
}
}
function initPhotosDialog() {
document.getElementById('load-albums').addEventListener('click', async () => {
try {
// /albums is frame-level (routers/api_frames.py) -- it lists the
// frame owner's whole Immich library, not something scoped to
// this one photo widget -- so it uses window.FRAME_BASE_API (the
// stable frame-level base), not window.FRAME_API (repointed to
// this widget's own API base while the dialog is open).
const resp = await fetch(`${window.FRAME_BASE_API}/albums`);
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('photos-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await savePhotoSettings();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
photosPollTimer = setInterval(loadQueue, 10000);
}
function closePhotosDialog() {
clearInterval(photosPollTimer);
photosPollTimer = null;
}
+62
View File
@@ -0,0 +1,62 @@
// Static image widget dialog: upload, display-mode setting, and the
// rendered preview. Not a page-load script -- frame_layout.js fetches
// this widget's dialog HTML fragment, injects it into the shared
// <dialog>, points window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initStaticDialog().
function loadStaticPreview() {
document.getElementById('static-preview').src = `${window.FRAME_API}/preview/static?_=${Date.now()}`;
}
function initStaticDialog() {
document.getElementById('static-upload').addEventListener('click', async () => {
const input = document.getElementById('static-file');
if (!input.files.length) {
showStatus(false, 'Pick a file first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch(`${window.FRAME_API}/static-upload`, { method: 'POST', body: form });
if (!resp.ok) throw new Error(await apiError(resp));
const result = await resp.json();
const currentFileEl = document.getElementById('static-current-file');
currentFileEl.textContent = 'Currently showing: ';
const nameEl = document.createElement('strong');
nameEl.textContent = result.filename;
currentFileEl.appendChild(nameEl);
input.value = '';
showStatus(true, 'Uploaded.');
loadStaticPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('static-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
display_mode: document.getElementById('display_mode').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.');
loadStaticPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
loadStaticPreview();
}
function closeStaticDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
+95
View File
@@ -0,0 +1,95 @@
// Tasks widget dialog: per-user included-task-list checkboxes + color
// pins (same shape as the calendar widget's "Included calendars"), the
// name/recently-completed settings form, and the rendered preview. Not
// a page-load script -- frame_layout.js fetches this widget's dialog
// HTML fragment, injects it into the shared <dialog>, points
// window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initTasksDialog().
function loadTasksPreview() {
document.getElementById('tasks-preview').src = `${window.FRAME_API}/preview/tasks?_=${Date.now()}`;
}
function initTasksDialog() {
// Each task list's own include/mute toggle -- auto-saves on change,
// not batched into the form below, since it's a data-sharing choice
// (see api_widget_task_list_select), not a widget-wide setting. Works
// the same element for your own lists (full add/remove) and other
// people's (mute only) -- the server enforces which direction is
// allowed and this just reverts the checkbox with an error message if
// rejected.
document.querySelectorAll('.task-list-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/task-list-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Task list included on this widget.' : 'Task list removed from this widget.');
loadTasksPreview();
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
// Per-task-list color pin -- owner-only (the server enforces it;
// these buttons only ever render for the viewer's own lists anyway).
document.querySelectorAll('.task-list-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/task-list-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
document.getElementById('tasks-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
tasks_name: document.getElementById('tasks_name').value,
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
});
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.');
loadTasksPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
}
function closeTasksDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
+141
View File
@@ -0,0 +1,141 @@
// Text widget dialog: a small rich-text editor (bold/italic/underline,
// text/highlight color), font size/alignment/background settings, and
// the rendered preview. Not a page-load script -- frame_layout.js
// fetches this widget's dialog HTML fragment, injects it into the
// shared <dialog>, points window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog().
//
// The editor is never seeded via innerHTML string interpolation --
// #text-editor's data-content attribute (server-rendered from
// app/text_content.py's already-sanitized run structure, not raw HTML)
// is JSON-parsed and rebuilt with createElement/textContent below. The
// same <div><span style="..."> shape this produces is exactly what
// app/text_content.py's parser expects back on save, so round-tripping
// (load -> edit -> save -> reload) is stable.
function loadTextPreview() {
document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`;
}
function buildTextEditorContent(editor, paragraphs) {
editor.textContent = '';
if (!paragraphs || !paragraphs.length) return;
paragraphs.forEach((para) => {
const div = document.createElement('div');
if (!para.length) {
div.appendChild(document.createElement('br'));
} else {
para.forEach((run) => {
const span = document.createElement('span');
span.textContent = run.text;
if (run.bold) span.style.fontWeight = 'bold';
if (run.italic) span.style.fontStyle = 'italic';
if (run.underline) span.style.textDecoration = 'underline';
if (run.color) span.style.color = run.color;
if (run.bg) span.style.backgroundColor = run.bg;
div.appendChild(span);
});
}
editor.appendChild(div);
});
}
let _textSavedRange = null;
let _textSelectionHandler = null;
function initTextDialog() {
const editor = document.getElementById('text-editor');
let initialContent = null;
try {
initialContent = JSON.parse(editor.dataset.content || 'null');
} catch (e) { /* leave empty */ }
buildTextEditorContent(editor, initialContent);
// Native <input type="color"> steals focus (and with it, the
// editor's text selection) the moment it's interacted with -- track
// the most recent in-editor selection continuously so a color pick
// can be reapplied to the text the user actually had selected,
// instead of applying to nothing.
_textSelectionHandler = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
_textSavedRange = sel.getRangeAt(0).cloneRange();
}
};
document.addEventListener('selectionchange', _textSelectionHandler);
function restoreSelection() {
if (!_textSavedRange) return;
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(_textSavedRange);
}
['text-bold', 'text-italic', 'text-underline'].forEach((id) => {
const btn = document.getElementById(id);
// preventDefault on mousedown keeps focus (and the selection) in
// the editor, so the click's execCommand has something to act on.
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', () => {
editor.focus();
document.execCommand(btn.dataset.cmd, false, null);
});
});
document.getElementById('text-color').addEventListener('input', (e) => {
editor.focus();
restoreSelection();
document.execCommand('foreColor', false, e.target.value);
});
document.getElementById('text-highlight').addEventListener('input', (e) => {
editor.focus();
restoreSelection();
document.execCommand('hiliteColor', false, e.target.value);
});
document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault());
document.getElementById('text-highlight-clear').addEventListener('click', () => {
editor.focus();
restoreSelection();
document.execCommand('hiliteColor', false, 'transparent');
});
document.getElementById('text_font_size').addEventListener('input', (e) => {
document.getElementById('text_font_size_value').textContent = `${e.target.value}px`;
});
document.getElementById('text-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
text_html: editor.innerHTML,
text_font_family: document.getElementById('text_font_family').value,
text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').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.');
loadTextPreview();
} catch (e2) {
showStatus(false, e2.message);
}
});
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview();
}
function closeTextDialog() {
if (_textSelectionHandler) {
document.removeEventListener('selectionchange', _textSelectionHandler);
_textSelectionHandler = null;
}
_textSavedRange = null;
}
@@ -0,0 +1,163 @@
// Whiteboard widget dialog: source URL (owner-gated, see
// api_widgets.py's api_widget_whiteboard_source), preview, and the file
// browser. Not a page-load script -- frame_layout.js fetches this
// widget's dialog HTML fragment, injects it into the shared <dialog>,
// points window.FRAME_API at this specific widget (/api/frames/{id}/
// widgets/{widget_id}), then calls initWhiteboardDialog().
// Rewrites #whiteboard-current-source in place instead of telling the
// user to reload -- the API always assigns a successful "set" to the
// caller (see api_widget_whiteboard_source), so after either action we
// already know exactly what the new state is without asking the server
// again.
function renderWhiteboardCurrentSource(url) {
const container = document.getElementById('whiteboard-current-source');
container.innerHTML = '';
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
if (url) {
p.append('Currently showing ');
const urlEl = document.createElement('strong');
urlEl.textContent = url;
p.append(urlEl, ' using your WebDAV account. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'whiteboard-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearWhiteboardSource);
p.append(clearBtn);
} else {
p.textContent = 'No whiteboard configured yet.';
}
container.append(p);
const label = document.getElementById('whiteboard-source-form-label');
if (label) {
label.textContent = url ? 'Change to one of your own files' : 'Use one of your own files';
}
}
async function clearWhiteboardSource() {
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared.');
renderWhiteboardCurrentSource(null);
const urlInput = document.getElementById('whiteboard-url-input');
if (urlInput) urlInput.value = '';
loadWhiteboardPreview(false);
} catch (e) {
showStatus(false, e.message);
}
}
function loadWhiteboardPreview(force) {
const forceParam = force ? '&force=1' : '';
document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}${forceParam}`;
}
function initWhiteboardDialog() {
const whiteboardForm = document.getElementById('whiteboard-source-form');
if (whiteboardForm) {
whiteboardForm.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('whiteboard-url-input').value.trim();
if (!url) return;
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
renderWhiteboardCurrentSource(url);
loadWhiteboardPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
}
const whiteboardClearBtn = document.getElementById('whiteboard-source-clear');
if (whiteboardClearBtn) {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
}
// Shows whatever's already cached (cheap, no refetch) on open; the
// button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_widget_preview_
// whiteboard's `force` param).
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false);
// --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle');
if (browseToggle) {
const browsePanel = document.getElementById('whiteboard-browser');
const browseList = document.getElementById('whiteboard-browse-list');
const browseCurrent = document.getElementById('whiteboard-browse-current');
const browseUp = document.getElementById('whiteboard-browse-up');
const browseError = document.getElementById('whiteboard-browse-error');
const urlInput = document.getElementById('whiteboard-url-input');
let opened = false;
async function browseTo(url) {
browseError.style.display = 'none';
browseList.innerHTML = '<li class="sub">Loading...</li>';
try {
const qs = url ? `?url=${encodeURIComponent(url)}` : '';
const resp = await fetch(`${window.FRAME_API}/whiteboard-browse${qs}`);
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
browseCurrent.textContent = data.current_url;
browseUp.disabled = !data.parent_url;
browseUp.onclick = data.parent_url ? () => browseTo(data.parent_url) : null;
browseList.innerHTML = '';
if (data.entries.length === 0) {
browseList.innerHTML = '<li class="sub">(empty folder)</li>';
}
for (const entry of data.entries) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary';
btn.style.margin = '2px 0';
btn.textContent = (entry.is_dir ? '📁 ' : '📄 ') + entry.name;
if (entry.is_dir) {
btn.addEventListener('click', () => browseTo(entry.url));
} else {
btn.addEventListener('click', () => {
urlInput.value = entry.url;
browsePanel.style.display = 'none';
});
}
li.appendChild(btn);
browseList.appendChild(li);
}
} catch (e) {
browseList.innerHTML = '';
browseError.textContent = e.message;
browseError.style.display = 'block';
}
}
browseToggle.addEventListener('click', () => {
opened = !opened;
browsePanel.style.display = opened ? 'block' : 'none';
if (opened && !browseCurrent.textContent) {
browseTo(null);
}
});
}
}
function closeWhiteboardDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
@@ -0,0 +1,16 @@
<span class="frame-name-view" id="frame-name-view">
<span id="frame-name-text">{{ frame.name or ("Frame " ~ frame.id) }}</span>
<button type="button" class="frame-name-pencil" id="frame-name-pencil" title="Rename frame" aria-label="Rename frame">&#9998;</button>
</span>
<span class="frame-name-edit" id="frame-name-edit-row" style="display: none;">
<input type="text" id="frame-name-input" maxlength="64" value="{{ frame.name }}">
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
</span>
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge">
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button>
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh">
</dialog>

Some files were not shown because too many files have changed in this diff Show More