Files
tfaour fe5a2df074
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m32s
Build and push server image / deploy (push) Failing after 1m28s
Update remaining docs for the second panel/board (missed in the previous commit)
docs/hardware.md and firmware/README.md were updated already; this
catches the root README, docs/architecture.md, docs/widgets.md, and
server/README.md -- all still described the project as single-panel/
single-chip (800x480, ESP32-C6 only) even after image_pipeline.py
stopped hardcoding that.
2026-08-04 20:50:29 +00:00

337 lines
19 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. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
only matters pre-setup: if set, it's the credential that gates who
gets to be the one to run first-run setup on a freshly deployed
server, before any admin account exists.
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 every supported board and publishes them as release assets
(`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/
`firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin`
duplicates for devices still on pre-rename firmware) 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 same header also determines which EPD panel the frame renders for
(`Frame.panel_type`, see `docs/hardware.md`'s board identifiers
section) -- also never a manual setting. 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).
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
server, port, username/password, from address, STARTTLS on/off; a
"send test email to myself" button, delivered to the admin's own
email); each user sets their own email in Settings. Once both are in
place: **"Forgot password?"** on the login page emails a one-hour
reset link (a generic "check your email" response either way, so the
endpoint can't be used to enumerate accounts), and a frame's
Configuration tab can set a **battery-alert threshold** -- an email to
the frame's owner the first time a report drops to or below it, not
again until a recharge is detected and it crosses again. No SMTP
configured, or no email on the relevant account, and both features
silently no-op rather than erroring.
- **Server logs.** `/admin/logs` shows the tail of the process's own
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
`/data` volume as the database and legacy config, so it survives
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
Rotates at ~2MB x 3 backups; the page only reads the current file,
"Download full log" streams it raw. There's no log shipping/
aggregation beyond this -- it's a single-container deployment, so
the file *is* the log.
## Endpoints
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
`/admin`, `/admin/logs`, `/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 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the
original 7.3" panel, 1600x1200/exactly 960,000 bytes for the 13.3"
panel (see `Frame.panel_type`/`image_pipeline.PANEL_SPECS`; a given
device's byte count is fixed by which firmware/panel it actually is).
**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/{manage_token}` -- creates a 30-minute public Immich
share link covering every photo widget's currently-showing photo on
*this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`.
- `GET /frame/face-labels` -- up to 4 named faces with positions in the
frame's own panel space (800x480 for the 7.3" panel, 1600x1200 for the
13.3"), 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. Also where
the battery-alert threshold (below) is checked and, at most once per
discharge cycle, emailed to the owner.
- `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`, `display_mode` (`crop_fill`/`crop_faces`/
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
`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`,
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
blue/green order --, `palette_reset` -- `true` clears back to the
default palette --, `color_boost`/`contrast_boost` -- PIL
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
0-1, blends toward a flat/undithered quantization before running
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
- `GET .../preview/original`, `GET .../preview/rendered` -- the
before/after comparison on the Configuration tab: the current
photo's Immich preview untouched (JPEG), and that same photo run
through this frame's actual saved rendering pipeline (PNG, upright
logical orientation, not packed device bytes) -- reflects saved
settings, not unsaved slider positions.
- `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 and the
scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
step 5 above).
- The calendar widget (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
never vendored or modified, so this project's own code stays under its
own license; LGPL's copyleft terms apply to that library itself, not
to code that merely links against it dynamically.
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
subscription) wraps the `caldav` PyPI package. `caldav` itself is
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
copyleft in this project's dependency tree, and the one whose
network-use clause is written specifically for server applications
like this one (not just "don't vendor/modify it," which was enough
reasoning for the LGPL dependency above). Taking this on was an
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small
Node.js sidecar using Excalidraw's own real export code
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
sidecar runs as a **second process inside this same container**
(`Dockerfile` installs Node, `start.sh` launches it in the background
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
Python process -- not a second docker-compose service, since it's
lightweight, stateless, and has nothing worth independently scaling or
restarting. License check (after getting burned once already in this
same file, on the CalDAV dependency below, into checking transitive
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
every one of its own runtime dependencies, `@resvg/resvg-js`
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
a real `npm install`/`docker build`** -- this project's dev environment
has no Node.js/npm, only network access to the npm registry API (used
to verify the above and pick real, current dependency versions). See
`render-service/README.md` for exactly what is and isn't verified.
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
(used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
skipping the codepoint. `app/calendar_render.py` draws emoji runs with
a vendored font instead (Google's Noto Emoji, OFL-1.1 -- license text
at `app/fonts/OFL.txt`), the one deliberate exception to this project's
usual "no new font/icon assets" default elsewhere in calendar_render.py
-- there's no way to hand-draw arbitrary emoji with primitives the way
the weather icons are. Full color (`app/fonts/NotoColorEmoji.ttf`,
embedded CBDT bitmap glyphs) is tried first and confirmed to hold up
fine through the panel's own Floyd-Steinberg dithering; a deployment
whose Pillow/FreeType wasn't built with embedded color bitmap support
falls back to a monochrome outline font (`app/fonts/NotoEmoji.ttf`)
instead of crashing or rendering nothing. Color glyphs are only stored
at one embedded bitmap size (109px), so they're rasterized once at
that size and scaled down to the target row height rather than drawn
directly like normal vector text.
- The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel).
Each frame's Configuration tab has an **Advanced configuration**
section (collapsed by default) with a color picker per ink color --
tune them once you can compare a rendered photo against the real
panel, and "Reset to defaults" to go back. Different panel units can
vary enough to be worth calibrating per frame.
## 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.
## Running tests
```
pip install -r requirements-dev.txt
pytest
```
Runs against a fresh temp SQLite database (`tests/conftest.py` sets
`DATABASE_URL` before anything imports `app.db`), with every table wiped
and reseeded (frame #1 + server settings, same as a real fresh install)
between tests -- no Docker, Node, or a real Immich/CalDAV/WebDAV server
needed; a few tests spin up small local HTTP servers as fixtures to
stand in for those. Also runs as its own job in
`.gitea/workflows/server-docker-build.yml`, gating the image build/push
-- a failing test suite blocks the push, not just decorates it.