Build and push server image / build-and-push (push) Successful in 53s
The earlier fix stripped emoji instead of rendering them, which wasn't what was asked for. Event titles now draw with two fonts: the usual default font for text, and a vendored monochrome emoji font (Noto Emoji, OFL-1.1) for actual emoji runs, so they show up as real glyphs instead of a tofu box or nothing at all. Monochrome rather than color, since reliably rendering COLR/CBDT color glyphs depends on how Pillow's FreeType was built -- not something to depend on across deployments. Also: events sharing the exact same title and time across different calendars (e.g. a shared family event synced onto more than one person's calendar) now collapse into one row instead of showing twice, with a color bar split between every contributing calendar so it's still clear whose event it is.
280 lines
16 KiB
Markdown
280 lines
16 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).
|
|
- **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.
|
|
|
|
## 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. 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 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.
|
|
- Calendar frame mode (`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.
|
|
- 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 monochrome font instead (`app/fonts/NotoEmoji.ttf`, Google's
|
|
Noto Emoji, OFL-1.1 -- license text alongside it at
|
|
`app/fonts/NotoEmoji-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. Monochrome rather than a
|
|
color emoji font on purpose: reliably rendering COLR/CBDT color glyphs
|
|
depends on how Pillow's FreeType was built, which isn't something this
|
|
project controls in every deployment environment, and solid black
|
|
reads cleanly against everything else this module draws in black.
|
|
- 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.
|