Files
espresso_frame/server/README.md
T
tfaour 3868d357ff
Build and push server image / build-and-push (push) Successful in 32s
Add back-photo button; consolidate reset/manage onto one hold-duration button
Back button (new GPIO0, POST /frame/back): the server now tracks a
bounded history of previously-current photos (photo_queue.py), pushed
to on every advance (auto or forced) and popped by back_forced() --
symmetric with advance, so pressing next afterwards returns to right
where you were. frame_client.c's force_advance bool becomes a 3-way
fetch_action_t (NORMAL/ADVANCE/BACK) threaded through the whole fetch
path.

Also folds the separate reset and manage buttons onto one pin
(combo_button.c, replacing reset_button.c/manage_button.c entirely),
disambiguated by hold duration: quick press shows the management menu
(unchanged), ~3s hold-then-release soft-resets (esp_restart(), config
kept -- new), ~15s hold factory-resets (today's old reset behavior,
extended from 10s for clearer tier separation). Driven by a production
board (Seeed XIAO ESP32-C6) exposing only 3 of the ESP32-C6's 8
deep-sleep-wakeup-capable GPIOs -- next/back keep their own dedicated
pins where instant response matters most, everything else shares the
third pin via timing instead of needing its own. Same three-pin layout
now works on both the dev board and the production board.

Fixed a fast-tap bug in combo_button_check() before shipping: it only
did a live gpio_get_level() read to decide whether the button was
pressed at all, so a press fast enough to already be released by the
time boot reached that check was missed entirely (treated as "never
pressed" rather than "quick press"). Added the same latched
esp_sleep_get_gpio_wakeup_status() check the other buttons already use
for exactly this reason.
2026-07-19 12:53:26 -04:00

177 lines
9.4 KiB
Markdown

# ESPresso Frame Server
Pulls photos from an [Immich](https://immich.app) 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. 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.
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`. 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's `https://` address instead (see
`firmware/README.md`'s HTTPS section for what the ESP32 side needs).
6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to 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; `/health` stays 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_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`). 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/back` below 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 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.
- Every endpoint except `/` and `/health` -- the web UI's `/api/*` and
every device-facing `/frame/*` -- requires `?token=` (or the
`mgmt_token` cookie the web UI sets after a valid one) once
`MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays
open like before, which is still fine on a trusted home LAN. `/frame/share`
additionally 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.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.