Build and push server image / build-and-push (push) Successful in 32s
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.
177 lines
9.4 KiB
Markdown
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.
|