firmware/README.md's HTTP vs HTTPS section still described the public-CA-bundle trust approach that was tried and abandoned in favor of pinning one specific certificate -- rewritten to match what's actually there. docs/architecture.md was missing the back-photo button entirely (sequence diagram and boot-flow bullets only covered next) and still said the system talks "over plain HTTP" despite HTTPS support. docker-compose.yml.example's MANAGEMENT_TOKEN comment understated its scope (said "the web UI", omitting that every /frame/* endpoint is gated too).
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
- Get an Immich API key: in Immich, go to Account Settings -> API Keys
-> New API Key. Needs read access to albums/assets/faces, plus
sharedLink.create(for the manage overlay's "scan to download" QR, which creates a temporary public share link) -- a plain read-only key will 403 on that one specific feature while everything else works. - Copy the compose file and fill in your Immich details:
Edit
cp docker-compose.yml.example docker-compose.ymldocker-compose.ymland setIMMICH_URL/IMMICH_API_KEYunderenvironment:.docker-compose.ymlis gitignored (it'll hold your real API key) --docker-compose.yml.exampleis the one that's committed. - Run the server:
docker compose up -d - Open
http://<this-machine>:8420/in a browser, click Load Albums, pick one, and Save. (The Immich URL/API key fields will already be populated from the environment; changing them in the UI has no effect as long as the env vars are set -- they win on every load.) - On the ESP32's captive portal setup form, set the Tools Server field
to
<this-machine>:8420. This server always speaks plain HTTP itself -- for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of it and enter the proxy'shttps://address instead (seefirmware/README.md's HTTPS section for what the ESP32 side needs). - Optional: set
MANAGEMENT_TOKENindocker-compose.ymlto gate the entire server -- the web UI (/,/api/*) and every device-facing/frame/*endpoint -- behind a shared secret (leave unset to keep it all open, the previous default -- fine on a trusted LAN). If set, paste the same value into the ESP32's captive portal setup form's Access Token field: the device then sends it on every request it makes, and the manage-menu/share QR codes embed it automatically (?token=...) so scanning them just works. Visiting the web UI without a valid token in the URL shows a plain token-entry prompt instead of the config UI;/healthstays open regardless (pure liveness, nothing sensitive in it).
Endpoints
GET /-- config UI (album, order, refresh interval, face-aware crop toggle, upcoming-photos count, now-displaying + drag-to-reorder upcoming grid -- not Immich URL/API key, see Setup above)GET /api/albums-- lists Immich albums (used by the config UI)POST /api/config-- saves album/order/refresh_interval_s/smart_crop_faces/queue_target_lenGET /frame/image-- returns the current photo 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 to the next photo oncerefresh_interval_shas elapsed since the current one was set, so calling it repeatedly (e.g. the device rebooting unexpectedly) just redisplays the same photo instead of skipping ahead.POST /frame/advance-- forces an immediate advance to the next photo, ignoringrefresh_interval_s, and resets the interval clock from now. Same response shape as/frame/image. Used by the device's next-photo button (seefirmware/README.md). Every photo actually displayed this way (or via the normal timer-based advance) is pushed onto a bounded history (app/photo_queue.py, last 20) that/frame/backbelow can return to.POST /frame/back-- returns to the previously-current photo (the exact mirror of/frame/advance), and resets the interval clock from now. A no-op (still 200, same photo) if there's no history yet. Pressing advance afterwards returns to where you were before going back -- it displaces the current photo onto the front of the upcoming queue rather than discarding it. Same response shape as/frame/image. Used by the device's back-photo button.GET /frame/config--{"refresh_interval_s": ...}, polled by the frame each wake alongside its reachability checkGET /frame/photo-info--{"asset_id": ..., "location_line1": ... | null, "location_line2": ... | null, "taken_at": ... | null}for the current photo (same idempotent current-photo semantics as/frame/image).location_line1/location_line2arecity/state-or-countryif Immich reverse-geocoded the photo's GPS EXIF (bothnullif not) -- for US/Canada, the region line is the abbreviated state/province ("CA","ON"); elsewhere it's the full country name.taken_atisMM/DD/YYfrom the photo's EXIF capture date, elsenull. Used by the device's manage button to build its overlay textGET /frame/share/{asset_id}-- creates a 30-minute public, view-only Immich share link forasset_idand redirects (302) to it. Only works for the photo currently showing or in the upcoming queue on this frame -- not any arbitrary Immich asset. The link is created on first hit (i.e. when someone actually scans the manage overlay's share QR), not when the button's pressed, so the 30-minute window starts when it's actually usedGET /frame/face-labels--{"count": N, "name_0": ..., "x_0": ..., "y_0": ..., ...}(up to 4 slots) -- named people from Immich's face recognition, positioned in final 800x480 frame pixel space. Only faces Immich already has an identified name for are included (no face detection/recognition happens in this project, seeapp/face_labels.py);count: 0if none are named. Used by the device manage button's escalated second menu levelGET /api/queue--{"current": {...} | null, "upcoming": [...]}, each entry an asset id + thumbnail URL; used by the config UIPOST /api/queue/reorder-- reorders the upcoming queue; body is{"queue": [asset_id, ...]}. Tolerant of drift from the queue having changed server-side since the client's last fetch (e.g. a top-up/trim) -- unrecognized IDs in the body are dropped, and any currently-queued photo missing from the body is appended rather than lost, instead of rejecting the whole requestPOST /api/queue/promote-- moves one photo to the front of the queue; body is{"asset_id": "..."}. Used by "Show next" in the web UI -- unlike/reorder, doesn't depend on the client knowing the queue's full current order, so it can't fail from stalenessGET /api/photo-thumbnail/{asset_id}-- proxies an Immich thumbnail so the browser never needs the Immich API key directlyGET /health-- liveness check
Notes
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
stored in
./data/config.jsonon the host via the compose volume mount. Immich URL/API key are too if set via the web UI, butIMMICH_URL/IMMICH_API_KEYenv vars (see Setup above) always 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. - Every endpoint except
/and/health-- the web UI's/api/*and every device-facing/frame/*-- requires?token=(or themgmt_tokencookie the web UI sets after a valid one) onceMANAGEMENT_TOKENis set (see Setup above); unset, everything stays open like before, which is still fine on a trusted home LAN./frame/shareadditionally stays scoped to only ever create a link for a photo this frame is actually showing or has queued, not any Immich asset ID someone might guess -- a second layer a leaked token alone wouldn't bypass. - The 6-color palette RGB values in
app/image_pipeline.pyare approximations, not measured values (Waveshare doesn't publish exact color primaries for this panel) -- tune them once you can compare a rendered test image against the real panel.
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.