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.
ESPresso Frame Server
Pulls photos from an Immich album, resizes/dithers/quantizes them to the E Ink Spectra 6 panel's exact 6-color format, and serves the frame a ready-to-display image once an hour. All the image processing happens here so the ESP32 never has to decode a JPEG or run a dithering algorithm itself -- it just streams the response straight to the panel.
Setup
- Copy the compose file and run the server:
cp docker-compose.yml.example docker-compose.yml docker compose up -d - First-run setup: open
http://<this-machine>:8420/-- you'll be walked through creating the admin account. Every user has their own login; the admin can enroll more from the Admin page (family members can also self-enroll through the frame-claim flow, below). - Connect your Immich library (per user, in Settings): your Immich
URL and an API key. The key needs read access to
albums/assets/faces, plus
sharedLink.create(for the on-frame "scan to download" QR, which creates a temporary public share link) -- a plain read-only key will 403 on that one feature while everything else works. Frames you own pull from your library. (IMMICH_URL/IMMICH_API_KEYenv vars indocker-compose.ymlstill work as an operator-level fallback and seed the first admin's settings when migrating an older deployment.) - Provision a frame: power it on, join its
ESPRESSO_XXXXXXWiFi (instructions show on the panel), fill in your WiFi details and this server's address (Tools Server, e.g.<this-machine>:8420). After saving, your browser is redirected to this server's claim page and the frame links to your account -- creating an account on the spot if you don't have one (a valid frame is the invitation). The server speaks plain HTTP itself -- for HTTPS, put a TLS-terminating reverse proxy in front and enter the proxy'shttps://address instead (seefirmware/README.md's HTTPS section). - Each frame gets its own device token automatically -- the server
issues it on the frame's first check-in, so there's nothing to
configure. The captive portal's Access Token field only matters
when pointing new firmware at an old (pre-multi-frame) server.
MANAGEMENT_TOKENindocker-compose.ymlis likewise now only the migration credential: a frame flashed with pre-multi-frame firmware authenticates with it until it's updated and bound (the Admin page shows the migration state per frame and a "Close legacy window" button for when it's done). - Optional: auto-update firmware from Gitea releases. If you're
pushing this repo to a Gitea instance,
.gitea/workflows/firmware-release-build.ymlbuilds both supported boards and publishes them as release assets (firmware-xiao.bin/firmware-devkit.bin) wheneverfirmware/version.txtchanges onmain. In a frame's Configuration tab, set the Gitea repo URL; if the repo is private, also setGITEA_FIRMWARE_TOKEN(a read-only PAT) indocker-compose.yml. Which board's build to fetch is learned from the frame itself (itsX-Frame-Boardheader) -- nothing to pick by hand. The server then periodically checks for a newer release and either shows an "Update frame" button or, with Automatically apply updates checked, stages it itself -- either way the frame only actually updates on its own next wake.
Users, frames, and control
- Users log in with a session cookie; passwords are scrypt-hashed;
mutating requests are CSRF-protected. Sign-up paths: first-run setup
(admin #1), admin enrollment (Admin page), or the claim flow (a valid
unclaimed frame's
device_idgates self-service signup). - Frames identify themselves by
?id=(MAC-derived) on every request and authenticate with a per-frame device token the server issues at first check-in. Unknown frames self-register as unclaimed; claiming (via/claim?device_id=...) sets the owner -- whose Immich library the frame renders from -- and links the account. Admins can link additional users to any frame; every linked user sees it in their sidebar. - Control is a soft lock per frame: everyone linked can view; changing settings/queue requires holding control, and "Take control" always succeeds (the 409 error names the current holder). The physical buttons on the frame ignore all of this.
- The on-frame manage QR opens a limited no-login page (
/m/<token>): view current + upcoming, "show next", advance, back -- nothing else. The share QR stays public (it creates a 30-minute Immich share link for exactly the photo shown). - Email (optional). An admin sets an SMTP server once (
/admin-- server, port, username/password, from address, STARTTLS on/off; a "send test email to myself" button, delivered to the admin's own email); each user sets their own email in Settings. Once both are in place: "Forgot password?" on the login page emails a one-hour reset link (a generic "check your email" response either way, so the endpoint can't be used to enumerate accounts), and a frame's Configuration tab can set a battery-alert threshold -- an email to the frame's owner the first time a report drops to or below it, not again until a recharge is detected and it crosses again. No SMTP configured, or no email on the relevant account, and both features silently no-op rather than erroring.
Endpoints
Pages: / (routing hub), /setup, /login, /claim, /settings,
/admin, /frames/{id} (Photos), /frames/{id}/config,
/frames/{id}/stats, /m/{manage_token}.
Device protocol (/frame/* -- paths frozen; auth = ?id= + ?token=)
GET /frame/image-- the frame's current image, pre-processed into the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format (application/octet-stream, exactly 192,000 bytes). Side-effect-free by default: it only actually advances oncerefresh_interval_shas elapsed since the current photo was set, so an unexpected reboot just redisplays the same photo. An unclaimed or not-yet-configured frame gets a rendered instruction placeholder (with a claim QR) instead of an error, so a fresh device never error-loops.POST /frame/advance/POST /frame/back-- the next/back photo buttons: force an immediate move (mirror images of each other; back pops a bounded 20-entry history and pushes the displaced photo onto the front of the queue). Same response shape as/frame/image.GET /frame/config--{"refresh_interval_s": ..., "firmware_version": ... | null, "device_token": ...?}, polled each wake. Captures theX-Frame-Version/X-Frame-Boardheaders (running firmware + board variant).device_tokenappears only during the one-time identity handshake -- until the device authenticates with its issued token once -- and the flat firmware parser's 512-byte buffer bounds how big 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/face-labels-- up to 4 named faces with 800x480 positions, flattened (name_0/x_0/y_0, ...) for the device's flat-scalar parser.POST /frame/battery--{"percent": 0-100}; per-discharge-cycle history (feeds the runtime estimate) plus a permanent per-frame battery log (the Stats chart). Only sent on battery power. Also where the battery-alert threshold (below) is checked and, at most once per discharge cycle, emailed to the owner.GET /frame/firmware-- streams the frame's staged OTA image.
Web API (/api/frames/{id}/... -- session auth; view for reads, control for writes)
GET .../queue-- current + upcoming (each entry id + thumbnail URL), the control state ({"controller": name, "you": bool}), and the device telemetry block (last_seen,overdue-- quiet-hours aware -- firmware versions, battery + runtime estimate).POST .../queue/reorder|promote|remove-- reorder is drift-tolerant (stale ids dropped, missing ids appended); promote is "Show next"; remove permanently excludes from this frame's rotation (never touches Immich) and advances if it was current.GET .../albums-- the owner's Immich albums.POST .../config-- partial update: only provided fields change (name,album_id-- resets queue/history on change --,order,refresh_interval_s,display_mode(crop_fill/crop_faces/stretch_fill/letterbox, seeimage_pipeline.DISPLAY_MODES),queue_target_len,orientation(composed logically then rotated server-side; the on-device manage overlay still renders native, a known limitation),quiet_hours_*+timezone(a pure server-side decision shaping whatrefresh_interval_sgets handed to the device),firmware_update_repo_url,firmware_auto_update,battery_alert_threshold_pct-- percent, or-1/blank to disable --,palette-- exactly 6#rrggbbvalues in black/white/yellow/red/ blue/green order --,palette_reset--trueclears back to the default palette --,color_boost/contrast_boost-- PILImageEnhancefactors, 0-2, 1 = unchanged --,dither_strength-- 0-1, blends toward a flat/undithered quantization before running Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).GET .../preview/original,GET .../preview/rendered-- the before/after comparison on the Configuration tab: the current photo's Immich preview untouched (JPEG), and that same photo run through this frame's actual saved rendering pipeline (PNG, upright logical orientation, not packed device bytes) -- reflects saved settings, not unsaved slider positions.POST .../take-control-- always succeeds for a linked user.GET .../stats,GET .../battery-log,GET .../thumbnail/{asset_id}.POST .../firmware(manual .bin upload, esp_app_desc_t-validated),GET .../firmware/check(throttled 15 min;?force=truebypasses),POST .../firmware/apply-latest.
Manage-QR API (/api/m/{manage_token}/... -- token in path, no login)
-
GET queue,POST promote,POST advance,POST back,GET thumbnail/{asset_id}(scoped to this frame's current/queued photos). Nothing else. -
GET /health-- liveness check, always open.
Notes
- All state (settings, current photo, upcoming queue, battery history,
stats) lives in a SQLite database at
./data/espresso.dbon the host via the compose volume mount (DATABASE_URLenv var to override -- any SQLAlchemy URL works, so a future move to Postgres is a config change). A pre-database deployment's./data/config.jsonis imported automatically on first boot (it becomes frame #1) and left untouched afterwards as the rollback path.IMMICH_URL/IMMICH_API_KEYenv vars (see Setup above) still take precedence when present. - The upcoming queue is a bounded lookahead, not the whole album --
"Upcoming photos to show" in the config UI (
queue_target_len, 5-50, default 20) controls its size and takes effect immediately (the queue is topped up or trimmed the next time the page loads, not lazily over future advances). It's topped up automatically as photos are consumed, in sequential or shuffle order per the Order setting. Dragging photos 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, andMANAGEMENT_TOKENsurvives only as the migration credential for pre-multi-frame firmware./frame/sharestays 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. - Calendar frame mode (
app/calendar_feed.py) expands recurring events (RRULE/EXDATE/DST) viarecurring-ical-events, which is LGPL-3.0-or-later -- the only non-permissively-licensed dependency here. It's used as an ordinarypip installruntime 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 thecaldavPyPI package.caldavitself is dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends onicalendar-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).whiteboardfile -- which turns out to be Excalidraw scene JSON (elements/appState/ files), not an image -- and renders it viarender-service/, a small Node.js sidecar using Excalidraw's own real export code (@excalidraw/utils'sexportToSvg) 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 (Dockerfileinstalls Node,start.shlaunches it in the background beforeexec-ing uvicorn), reachable only at127.0.0.1:3001from 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, andexpressare all MIT/Apache-2.0/Zlib/ MPL-2.0 -- no repeat of the AGPL surprise. Not runtime-tested against a realnpm 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). Seerender-service/README.mdfor 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.pydraws emoji runs with a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text atapp/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). Each frame's Configuration tab has an Advanced configuration section (collapsed by default) with a color picker per ink color -- tune them once you can compare a rendered photo against the real panel, and "Reset to defaults" to go back. Different panel units can vary enough to be worth calibrating per frame.
Deploying a pre-built image
Every push to main that touches server/ triggers a Gitea Actions
workflow (.gitea/workflows/server-docker-build.yml) that builds this
image and pushes it to this repo's Gitea Container Registry at
git.thumeit.com/tfaour/espresso-frame-server. docker-compose.yml
(copied from docker-compose.yml.example, see Setup above) already
points at that image, so a deploy host doesn't need this repo's build
context at all -- just the compose file:
docker compose pull
docker compose up -d
docker compose build (or up --build) still works too, for local
iteration against your own Dockerfile changes.
Local development (without Docker)
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --host 0.0.0.0 --port 8420
--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.