Build and push server image / build-and-push (push) Successful in 35s
Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and restarts into provisioning -- the deliberate, USB-free replacement for the earlier reverted RST-based auto-reprovisioning idea. Next-photo (GPIO2, tap): wakes the device and forces the server to advance immediately via a new POST /frame/advance, instead of waiting for the refresh interval. Both buttons arm themselves as deep-sleep GPIO wakeup sources so a press is noticed promptly even while asleep. Also makes GET /frame/image side-effect-free: it now only advances once refresh_interval_s has elapsed since the current photo was set (tracked server-side), so a device reboot for any reason just redisplays the current photo instead of silently skipping ahead. The server maintains a small reorderable upcoming-photos queue, viewable and rearrangeable from the web UI.
110 lines
5.0 KiB
Markdown
110 lines
5.0 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. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
|
|
-> New API Key. Read-only access to albums/assets is enough.
|
|
2. **Copy the compose file and fill in your Immich details**:
|
|
```
|
|
cp docker-compose.yml.example docker-compose.yml
|
|
```
|
|
Edit `docker-compose.yml` and set `IMMICH_URL`/`IMMICH_API_KEY` under
|
|
`environment:`. `docker-compose.yml` is gitignored (it'll hold your real
|
|
API key) -- `docker-compose.yml.example` is the one that's committed.
|
|
3. **Run the server**:
|
|
```
|
|
docker compose up -d
|
|
```
|
|
4. Open `http://<this-machine>:8420/` in a browser, click **Load Albums**,
|
|
pick one, and **Save**. (The Immich URL/API key fields will already be
|
|
populated from the environment; changing them in the UI has no effect
|
|
as long as the env vars are set -- they win on every load.)
|
|
5. On the ESP32's captive portal setup form, set the **Tools Server** field
|
|
to `<this-machine>:8420`.
|
|
|
|
## Endpoints
|
|
|
|
- `GET /` -- config UI (album, order, refresh interval, face-aware crop
|
|
toggle, now-displaying + reorderable upcoming photos -- not Immich
|
|
URL/API key, see Setup above)
|
|
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
|
- `POST /api/config` -- saves album/order/refresh_interval_s/smart_crop_faces
|
|
- `GET /frame/image` -- returns the current photo 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 to the next photo once
|
|
`refresh_interval_s` has elapsed since the current one was set, so
|
|
calling it repeatedly (e.g. the device rebooting unexpectedly) just
|
|
redisplays the same photo instead of skipping ahead.
|
|
- `POST /frame/advance` -- forces an immediate advance to the next photo,
|
|
ignoring `refresh_interval_s`, and resets the interval clock from now.
|
|
Same response shape as `/frame/image`. Used by the device's next-photo
|
|
button (see `firmware/README.md`).
|
|
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
|
|
each wake alongside its reachability check
|
|
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each
|
|
entry an asset id + thumbnail URL; used by the config UI
|
|
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
|
|
`{"queue": [asset_id, ...]}`, must be exactly a permutation of the
|
|
current queue
|
|
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
|
|
the browser never needs the Immich API key directly
|
|
- `GET /health` -- liveness check
|
|
|
|
## Notes
|
|
|
|
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
|
|
stored in `./data/config.json` on the host via the compose volume
|
|
mount. Immich URL/API key are too if set via the web UI, but
|
|
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
|
|
precedence when present.
|
|
- The upcoming queue is a bounded lookahead (10 photos), not the whole
|
|
album -- it's topped up automatically as photos are consumed
|
|
(`app/photo_queue.py`), in sequential or shuffle order per the Order
|
|
setting. Reordering only rearranges those 10; it doesn't add or remove
|
|
photos from the album.
|
|
- `/frame/image` and `/frame/advance` aren't authenticated yet. That's
|
|
fine on a trusted home LAN for now, but worth revisiting once the ESP32
|
|
side is wired up to send a shared device token.
|
|
- 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.
|