Build and push server image / build-and-push (push) Successful in 37s
All firmware-related controls (manual upload, Gitea repo URL, auto-update checkbox, detected board, Update frame button) now live in one "Firmware update" card instead of being split across the main Settings form and a separate card. The board variant used to pick a Gitea release asset was a dropdown the user had to set by hand and could get wrong. The device now reports it itself via a new X-Frame-Board header (CONFIG_FRAME_BOARD_NAME, "devkit" by default, "xiao" in sdkconfig.xiao) on every /frame/config poll, stored as device_board_variant -- the server learns it instead. Update checks/applies are gated on the board being known, since there's nothing to fetch until a device has checked in at least once.
282 lines
16 KiB
Markdown
282 lines
16 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).
|
||
7. **Optional: auto-update firmware from Gitea releases.** If you're
|
||
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
|
||
builds both supported boards and publishes them as release assets
|
||
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
|
||
changes on `main`. In the web UI's "Firmware update" card, set the
|
||
**Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`);
|
||
if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only
|
||
PAT) in `docker-compose.yml`. Which board's build to fetch is learned
|
||
from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME`
|
||
on the firmware side) -- nothing to pick by hand, though the frame
|
||
does need to have checked in at least once first. The server then
|
||
periodically checks for a newer release and either shows an "Update
|
||
frame" button or, with **Automatically apply updates** checked,
|
||
stages it itself -- either way the frame only actually updates on its
|
||
own next wake (see `POST /api/firmware` above).
|
||
|
||
## 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/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*/timezone.
|
||
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
|
||
`portrait_flipped`) matches how the frame is physically hung: photos
|
||
are composed/cropped for that shape (portrait crops at 480x800), then
|
||
rotated into the panel's native 800x480 byte layout server-side --
|
||
the device never knows. Note the device-side manage-menu overlay
|
||
(QRs, text, battery indicator, face labels) still renders in native
|
||
panel orientation, so on a portrait-hung frame it appears rotated
|
||
90° to the viewer -- QR codes scan fine at any rotation, but the text
|
||
reads sideways. A known limitation, not planned to change soon.
|
||
`quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end`
|
||
(`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch
|
||
the device at all -- purely a server decision about what
|
||
`refresh_interval_s` to hand back from `GET /frame/config` below,
|
||
computed in `_effective_refresh_interval_s`. Interpreted in the
|
||
`timezone` set from the web UI's "Timezone" dropdown (an IANA zone
|
||
name, e.g. `America/New_York`; defaults to `UTC`) -- no
|
||
docker-compose.yml edit or container restart needed to change it. The
|
||
device can still land one wake right
|
||
at the start of the window (nothing server-side can prevent that
|
||
without touching the firmware, since the device doesn't know wall-clock
|
||
time), but from that wake on it's told to sleep exactly until the
|
||
window ends. The "overdue" indicator in `/api/queue`'s `device` object
|
||
also accounts for this -- it won't falsely flag a device that's
|
||
legitimately sleeping through a long quiet-hours window
|
||
- `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": ..., "firmware_version": "1.2.3" | null}`,
|
||
polled by the frame each wake alongside its reachability check.
|
||
`firmware_version` is whatever's currently uploaded via
|
||
`POST /api/firmware` below (`null` if nothing's been uploaded) -- the
|
||
device compares it against its own running version
|
||
(`esp_app_get_description()->version`, sent as an `X-Frame-Version`
|
||
request header, stored as `device_firmware_version`) to decide whether
|
||
to OTA. The device also sends an `X-Frame-Board` header
|
||
(`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as
|
||
`device_board_variant` -- how the Gitea auto-update feature below
|
||
learns which board to fetch a release for, instead of a user picking
|
||
it
|
||
- `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
|
||
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
|
||
battery reading, stored with a timestamp plus two histories: a
|
||
per-discharge-cycle one (reset whenever a report jumps up enough to
|
||
look like a recharge) feeding the "on battery for"/estimate numbers,
|
||
and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2
|
||
years at hourly reports) feeding the web UI's battery graph. Only sent
|
||
when the device is actually running on battery (see
|
||
`firmware/README.md`'s Battery section) -- a frame on mains power
|
||
never reports
|
||
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
|
||
full permanent battery history above; used by the web UI's "Battery
|
||
history" chart
|
||
- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`,
|
||
`device_wakes`, `photos_displayed`, `photos_removed`,
|
||
`battery_reports`, `recharge_cycles`, `ota_updates_applied`,
|
||
`config_saves` (see `FrameStats` in `app/config.py`). Purely
|
||
informational -- nothing else reads these back -- shown in a
|
||
collapsed "Stats" section in the web UI
|
||
- `POST /api/firmware` -- multipart upload (`file`) of a built
|
||
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
|
||
anything that isn't a valid image for this project) and stores it as
|
||
the available firmware; devices pick it up via `GET /frame/config`
|
||
above on their next wake
|
||
- `GET /frame/firmware` -- streams back whatever was last uploaded via
|
||
`POST /api/firmware`, for the device's OTA fetch. 404 if nothing's
|
||
been uploaded yet
|
||
- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`,
|
||
15 min) check of the configured Gitea repo's latest release for the
|
||
frame's board variant (learned from the device, see `device_board_variant`
|
||
below -- not user-configured). `{"enabled": false}` if no repo URL is
|
||
configured; otherwise `{"enabled": true, "board": "xiao" | null,
|
||
"latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null,
|
||
"update_available": bool}`. `update_available` stays false until the
|
||
board is known, regardless of what Gitea has. If "Automatically apply
|
||
updates" is on and a newer release is found, this call also stages it
|
||
immediately (same effect as a manual upload) -- otherwise the web UI
|
||
shows an "Update frame" button
|
||
- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls
|
||
and stages the latest Gitea release right now, bypassing the check
|
||
throttle. 400 if no repo is configured or no device has checked in
|
||
yet (board unknown); 404 if the repo has no releases, or the latest
|
||
release has no asset for the frame's board
|
||
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
|
||
"device": {"last_seen": ts | null, "overdue": bool,
|
||
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
|
||
"battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null,
|
||
"battery_estimate_s": N | null}}`, each queue entry an asset id +
|
||
thumbnail URL; used by the config UI's "Device" panel
|
||
- `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
|
||
- `POST /api/queue/remove` -- permanently excludes a photo from this
|
||
frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich
|
||
or the album -- the photo just stops being selected by this frame
|
||
again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`).
|
||
Works on the current photo too, in which case it immediately advances
|
||
to a different one (without recording the removed photo in history --
|
||
going back to a photo you just removed wouldn't make sense). Used by
|
||
the "×" button in the web UI on both the current-photo thumbnail and
|
||
each upcoming card
|
||
- `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.
|