Replaces the up/down-button vertical list with a responsive photo grid (native HTML5 drag-and-drop between cards, reusing the existing POST /api/queue/reorder endpoint -- no new server route needed). Each card also gets a "Show next" button that jumps it straight to the front of the queue. Also bumps the queue lookahead from 10 to 24 photos (QUEUE_TARGET_LEN in photo_queue.py) now that the grid has room to show more at once.
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 (
QUEUE_TARGET_LENinapp/photo_queue.py, currently 24 photos), not the whole album -- 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. /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.