Files
espresso_frame/server
tfaour e870898490
Build and push server image / build-and-push (push) Successful in 32s
Refine manage overlay: US/CAN state abbreviations, share-QR caption, and an escalating second menu with named-face labels
Two rounds of follow-up work on the manage-button overlay:

1. Location formatting: US/Canada now show abbreviated state/province
   ("CA", "ON") instead of the full name, other countries show the full
   country name, and each is its own line (was one line, now wraps to
   two) so longer international place names have more room without
   threatening to overlap the top-right QR box. The bottom-left share QR
   also gets a "SCAN TO DOWNLOAD" caption.

2. Escalating menu: pressing the manage button again while its overlay
   is already up adds a second level -- each Immich-identified person's
   name labeled next to their face in the photo (using Immich's own
   face recognition/People data, no detection/recognition added to this
   project). A third press exits immediately instead of waiting out the
   30s auto-revert timer. No new Immich API needed -- GET /api/faces
   already embeds a nullable person.name per face; new
   server/app/face_labels.py maps a named face's box into the final
   800x480 frame's pixel space (reusing crop-box math extracted from
   image_pipeline.py's face-aware cropping). Capped at 4 named faces,
   sized to a real firmware RAM budget: each label is its own malloc'd
   overlay region on the device, alongside the 4 fixed corner regions
   already in use. New GET /frame/face-labels returns a flattened
   fixed-slot JSON shape (not a real array) so firmware's existing
   flat-scalar parser can read it without needing an actual array
   parser. No persistent state needed for the escalation itself -- it's
   all local control flow within one continuous awake session
   (frame_client.c's run_management_menu()).
2026-07-19 09:09:06 -04:00
..

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

  1. Get an Immich API key: in Immich, go to Account Settings -> API Keys -> New API Key. Read-only access to albums/assets is enough.
  2. Copy the compose file and fill in your Immich details:
    cp docker-compose.yml.example docker-compose.yml
    
    Edit docker-compose.yml and set IMMICH_URL/IMMICH_API_KEY under environment:. docker-compose.yml is gitignored (it'll hold your real API key) -- docker-compose.yml.example is the one that's committed.
  3. Run the server:
    docker compose up -d
    
  4. 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.)
  5. 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, 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_len
  • GET /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 once refresh_interval_s has 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, ignoring refresh_interval_s, and resets the interval clock from now. Same response shape as /frame/image. Used by the device's next-photo button (see firmware/README.md).
  • GET /frame/config -- {"refresh_interval_s": ...}, polled by the frame each wake alongside its reachability check
  • GET /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_line2 are city / state-or-country if Immich reverse-geocoded the photo's GPS EXIF (both null if not) -- for US/Canada, the region line is the abbreviated state/province ("CA", "ON"); elsewhere it's the full country name. taken_at is MM/DD/YY from the photo's EXIF capture date, else null. Used by the device's manage button to build its overlay text
  • GET /frame/share/{asset_id} -- creates a 30-minute public, view-only Immich share link for asset_id and 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 used
  • GET /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, see app/face_labels.py); count: 0 if none are named. Used by the device manage button's escalated second menu level
  • GET /api/queue -- {"current": {...} | null, "upcoming": [...]}, each entry an asset id + thumbnail URL; used by the config UI
  • POST /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 request
  • POST /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 staleness
  • GET /api/photo-thumbnail/{asset_id} -- proxies an Immich thumbnail so the browser never needs the Immich API key directly
  • GET /health -- liveness check

Notes

  • Album/order/refresh-interval/current photo/upcoming queue/etc. are stored in ./data/config.json on the host via the compose volume mount. Immich URL/API key are too if set via the web UI, but IMMICH_URL/IMMICH_API_KEY env 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.
  • /frame/image, /frame/advance, /frame/photo-info, and /frame/share/{asset_id} aren'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. /frame/share at least is 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.
  • The 6-color palette RGB values in app/image_pipeline.py are 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.