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.
337 lines
19 KiB
Markdown
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.
|