Files
espresso_frame/server/README.md
T
tfaour 8ac3fc0de3
Build and push server image / build-and-push (push) Successful in 43s
Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
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).
2026-07-21 23:56:18 -04:00

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.