Build and push server image / build-and-push (push) Successful in 39s
New Gitea Actions workflow builds both board variants and publishes them as release assets whenever firmware/version.txt is bumped. The server can now poll that repo's releases (next to the existing manual upload) and either surface an "Update frame" button or, with "Automatically apply updates" checked, stage the new build itself -- the frame still only updates on its own next wake either way.
271 lines
15 KiB
Markdown
271 lines
15 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 Settings form, set **Firmware Gitea
|
||
repo URL** to that repo (e.g. `https://git.example.com/owner/repo`) and
|
||
pick the frame's **board**; if the repo is private, also set
|
||
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`. 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
|
||
- `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. `{"enabled": false}` if no repo URL is
|
||
configured; otherwise `{"enabled": true, "latest_version": "1.2.3" | null,
|
||
"staged_version": "1.2.2" | null, "update_available": bool}`. 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. 404 if no repo is configured, has no releases, or the latest
|
||
release has no asset for the configured board variant
|
||
- `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.
|