Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.
The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.
All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.
Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
216 lines
11 KiB
Markdown
216 lines
11 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. **Copy the compose file and run the server**:
|
|
```
|
|
cp docker-compose.yml.example docker-compose.yml
|
|
docker compose up -d
|
|
```
|
|
2. **First-run setup**: open `http://<this-machine>:8420/` -- you'll be
|
|
walked through creating the admin account. Every user has their own
|
|
login; the admin can enroll more from the Admin page (family members
|
|
can also self-enroll through the frame-claim flow, below).
|
|
3. **Connect your Immich library** (per user, in Settings): your Immich
|
|
URL and an API key. The key needs read access to
|
|
albums/assets/faces, plus `sharedLink.create` (for the on-frame
|
|
"scan to download" QR, which creates a temporary public share link)
|
|
-- a plain read-only key will 403 on that one feature while
|
|
everything else works. Frames you own pull from *your* library.
|
|
(`IMMICH_URL`/`IMMICH_API_KEY` env vars in `docker-compose.yml` still
|
|
work as an operator-level fallback and seed the first admin's
|
|
settings when migrating an older deployment.)
|
|
4. **Provision a frame**: power it on, join its `ESPRESSO_XXXXXX` WiFi
|
|
(instructions show on the panel), fill in your WiFi details and this
|
|
server's address (**Tools Server**, e.g. `<this-machine>:8420`).
|
|
After saving, your browser is redirected to this server's claim page
|
|
and the frame links to your account -- creating an account on the
|
|
spot if you don't have one (a valid frame is the invitation). The
|
|
server speaks plain HTTP itself -- for HTTPS, put a TLS-terminating
|
|
reverse proxy in front and enter the proxy's `https://` address
|
|
instead (see `firmware/README.md`'s HTTPS section).
|
|
5. **Each frame gets its own device token automatically** -- the server
|
|
issues it on the frame's first check-in, so there's nothing to
|
|
configure. The captive portal's **Access Token** field only matters
|
|
when pointing new firmware at an old (pre-multi-frame) server.
|
|
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
|
|
*migration* credential: a frame flashed with pre-multi-frame
|
|
firmware authenticates with it until it's updated and bound (the
|
|
Admin page shows the migration state per frame and a "Close legacy
|
|
window" button for when it's done).
|
|
6. **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 a frame's **Configuration** tab, set the
|
|
**Gitea repo URL**; 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) -- nothing to pick by hand. 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.
|
|
|
|
## Users, frames, and control
|
|
|
|
- **Users** log in with a session cookie; passwords are scrypt-hashed;
|
|
mutating requests are CSRF-protected. Sign-up paths: first-run setup
|
|
(admin #1), admin enrollment (Admin page), or the claim flow (a valid
|
|
unclaimed frame's `device_id` gates self-service signup).
|
|
- **Frames** identify themselves by `?id=` (MAC-derived) on every
|
|
request and authenticate with a per-frame device token the server
|
|
issues at first check-in. Unknown frames self-register as unclaimed;
|
|
claiming (via `/claim?device_id=...`) sets the owner -- whose Immich
|
|
library the frame renders from -- and links the account. Admins can
|
|
link additional users to any frame; every linked user sees it in
|
|
their sidebar.
|
|
- **Control** is a soft lock per frame: everyone linked can *view*;
|
|
changing settings/queue requires holding control, and "Take control"
|
|
always succeeds (the 409 error names the current holder). The
|
|
physical buttons on the frame ignore all of this.
|
|
- **The on-frame manage QR** opens a limited no-login page (`/m/<token>`):
|
|
view current + upcoming, "show next", advance, back -- nothing else.
|
|
The share QR stays public (it creates a 30-minute Immich share link
|
|
for exactly the photo shown).
|
|
|
|
## Endpoints
|
|
|
|
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
|
`/frames/{id}/stats`, `/m/{manage_token}`.
|
|
|
|
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
|
|
|
- `GET /frame/image` -- the frame's current image, 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 once `refresh_interval_s` has
|
|
elapsed since the current photo was set, so an unexpected reboot just
|
|
redisplays the same photo. An unclaimed or not-yet-configured frame
|
|
gets a rendered instruction placeholder (with a claim QR) instead of
|
|
an error, so a fresh device never error-loops.
|
|
- `POST /frame/advance` / `POST /frame/back` -- the next/back photo
|
|
buttons: force an immediate move (mirror images of each other; back
|
|
pops a bounded 20-entry history and pushes the displaced photo onto
|
|
the front of the queue). Same response shape as `/frame/image`.
|
|
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version":
|
|
... | null, "device_token": ...?}`, polled each wake. Captures the
|
|
`X-Frame-Version`/`X-Frame-Board` headers (running firmware + board
|
|
variant). `device_token` appears only during the one-time identity
|
|
handshake -- until the device authenticates with its issued token
|
|
once -- and the flat firmware parser's 512-byte buffer bounds how big
|
|
this response may grow.
|
|
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
|
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
|
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
|
|
share link and 302s to it; scoped to the photo currently showing or
|
|
queued on *this* frame only.
|
|
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
|
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
|
flat-scalar parser.
|
|
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
|
history (feeds the runtime estimate) plus a permanent per-frame
|
|
battery log (the Stats chart). Only sent on battery power.
|
|
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
|
|
|
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
|
|
|
- `GET .../queue` -- current + upcoming (each entry id + thumbnail
|
|
URL), the control state (`{"controller": name, "you": bool}`), and
|
|
the device telemetry block (`last_seen`, `overdue` -- quiet-hours
|
|
aware -- firmware versions, battery + runtime estimate).
|
|
- `POST .../queue/reorder|promote|remove` -- reorder is drift-tolerant
|
|
(stale ids dropped, missing ids appended); promote is "Show next";
|
|
remove permanently excludes from this frame's rotation (never touches
|
|
Immich) and advances if it was current.
|
|
- `GET .../albums` -- the owner's Immich albums.
|
|
- `POST .../config` -- **partial** update: only provided fields change
|
|
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
|
`refresh_interval_s`, `smart_crop_faces`, `queue_target_len`,
|
|
`orientation` (composed logically then rotated server-side; the
|
|
on-device manage overlay still renders native, a known limitation),
|
|
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
|
what `refresh_interval_s` gets handed to the device),
|
|
`firmware_update_repo_url`, `firmware_auto_update`).
|
|
- `POST .../take-control` -- always succeeds for a linked user.
|
|
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
|
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
|
`GET .../firmware/check` (throttled 15 min; `?force=true` bypasses),
|
|
`POST .../firmware/apply-latest`.
|
|
|
|
### Manage-QR API (`/api/m/{manage_token}/...` -- token in path, no login)
|
|
|
|
- `GET queue`, `POST promote`, `POST advance`, `POST back`,
|
|
`GET thumbnail/{asset_id}` (scoped to this frame's current/queued
|
|
photos). Nothing else.
|
|
|
|
- `GET /health` -- liveness check, always open.
|
|
|
|
## Notes
|
|
|
|
- All state (settings, current photo, upcoming queue, battery history,
|
|
stats) lives in a SQLite database at `./data/espresso.db` on the host
|
|
via the compose volume mount (`DATABASE_URL` env var to override --
|
|
any SQLAlchemy URL works, so a future move to Postgres is a config
|
|
change). A pre-database deployment's `./data/config.json` is imported
|
|
automatically on first boot (it becomes frame #1) and left untouched
|
|
afterwards as the rollback path. `IMMICH_URL`/`IMMICH_API_KEY` env
|
|
vars (see Setup above) still 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.
|
|
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
|
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
|
|
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
|
|
credential for pre-multi-frame firmware. `/frame/share` stays scoped
|
|
to photos this frame is actually showing or has queued, not any
|
|
Immich asset ID someone might guess -- a second layer a leaked device
|
|
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.
|