Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and restarts into provisioning -- the deliberate, USB-free replacement for the earlier reverted RST-based auto-reprovisioning idea. Next-photo (GPIO2, tap): wakes the device and forces the server to advance immediately via a new POST /frame/advance, instead of waiting for the refresh interval. Both buttons arm themselves as deep-sleep GPIO wakeup sources so a press is noticed promptly even while asleep. Also makes GET /frame/image side-effect-free: it now only advances once refresh_interval_s has elapsed since the current photo was set (tracked server-side), so a device reboot for any reason just redisplays the current photo instead of silently skipping ahead. The server maintains a small reorderable upcoming-photos queue, viewable and rearrangeable from the web UI.
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. Read-only access to albums/assets is enough.
- 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.
Endpoints
GET /-- config UI (album, order, refresh interval, face-aware crop toggle, now-displaying + reorderable upcoming photos -- 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_facesGET /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).GET /frame/config--{"refresh_interval_s": ...}, polled by the frame each wake alongside its reachability checkGET /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, ...]}, must be exactly a permutation of the current queueGET /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 (10 photos), not the whole
album -- it's topped up automatically as photos are consumed
(
app/photo_queue.py), in sequential or shuffle order per the Order setting. Reordering only rearranges those 10; it doesn't add or remove photos from the album. /frame/imageand/frame/advancearen't authenticated yet. That's fine on a trusted home LAN for now, but worth revisiting once the ESP32 side is wired up to send a shared device token.- 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.