Files
espresso_frame/server
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
..

ESPresso Frame Server

Pulls photos from an Immich 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.