From 8ac3fc0de3be317a5c37804c8d200e3f819e6782 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 21 Jul 2026 23:56:18 -0400 Subject: [PATCH] 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 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). --- firmware/README.md | 29 +- server/README.md | 346 ++++---- server/app/auth.py | 57 ++ server/app/main.py | 67 +- server/app/routers/{api.py => api_frames.py} | 243 +++--- server/app/routers/common.py | 38 +- server/app/routers/frame_pages.py | 49 ++ server/app/routers/pages.py | 42 +- server/app/static/battery_chart.js | 89 ++ server/app/static/common.js | 97 +++ server/app/static/frame_config.js | 201 +++++ server/app/static/frame_photos.js | 126 +++ server/app/static/frame_stats.js | 118 +++ server/app/static/queue.js | 240 ++++++ server/app/static/theme.css | 470 +++++++++++ server/app/templates/_frame_tabs.html | 5 + server/app/templates/admin.html | 7 +- server/app/templates/app_base.html | 80 ++ server/app/templates/base.html | 390 +-------- server/app/templates/frame_config.html | 111 +++ server/app/templates/frame_photos.html | 60 ++ server/app/templates/frame_stats.html | 37 + server/app/templates/frames_empty.html | 20 + server/app/templates/index.html | 828 ------------------- server/app/templates/settings.html | 9 +- 25 files changed, 2147 insertions(+), 1612 deletions(-) rename server/app/routers/{api.py => api_frames.py} (59%) create mode 100644 server/app/routers/frame_pages.py create mode 100644 server/app/static/battery_chart.js create mode 100644 server/app/static/common.js create mode 100644 server/app/static/frame_config.js create mode 100644 server/app/static/frame_photos.js create mode 100644 server/app/static/frame_stats.js create mode 100644 server/app/static/queue.js create mode 100644 server/app/static/theme.css create mode 100644 server/app/templates/_frame_tabs.html create mode 100644 server/app/templates/app_base.html create mode 100644 server/app/templates/frame_config.html create mode 100644 server/app/templates/frame_photos.html create mode 100644 server/app/templates/frame_stats.html create mode 100644 server/app/templates/frames_empty.html delete mode 100644 server/app/templates/index.html diff --git a/firmware/README.md b/firmware/README.md index 829e447..329dd51 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -121,8 +121,14 @@ two-step setup screen: The config page asks for your home WiFi SSID/password, the "Tools Server" address (`host:port` of the [server](../server/) -- **not** your Immich server; see below for the `https://` form), and an optional -"Access Token" (see below). Saving reboots the device, which then -connects to your home network and starts its normal fetch/sleep cycle. +"Access Token" (see below -- usually blank). Saving hands your browser +off to the server's claim page (after ~7 seconds, giving your phone +time to rejoin its normal WiFi while the device reboots) so the frame +gets linked to your account; the device meanwhile connects to your home +network and starts its normal fetch/sleep cycle. The frame identifies +itself to the server by `?id=` (derived from its WiFi MAC) on every +request, and the server issues it a private per-frame token on first +contact -- no manual token handling involved. ## HTTP vs HTTPS @@ -179,15 +185,16 @@ perfectly valid cert for a different name. ## Access token -If the server has `MANAGEMENT_TOKEN` set (see -[`server/README.md`](../server/README.md)), it requires that same value -on every request -- the web UI *and* every device-facing request the -frame itself makes. Paste it into the captive portal's "Access Token" -field and the device sends it (`?token=...`) on every request -automatically, and bakes it into the manage-menu/share QR codes so -scanning them just works too. Leave it blank if the server has no -`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a- -trusted-LAN behavior from before. +Usually blank. Current servers issue each frame its own private token +automatically on first contact (delivered via `GET /frame/config`, +persisted in NVS, preferred by `build_url()` from then on -- and baked +into the manage-menu/share QR codes so scanning them just works). The +captive portal's "Access Token" field only matters when pointing this +firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN` +is set: paste that shared value and the device sends it (`?token=...`) +until a newer server replaces it with a per-frame one. Re-provisioning +clears any stored per-frame token -- a fresh identity handshake with +whatever server you point it at next. ## Skipping to the next photo diff --git a/server/README.md b/server/README.md index 4022ae9..9d5d996 100644 --- a/server/README.md +++ b/server/README.md @@ -8,218 +8,150 @@ 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. Needs read access to albums/assets/faces, plus - `sharedLink.create` (for the manage overlay's "scan to download" QR, - which creates a temporary public share link) -- a plain read-only key - will 403 on that one specific feature while everything else works. -2. **Copy the compose file and fill in your Immich details**: +1. **Copy the compose file and run the server**: ``` 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://: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 `:8420`. This server always speaks plain HTTP itself -- - for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of - it and enter the proxy's `https://` address instead (see - `firmware/README.md`'s HTTPS section for what the ESP32 side needs). -6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate - the *entire server* -- the web UI (`/`, `/api/*`) and every - device-facing `/frame/*` endpoint -- behind a shared secret (leave - unset to keep it all open, the previous default -- fine on a trusted - LAN). If set, paste the same value into the ESP32's captive portal - setup form's **Access Token** field: the device then sends it on - every request it makes, and the manage-menu/share QR codes embed it - automatically (`?token=...`) so scanning them just works. Visiting - the web UI without a valid token in the URL shows a plain token-entry - prompt instead of the config UI; `/health` stays open regardless - (pure liveness, nothing sensitive in it). -7. **Optional: auto-update firmware from Gitea releases.** If you're +2. **First-run setup**: open `http://: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. `: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 the web UI's "Firmware update" card, set the - **Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`); - 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, `CONFIG_FRAME_BOARD_NAME` - on the firmware side) -- nothing to pick by hand, though the frame - does need to have checked in at least once first. The server then + 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 (see `POST /api/firmware` above). + 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/`): + 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 -- `GET /` -- config UI (album, order, refresh interval, face-aware crop - toggle, upcoming-photos count, now-displaying + drag-to-reorder - upcoming grid -- 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/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*/timezone. - `orientation` (`landscape`, `portrait`, `landscape_flipped`, - `portrait_flipped`) matches how the frame is physically hung: photos - are composed/cropped for that shape (portrait crops at 480x800), then - rotated into the panel's native 800x480 byte layout server-side -- - the device never knows. Note the device-side manage-menu overlay - (QRs, text, battery indicator, face labels) still renders in native - panel orientation, so on a portrait-hung frame it appears rotated - 90° to the viewer -- QR codes scan fine at any rotation, but the text - reads sideways. A known limitation, not planned to change soon. - `quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end` - (`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch - the device at all -- purely a server decision about what - `refresh_interval_s` to hand back from `GET /frame/config` below, - computed in `_effective_refresh_interval_s`. Interpreted in the - `timezone` set from the web UI's "Timezone" dropdown (an IANA zone - name, e.g. `America/New_York`; defaults to `UTC`) -- no - docker-compose.yml edit or container restart needed to change it. The - device can still land one wake right - at the start of the window (nothing server-side can prevent that - without touching the firmware, since the device doesn't know wall-clock - time), but from that wake on it's told to sleep exactly until the - window ends. The "overdue" indicator in `/api/queue`'s `device` object - also accounts for this -- it won't falsely flag a device that's - legitimately sleeping through a long quiet-hours window -- `GET /frame/image` -- returns the current photo pre-processed into the - panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format +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 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`). Every photo actually displayed this - way (or via the normal timer-based advance) is pushed onto a bounded - history (`app/photo_queue.py`, last 20) that `/frame/back` below can - return to. -- `POST /frame/back` -- returns to the previously-current photo (the - exact mirror of `/frame/advance`), and resets the interval clock from - now. A no-op (still 200, same photo) if there's no history yet. - Pressing advance afterwards returns to where you were before going - back -- it displaces the current photo onto the front of the upcoming - queue rather than discarding it. Same response shape as - `/frame/image`. Used by the device's back-photo button. -- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`, - polled by the frame each wake alongside its reachability check. - `firmware_version` is whatever's currently uploaded via - `POST /api/firmware` below (`null` if nothing's been uploaded) -- the - device compares it against its own running version - (`esp_app_get_description()->version`, sent as an `X-Frame-Version` - request header, stored as `device_firmware_version`) to decide whether - to OTA. The device also sends an `X-Frame-Board` header - (`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as - `device_board_variant` -- how the Gitea auto-update feature below - learns which board to fetch a release for, instead of a user picking - it -- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... | - null, "location_line2": ... | null, "taken_at": ... | null}` for the - current photo (same idempotent current-photo semantics as - `/frame/image`). `location_line1`/`location_line2` are `city` / - `state-or-country` if Immich reverse-geocoded the photo's GPS EXIF - (both `null` if not) -- for US/Canada, the region line is the - abbreviated state/province (`"CA"`, `"ON"`); elsewhere it's the full - country name. `taken_at` is `MM/DD/YY` from the photo's EXIF capture - date, else `null`. Used by the device's manage button to build its - overlay text -- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only - Immich share link for `asset_id` and redirects (302) to it. Only works - for the photo currently showing or in the upcoming queue on this frame - -- not any arbitrary Immich asset. The link is created on first hit - (i.e. when someone actually scans the manage overlay's share QR), not - when the button's pressed, so the 30-minute window starts when it's - actually used -- `GET /frame/face-labels` -- `{"count": N, "name_0": ..., "x_0": ..., - "y_0": ..., ...}` (up to 4 slots) -- named people from Immich's face - recognition, positioned in final 800x480 frame pixel space. Only faces - Immich already has an identified name for are included (no face - detection/recognition happens in this project, see - `app/face_labels.py`); `count: 0` if none are named. Used by the - device manage button's escalated second menu level -- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last - battery reading, stored with a timestamp plus two histories: a - per-discharge-cycle one (reset whenever a report jumps up enough to - look like a recharge) feeding the "on battery for"/estimate numbers, - and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2 - years at hourly reports) feeding the web UI's battery graph. Only sent - when the device is actually running on battery (see - `firmware/README.md`'s Battery section) -- a frame on mains power - never reports -- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the - full permanent battery history above; used by the web UI's "Battery - history" chart -- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`, - `device_wakes`, `photos_displayed`, `photos_removed`, - `battery_reports`, `recharge_cycles`, `ota_updates_applied`, - `config_saves` (see `FrameStats` in `app/config.py`). Purely - informational -- nothing else reads these back -- shown in a - collapsed "Stats" section in the web UI -- `POST /api/firmware` -- multipart upload (`file`) of a built - `espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects - anything that isn't a valid image for this project) and stores it as - the available firmware; devices pick it up via `GET /frame/config` - above on their next wake -- `GET /frame/firmware` -- streams back whatever was last uploaded via - `POST /api/firmware`, for the device's OTA fetch. 404 if nothing's - been uploaded yet -- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`, - 15 min) check of the configured Gitea repo's latest release for the - frame's board variant (learned from the device, see `device_board_variant` - below -- not user-configured). `?force=true` (the "Check now" button) - bypasses the throttle. `{"enabled": false}` if no repo URL is - configured; otherwise `{"enabled": true, "board": "xiao" | null, - "latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null, - "update_available": bool}`. `update_available` stays false until the - board is known, regardless of what Gitea has. If "Automatically apply - updates" is on and a newer release is found, this call also stages it - immediately (same effect as a manual upload) -- otherwise the web UI - shows an "Update frame" button -- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls - and stages the latest Gitea release right now, bypassing the check - throttle. 400 if no repo is configured or no device has checked in - yet (board unknown); 404 if the repo has no releases, or the latest - release has no asset for the frame's board -- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...], - "device": {"last_seen": ts | null, "overdue": bool, - "firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null, - "battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null, - "battery_estimate_s": N | null}}`, each queue entry an asset id + - thumbnail URL; used by the config UI's "Device" panel -- `POST /api/queue/reorder` -- reorders the upcoming queue; body is - `{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having - changed server-side since the client's last fetch (e.g. a top-up/trim) - -- unrecognized IDs in the body are dropped, and any currently-queued - photo missing from the body is appended rather than lost, instead of - rejecting the whole request -- `POST /api/queue/promote` -- moves one photo to the front of the queue; - body is `{"asset_id": "..."}`. Used by "Show next" in the web UI -- - unlike `/reorder`, doesn't depend on the client knowing the queue's - full current order, so it can't fail from staleness -- `POST /api/queue/remove` -- permanently excludes a photo from this - frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich - or the album -- the photo just stops being selected by this frame - again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`). - Works on the current photo too, in which case it immediately advances - to a different one (without recording the removed photo in history -- - going back to a photo you just removed wouldn't make sense). Used by - the "×" button in the web UI on both the current-photo thumbnail and - each upcoming card -- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so - the browser never needs the Immich API key directly -- `GET /health` -- liveness check + 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 @@ -239,15 +171,13 @@ algorithm itself -- it just streams the response straight to the panel. 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. -- Every endpoint except `/` and `/health` -- the web UI's `/api/*` and - every device-facing `/frame/*` -- requires `?token=` (or the - `mgmt_token` cookie the web UI sets after a valid one) once - `MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays - open like before, which is still fine on a trusted home LAN. `/frame/share` - additionally stays scoped to only ever create a link for a photo this - frame is actually showing or has queued, not any Immich asset ID - someone might guess -- a second layer a leaked token alone wouldn't - bypass. +- 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 diff --git a/server/app/auth.py b/server/app/auth.py index 76eac85..69d5461 100644 --- a/server/app/auth.py +++ b/server/app/auth.py @@ -156,6 +156,63 @@ def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User: return user +def user_frames(db: Session, user: User) -> list[Frame]: + """The frames this user sees in their sidebar: linked ones, or all of + them for an admin (admins are the household operators -- they see + unclaimed/new frames too, that's how those get adopted).""" + if user.is_admin: + return list(db.scalars(select(Frame).order_by(Frame.id))) + return list( + db.scalars( + select(Frame) + .join(UserFrame, UserFrame.frame_id == Frame.id) + .where(UserFrame.user_id == user.id) + .order_by(Frame.id) + ) + ) + + +def can_view_frame(db: Session, user: User, frame: Frame) -> bool: + return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None + + +def require_frame_view( + frame_id: int, request: Request, db: Session = Depends(get_db) +) -> Frame: + """JSON-API dependency: a logged-in user who is linked to this frame + (or an admin). 404 -- not 403 -- for frames outside the user's view, + so the API doesn't confirm which frame ids exist.""" + user = require_user_api(request, db) + frame = db.get(Frame, frame_id) + if frame is None or not can_view_frame(db, user, frame): + raise HTTPException(404, "No such frame") + return frame + + +def require_frame_control( + frame_id: int, request: Request, db: Session = Depends(get_db) +) -> Frame: + """View access plus the soft control lock: only the user currently + holding control may mutate settings/queue. The 409 payload names the + holder so the UI can offer "take control" instead of a dead end. + Physical device buttons don't go through this -- device actions are + device actions.""" + user = require_user_api(request, db) + frame = db.get(Frame, frame_id) + if frame is None or not can_view_frame(db, user, frame): + raise HTTPException(404, "No such frame") + if frame.controlled_by_user_id != user.id: + holder = frame.controlled_by + raise HTTPException( + 409, + { + "error": "not_controller", + "holder": (holder.display_name or holder.username) if holder else None, + }, + ) + return frame + + def management_token() -> str: """The legacy shared secret. Env-only, never stored -- same as the old server, where the env var overrode anything on disk on every load.""" diff --git a/server/app/main.py b/server/app/main.py index 97cf24e..5e00bd3 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,11 +1,14 @@ """ESPresso Frame server: pulls photos from Immich, pre-processes them for the panel, and serves ESP32 frames ready-to-display images. -This module is assembly only -- routes live in app/routers/ (device.py -for the firmware-facing /frame/* protocol, api.py for the web UI's -/api/*, pages.py for setup/login/settings/admin), storage in SQLite via -models.py/db.py, with migration.py importing a pre-database config.json -deployment on first boot.""" +This module is assembly only -- routes live in app/routers/: + device.py the firmware-facing /frame/* protocol (paths frozen) + api_frames.py the web UI's JSON API, /api/frames/{id}/... + frame_pages.py the per-frame Photos/Configuration/Stats pages + pages.py setup/login/claim/settings/admin + manage.py the limited manage-QR surface (/m/, /api/m/) +Storage is SQLite via models.py/db.py; migration.py imports a +pre-database config.json deployment on first boot.""" from __future__ import annotations @@ -13,23 +16,22 @@ import logging from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates - from sqlalchemy import select from . import migration from .auth import ( browser_token_valid, - current_session, current_user, management_token, + user_frames, users_exist, ) from .db import SessionLocal from .models import Frame -from .quiet_hours import ALL_TIMEZONES -from .routers import api, device, manage, pages -from .routers.common import default_frame, immich_creds +from .routers import api_frames, device, frame_pages, manage, pages +from .routers.common import shell_context logger = logging.getLogger(__name__) @@ -39,17 +41,25 @@ migration.run_migrations() app = FastAPI(title="ESPresso Frame Server") templates = Jinja2Templates(directory="app/templates") +app.mount("/static", StaticFiles(directory="app/static"), name="static") + app.include_router(device.router) -app.include_router(api.router) +app.include_router(api_frames.router) +app.include_router(frame_pages.router) app.include_router(pages.router) app.include_router(manage.router) +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None: """The on-frame manage QR points at the server root with the device's own credentials (new firmware: ?id=&token=; deployed firmware: ?token=). Those scans get the frame's limited - manage page -- never the full UI, which now requires a login. + manage page -- never the full UI, which requires a login. allow_legacy is False before /setup has run: at that point a bare ?token= hit is the admin coming through the token prompt to do first-run setup, not a QR scan.""" @@ -68,19 +78,11 @@ def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str return None -@app.get("/health") -def health() -> dict: - return {"status": "ok"} - - @app.get("/", response_class=HTMLResponse) def index(request: Request): - """The web UI (still the single-frame page until Phase D). Access: - a user session (the normal path once /setup has run), or -- only - while no users exist AND no MANAGEMENT_TOKEN is configured -- fully - open, the original trusted-LAN default. A hit carrying device - credentials (the on-frame manage QR) redirects to that frame's - limited manage page instead.""" + """Routing hub: manage-QR scans go to the limited manage page, users + land on their first frame (or an empty-state page), and everyone + else is walked through setup/login.""" with SessionLocal() as db: have_users = users_exist(db) manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users) @@ -88,8 +90,6 @@ def index(request: Request): return RedirectResponse(manage_redirect, status_code=303) user = current_user(request, db) - session = current_session(request, db) if user else None - if user is None: if not have_users: if management_token() and not browser_token_valid(request): @@ -101,16 +101,7 @@ def index(request: Request): return RedirectResponse("/setup", status_code=303) return RedirectResponse("/login", status_code=303) - frame = default_frame(db) - immich_url, _ = immich_creds(frame) - return templates.TemplateResponse( - "index.html", - { - "request": request, - "cfg": frame, - "immich_url": immich_url, - "timezones": ALL_TIMEZONES, - "user": user, - "csrf_token": session.csrf_token if session else None, - }, - ) + frames = user_frames(db, user) + if frames: + return RedirectResponse(f"/frames/{frames[0].id}", status_code=303) + return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user)) diff --git a/server/app/routers/api.py b/server/app/routers/api_frames.py similarity index 59% rename from server/app/routers/api.py rename to server/app/routers/api_frames.py index 902afc3..7b79b28 100644 --- a/server/app/routers/api.py +++ b/server/app/routers/api_frames.py @@ -1,7 +1,17 @@ -"""Browser-facing /api/* routes -- Phase A keeps the old single-frame -paths, resolved to the default frame (frame #1), behind the legacy -shared-token gate. Phase B moves auth to sessions; Phase D moves paths -to /api/frames/{id}/... together with the new multi-frame UI.""" +"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/... + +Auth: session-only (require_frame_view for reads, require_frame_control +for mutations -- the "take control" soft lock). The limited manage-QR +surface lives separately under /api/m/ (routers/manage.py), and device +traffic under /frame/* (routers/device.py). + +Config saves are PARTIAL updates: each page's form posts only its own +fields (the old single Settings form split across the Photos and +Configuration tabs), so every field is optional and only provided ones +are touched. Checkboxes are sent explicitly as "true"/"false" strings by +the page JS -- an absent field means "not this form's field", never +"unchecked". +""" from __future__ import annotations @@ -9,21 +19,20 @@ import logging import time import httpx -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session from .. import gitea_releases, photo_queue, quiet_hours -from ..auth import require_browser +from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, Frame from .common import ( OVERDUE_FACTOR, battery_estimate_s, - default_frame, immich_client_for, immich_creds, list_assets, @@ -32,7 +41,7 @@ from .common import ( logger = logging.getLogger(__name__) -router = APIRouter(dependencies=[Depends(require_browser)]) +router = APIRouter() MIN_REFRESH_INTERVAL_S = 60 MAX_REFRESH_INTERVAL_S = 86400 @@ -42,12 +51,11 @@ MAX_QUEUE_TARGET_LEN = 5000 ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped") -@router.get("/api/albums") -def api_albums(db: Session = Depends(get_db)): - frame = default_frame(db) +@router.get("/api/frames/{frame_id}/albums") +def api_albums(frame: Frame = Depends(require_frame_view)): url, key = immich_creds(frame) if not url or not key: - raise HTTPException(400, "Immich URL/API key not configured yet") + raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)") try: albums = immich_client_for(frame).list_albums() except httpx.HTTPError as e: @@ -55,57 +63,82 @@ def api_albums(db: Session = Depends(get_db)): return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums] -@router.post("/api/config") +@router.post("/api/frames/{frame_id}/config") def api_config_save( - album_id: str = Form(""), - order: str = Form("sequential"), - refresh_interval_s: int = Form(3600), - smart_crop_faces: bool = Form(True), - queue_target_len: int = Form(20), - orientation: str = Form("landscape"), - quiet_hours_enabled: bool = Form(False), - quiet_hours_start: str = Form("22:00"), - quiet_hours_end: str = Form("07:00"), - timezone: str = Form("UTC"), - firmware_update_repo_url: str = Form(""), - firmware_auto_update: bool = Form(False), + name: str | None = Form(None), + album_id: str | None = Form(None), + order: str | None = Form(None), + refresh_interval_s: int | None = Form(None), + smart_crop_faces: bool | None = Form(None), + queue_target_len: int | None = Form(None), + orientation: str | None = Form(None), + quiet_hours_enabled: bool | None = Form(None), + quiet_hours_start: str | None = Form(None), + quiet_hours_end: str | None = Form(None), + timezone: str | None = Form(None), + firmware_update_repo_url: str | None = Form(None), + firmware_auto_update: bool | None = Form(None), + frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), ): - # Immich creds are per-user (Phase B) / env-fallback -- this handler - # deliberately never touches them, same as the old env-only rule. - frame = default_frame(db) with frame_locked(db, frame.id) as cfg: - if album_id != cfg.album_id: - # A newly selected album starts clean -- the old current photo and - # queue don't mean anything in the new album's context. + if name is not None: + cfg.name = name.strip()[:64] or cfg.name + if album_id is not None and album_id != cfg.album_id: + # A newly selected album starts clean -- the old current photo + # and queue don't mean anything in the new album's context. cfg.current_asset_id = "" cfg.current_asset_set_at = 0.0 cfg.queue = [] cfg.queue_cursor = 0 cfg.history = [] cfg.excluded_asset_ids = [] - cfg.album_id = album_id - cfg.order = order if order in ("sequential", "shuffle") else "sequential" - cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)) - cfg.smart_crop_faces = smart_crop_faces - cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) - cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape" - cfg.quiet_hours_enabled = quiet_hours_enabled - if quiet_hours.valid_hhmm(quiet_hours_start): + cfg.album_id = album_id + if order is not None: + cfg.order = order if order in ("sequential", "shuffle") else "sequential" + if refresh_interval_s is not None: + cfg.refresh_interval_s = max( + MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s) + ) + if smart_crop_faces is not None: + cfg.smart_crop_faces = smart_crop_faces + if queue_target_len is not None: + cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len)) + if orientation is not None: + cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape" + if quiet_hours_enabled is not None: + cfg.quiet_hours_enabled = quiet_hours_enabled + if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start): cfg.quiet_hours_start = quiet_hours_start - if quiet_hours.valid_hhmm(quiet_hours_end): + if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end): cfg.quiet_hours_end = quiet_hours_end - if timezone in quiet_hours.ALL_TIMEZONES: + if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES: cfg.timezone = timezone - cfg.firmware_update_repo_url = firmware_update_repo_url.strip() - cfg.firmware_auto_update = firmware_auto_update + if firmware_update_repo_url is not None: + cfg.firmware_update_repo_url = firmware_update_repo_url.strip() + if firmware_auto_update is not None: + cfg.firmware_auto_update = firmware_auto_update cfg.stats_config_saves += 1 return {"status": "saved"} -@router.get("/api/stats") -def api_stats(db: Session = Depends(get_db)): - frame = default_frame(db) +@router.post("/api/frames/{frame_id}/take-control") +def api_take_control( + request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) +): + """Always succeeds for any linked user -- the lock is deliberately + soft. The previous holder just sees who has it now.""" + user = require_user_api(request, db) + previous = frame.controlled_by + frame.controlled_by_user_id = user.id + db.commit() + logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id, + previous.username if previous else "nobody") + return {"status": "saved", "controller": user.display_name or user.username} + + +@router.get("/api/frames/{frame_id}/stats") +def api_stats(frame: Frame = Depends(require_frame_view)): return { "first_seen": frame.stats_first_seen, "device_wakes": frame.stats_device_wakes, @@ -118,9 +151,11 @@ def api_stats(db: Session = Depends(get_db)): } -@router.get("/api/queue") -def api_queue(db: Session = Depends(get_db)): - frame = default_frame(db) +@router.get("/api/frames/{frame_id}/queue") +def api_queue( + request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) +): + user = require_user_api(request, db) require_configured(frame) client = immich_client_for(frame) @@ -140,15 +175,25 @@ def api_queue(db: Session = Depends(get_db)): "battery_as_of": cfg.battery_as_of, "on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None, "battery_estimate_s": battery_estimate_s(cfg), + "controller_id": cfg.controlled_by_user_id, + "controller": ( + (cfg.controlled_by.display_name or cfg.controlled_by.username) + if cfg.controlled_by + else None + ), } def entry(asset_id: str) -> dict: - return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"} + return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"} now = time.time() return { "current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None, "upcoming": [entry(asset_id) for asset_id in snapshot["queue"]], + "control": { + "controller": snapshot["controller"], + "you": snapshot["controller_id"] == user.id, + }, "device": { "last_seen": snapshot["last_seen"] or None, "overdue": bool( @@ -167,9 +212,8 @@ def api_queue(db: Session = Depends(get_db)): } -@router.get("/api/battery-log") -def api_battery_log(db: Session = Depends(get_db)): - frame = default_frame(db) +@router.get("/api/frames/{frame_id}/battery-log") +def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): rows = db.execute( select(BatteryLog.ts, BatteryLog.percent) .where(BatteryLog.frame_id == frame.id) @@ -182,15 +226,18 @@ class QueueReorderRequest(BaseModel): queue: list[str] -@router.post("/api/queue/reorder") -def api_queue_reorder(body: QueueReorderRequest, db: Session = Depends(get_db)): +@router.post("/api/frames/{frame_id}/queue/reorder") +def api_queue_reorder( + body: QueueReorderRequest, + frame: Frame = Depends(require_frame_control), + db: Session = Depends(get_db), +): """Applies the client's requested order, tolerating drift between the browser's last-fetched snapshot and the server's current queue (e.g. a top-up/trim landed in between) instead of hard-rejecting: any ID the client sent that's no longer actually queued is dropped, and any ID the server has that the client didn't know about is appended rather than lost.""" - frame = default_frame(db) with frame_locked(db, frame.id) as cfg: current_set = set(cfg.queue) reordered = [asset_id for asset_id in body.queue if asset_id in current_set] @@ -203,14 +250,15 @@ class QueuePromoteRequest(BaseModel): asset_id: str -@router.post("/api/queue/promote") -def api_queue_promote(body: QueuePromoteRequest, db: Session = Depends(get_db)): - """Moves a single photo to the front of the queue -- "Show next" in - the web UI. Unlike /api/queue/reorder, this doesn't depend on the - client supplying a full, exactly-current snapshot of the queue at - all, so it can't fail due to the queue having shifted server-side - since the browser's last fetch.""" - frame = default_frame(db) +@router.post("/api/frames/{frame_id}/queue/promote") +def api_queue_promote( + body: QueuePromoteRequest, + frame: Frame = Depends(require_frame_control), + db: Session = Depends(get_db), +): + """Moves a single photo to the front of the queue -- "Show next". + Unlike reorder, doesn't depend on the client knowing the queue's + exact current order, so it can't fail from staleness.""" with frame_locked(db, frame.id) as cfg: if body.asset_id not in cfg.queue: raise HTTPException(400, "That photo is no longer in the upcoming queue") @@ -222,14 +270,15 @@ class QueueRemoveRequest(BaseModel): asset_id: str -@router.post("/api/queue/remove") -def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)): - """Permanently removes a photo from this frame's rotation -- "Remove" - in the web UI, on either an upcoming card or the current photo. Does - NOT touch Immich or the album itself; see photo_queue.remove_from_rotation().""" - frame = default_frame(db) +@router.post("/api/frames/{frame_id}/queue/remove") +def api_queue_remove( + body: QueueRemoveRequest, + frame: Frame = Depends(require_frame_control), + db: Session = Depends(get_db), +): + """Permanently removes a photo from this frame's rotation. Does NOT + touch Immich or the album itself; see photo_queue.remove_from_rotation().""" require_configured(frame) - client = immich_client_for(frame) assets = list_assets(client, frame) @@ -238,9 +287,8 @@ def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)): return {"status": "removed"} -@router.get("/api/photo-thumbnail/{asset_id}") -def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)): - frame = default_frame(db) +@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}") +def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)): require_configured(frame) client = immich_client_for(frame) try: @@ -250,13 +298,16 @@ def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)): return Response(content=content, media_type=content_type) -@router.post("/api/firmware") -def api_firmware_upload(file: UploadFile = File(...), db: Session = Depends(get_db)): +@router.post("/api/frames/{frame_id}/firmware") +def api_firmware_upload( + file: UploadFile = File(...), + frame: Frame = Depends(require_frame_control), + db: Session = Depends(get_db), +): """Uploads a firmware image for OTA. The version is parsed out of the image itself (esp_app_desc_t) rather than trusted from a filename or form field, and the project name is checked so an unrelated .bin can't be pushed to the frame by mistake.""" - frame = default_frame(db) data = file.file.read() version = parse_app_version(data) path = firmware_path(frame.id) @@ -278,11 +329,9 @@ def _fetch_latest_release(frame: Frame) -> dict | None: def _apply_gitea_update(db: Session, frame: Frame) -> str: """Downloads the configured Gitea repo's latest release asset for this - frame's board variant and stages it exactly like a manual upload - would. The board comes from the device itself (device_board_variant, - learned from its X-Frame-Board header), not a user picker, so - there's nothing to fetch until the device has checked in at least - once. Network I/O happens before the lock is taken.""" + frame's board variant (learned from the device's X-Frame-Board + header, never picked by hand) and stages it exactly like a manual + upload. Network I/O happens before the lock is taken.""" if not frame.device_board_variant: raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch") release = _fetch_latest_release(frame) @@ -307,18 +356,15 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str: return version -@router.get("/api/firmware/check") -def api_firmware_check(force: bool = False, db: Session = Depends(get_db)): +@router.get("/api/frames/{frame_id}/firmware/check") +def api_firmware_check( + force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) +): """Throttled check of the configured Gitea repo's latest release - (gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads - the release's tag name, not its binaries. If firmware_auto_update is + (gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is on and a newer version is found, applies it immediately; otherwise - just reports it so the web UI can offer the "Update frame" button. - - force=true (the "Check now" button) bypasses the throttle and always - hits Gitea -- otherwise a genuinely new release can sit invisible in - the UI for up to the full throttle interval.""" - frame = default_frame(db) + just reports it so the UI can offer the "Update frame" button. + force=true (the "Check now" button) bypasses the throttle.""" if not frame.firmware_update_repo_url: return {"enabled": False} @@ -351,13 +397,14 @@ def api_firmware_check(force: bool = False, db: Session = Depends(get_db)): } -@router.post("/api/firmware/apply-latest") -def api_firmware_apply_latest(db: Session = Depends(get_db)): +@router.post("/api/frames/{frame_id}/firmware/apply-latest") +def api_firmware_apply_latest( + frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) +): """The "Update frame" button: applies the latest Gitea release right - now, bypassing the check throttle -- this is an explicit user action, - not a background poll.""" - frame = default_frame(db) + now, bypassing the check throttle -- an explicit user action, not a + background poll.""" if not frame.firmware_update_repo_url: raise HTTPException(400, "No Gitea firmware repo configured") version = _apply_gitea_update(db, frame) - return {"status": "saved", "version": version} + return {"status": "saved", "version": version} \ No newline at end of file diff --git a/server/app/routers/common.py b/server/app/routers/common.py index c8173f4..04ed6e8 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -106,15 +106,29 @@ def battery_estimate_s(frame: Frame) -> int | None: return int(last_pct / rate) -def default_frame(db: Session) -> Frame: - """Phase A only: the old single-frame /api/* routes all operate on - "the" frame -- the legacy one if flagged, else the lowest id. - Replaced by explicit /api/frames/{id}/ paths in Phase D.""" - frame = db.scalars( - select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 - ).first() - if frame is None: - frame = db.scalars(select(Frame).order_by(Frame.id).limit(1)).first() - if frame is None: - raise HTTPException(404, "No frame exists yet") - return frame +def shell_context(request, db: Session, user, active_frame: Frame | None = None, + active_nav: str | None = None) -> dict: + """Template context every app-shell (sidebar) page needs: the user's + frame list with an online indicator, the active highlights, and the + session's CSRF token. Import here (not auth) keeps the router + modules' template plumbing in one place.""" + import time as _time + + from .. import quiet_hours + from ..auth import current_session, user_frames + + session = current_session(request, db) + frames = user_frames(db, user) + now = _time.time() + for f in frames: + # Same "not overdue" definition the Device panel uses. + gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR + f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap) + return { + "request": request, + "user": user, + "csrf_token": session.csrf_token if session else None, + "sidebar_frames": frames, + "active_frame": active_frame, + "active_nav": active_nav, + } diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py new file mode 100644 index 0000000..d8f4148 --- /dev/null +++ b/server/app/routers/frame_pages.py @@ -0,0 +1,49 @@ +"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and +Stats tabs, all inside the sidebar app shell. Data loading happens +client-side against /api/frames/{id}/... (routers/api_frames.py); these +routes just authorize and render the scaffold.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from ..auth import can_view_frame, current_user +from ..db import get_db +from ..models import Frame +from ..quiet_hours import ALL_TIMEZONES +from .common import shell_context + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra): + user = current_user(request, db) + if user is None: + return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303) + frame = db.get(Frame, frame_id) + if frame is None or not can_view_frame(db, user, frame): + raise HTTPException(404, "No such frame") + ctx = shell_context(request, db, user, active_frame=frame) + ctx.update({"frame": frame, "active_tab": tab, **extra}) + return templates.TemplateResponse(template, ctx) + + +@router.get("/frames/{frame_id}", response_class=HTMLResponse) +def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + return _frame_page(request, db, frame_id, "frame_photos.html", "photos") + + +@router.get("/frames/{frame_id}/config", response_class=HTMLResponse) +def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + return _frame_page( + request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES + ) + + +@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse) +def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)): + return _frame_page(request, db, frame_id, "frame_stats.html", "stats") \ No newline at end of file diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index d0335da..93765e5 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -326,15 +326,21 @@ def claim_signup( return response +def _settings_context(request: Request, db: Session, user, saved: bool, error: str | None) -> dict: + from .common import shell_context + + ctx = shell_context(request, db, user, active_nav="settings") + ctx.update({"saved": saved, "error": error}) + return ctx + + @router.get("/settings", response_class=HTMLResponse) def settings_page(request: Request, db: Session = Depends(get_db)): user = current_user(request, db) if user is None: return RedirectResponse("/login", status_code=303) - session = current_session(request, db) return templates.TemplateResponse( - "settings.html", - {"request": request, "user": user, "csrf_token": session.csrf_token, "saved": False, "error": None}, + "settings.html", _settings_context(request, db, user, saved=False, error=None) ) @@ -353,7 +359,6 @@ def settings_submit( if user is None: return RedirectResponse("/login", status_code=303) _check_form_csrf(request, db, csrf_token) - session = current_session(request, db) error = None user.display_name = display_name.strip() or user.username @@ -374,9 +379,7 @@ def settings_submit( db.commit() return templates.TemplateResponse( - "settings.html", - {"request": request, "user": user, "csrf_token": session.csrf_token, - "saved": error is None, "error": error}, + "settings.html", _settings_context(request, db, user, saved=error is None, error=error) ) @@ -389,7 +392,8 @@ def _require_admin_page(request: Request, db: Session) -> User: def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None, error: str | None = None) -> HTMLResponse: - session = current_session(request, db) + from .common import shell_context + users = list(db.scalars(select(User).order_by(User.id))) frames = list(db.scalars(select(Frame).order_by(Frame.id))) links = list(db.scalars(select(UserFrame))) @@ -397,19 +401,15 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None users_by_id = {u.id: u for u in users} for link in links: links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id]) - return templates.TemplateResponse( - "admin.html", - { - "request": request, - "user": admin, - "csrf_token": session.csrf_token, - "users": users, - "frames": frames, - "links_by_frame": links_by_frame, - "notice": notice, - "error": error, - }, - ) + ctx = shell_context(request, db, admin, active_nav="admin") + ctx.update({ + "users": users, + "frames": frames, + "links_by_frame": links_by_frame, + "notice": notice, + "error": error, + }) + return templates.TemplateResponse("admin.html", ctx) @router.get("/admin", response_class=HTMLResponse) diff --git a/server/app/static/battery_chart.js b/server/app/static/battery_chart.js new file mode 100644 index 0000000..4715d84 --- /dev/null +++ b/server/app/static/battery_chart.js @@ -0,0 +1,89 @@ +// Hand-drawn canvas battery-history chart. Ported intact from the +// original single-page UI. Reads theme colors live so it redraws +// correctly on theme changes (see the themechange listener in +// frame_stats.js). + +let lastBatteryLog = null; + +function drawBatteryChart(log) { + lastBatteryLog = log; + const wrap = document.getElementById('battery-chart-wrap'); + if (!log || log.length < 2) { + wrap.innerHTML = '

Not enough data yet.

'; + return; + } + wrap.innerHTML = ''; + const width = wrap.clientWidth || 440; + const height = 180; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.style.display = 'block'; + canvas.style.border = `1px solid ${themeColor('--border')}`; + canvas.style.borderRadius = '8px'; + wrap.appendChild(canvas); + const ctx = canvas.getContext('2d'); + + const gridColor = themeColor('--border'); + const mutedColor = themeColor('--text-muted'); + const accentColor = themeColor('--accent'); + + const pad = { left: 28, right: 8, top: 10, bottom: 20 }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + + const times = log.map((p) => p[0]); + const minT = Math.min(...times); + const maxT = Math.max(...times); + const spanT = Math.max(1, maxT - minT); + + const x = (t) => pad.left + ((t - minT) / spanT) * plotW; + const y = (pct) => pad.top + (1 - pct / 100) * plotH; + + ctx.strokeStyle = gridColor; + ctx.fillStyle = mutedColor; + ctx.font = '10px system-ui, sans-serif'; + ctx.lineWidth = 1; + ctx.textAlign = 'left'; + [0, 25, 50, 75, 100].forEach((pct) => { + const yy = y(pct); + ctx.beginPath(); + ctx.moveTo(pad.left, yy); + ctx.lineTo(width - pad.right, yy); + ctx.stroke(); + ctx.fillText(String(pct), 2, yy + 3); + }); + + ctx.strokeStyle = accentColor; + ctx.lineWidth = 1.5; + ctx.beginPath(); + log.forEach((p, i) => { + const px = x(p[0]); + const py = y(p[1]); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + + const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + ctx.fillStyle = mutedColor; + ctx.textAlign = 'left'; + ctx.fillText(fmt(minT), pad.left, height - 4); + ctx.textAlign = 'right'; + ctx.fillText(fmt(maxT), width - pad.right, height - 4); +} + +async function loadBatteryLog() { + const wrap = document.getElementById('battery-chart-wrap'); + try { + const resp = await fetch(`${window.FRAME_API}/battery-log`); + if (!resp.ok) { + wrap.innerHTML = '

Could not load.

'; + return; + } + const data = await resp.json(); + drawBatteryChart(data.log); + } catch (e) { + wrap.innerHTML = '

Could not load.

'; + } +} diff --git a/server/app/static/common.js b/server/app/static/common.js new file mode 100644 index 0000000..27ec4da --- /dev/null +++ b/server/app/static/common.js @@ -0,0 +1,97 @@ +// Shared plumbing for every page: CSRF-injecting fetch, theme toggle, +// sidebar toggle (mobile), and small formatting helpers. No framework, +// no build step -- plain scripts, load order handled by + + {% block extra_head %}{% endblock %} + + +
+ + + +
+
+ +

ESPresso Frame

+
+
+ +
+
+

{% block page_title %}{% endblock %}

+
+ {% block head_actions %}{% endblock %} + +
+
+ {% block tabs %}{% endblock %} + {% block content %}{% endblock %} +
+
+
+ + + {% block scripts %}{% endblock %} + + diff --git a/server/app/templates/base.html b/server/app/templates/base.html index 4d7de6e..b3e3afc 100644 --- a/server/app/templates/base.html +++ b/server/app/templates/base.html @@ -4,6 +4,7 @@ {% block title %}ESPresso Frame{% endblock %} + {% if csrf_token %}{% endif %} - + {% block extra_head %}{% endblock %} @@ -348,76 +29,13 @@ {% block subtitle %}{% endblock %} -
- {% if user %} - - {% endif %} - -
+ {% block content %}{% endblock %} - {% if csrf_token %} - - {% endif %} - - + {% block scripts %}{% endblock %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html new file mode 100644 index 0000000..f3afdb8 --- /dev/null +++ b/server/app/templates/frame_config.html @@ -0,0 +1,111 @@ +{% extends "app_base.html" %} + +{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %} +{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %} + +{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} + +{% block content %} + + +
+
+
+

Display settings

+
+ + + + +
+ + +
+
+ + +
+ + + +

Quiet hours times are + interpreted in this timezone. The device may still wake once right + at the start of quiet hours -- it can't know ahead of time -- but + goes right back to sleep until they end.

+ +
+
+
+ +
+
+

Firmware update

+

+ {% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }} + {% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %} +

+

+ {% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame + updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %} +

+ + + +
+

Gitea repo: {{ frame.firmware_update_repo_url }} + +

+
+ +
+ + +
+ + + + +
+
+
+ +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/server/app/templates/frame_photos.html b/server/app/templates/frame_photos.html new file mode 100644 index 0000000..4ad3b47 --- /dev/null +++ b/server/app/templates/frame_photos.html @@ -0,0 +1,60 @@ +{% extends "app_base.html" %} + +{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} +{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %} + +{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} + +{% block content %} + + +
+
+
+

Album

+
+ + + + +
+
+
+ +
+
+

Now displaying

+

Loading...

+
+
+
+ +
+

Upcoming

+

Drag a photo to reorder (on touch, hold briefly first so a + normal scroll still works), "Show next" to jump it to the front, or + the × to remove it from rotation entirely.

+
+
+ +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/server/app/templates/frame_stats.html b/server/app/templates/frame_stats.html new file mode 100644 index 0000000..7d336c4 --- /dev/null +++ b/server/app/templates/frame_stats.html @@ -0,0 +1,37 @@ +{% extends "app_base.html" %} + +{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %} +{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %} + +{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %} + +{% block content %} +
+
+
+

Battery history

+

Loading...

+
+ +
+

Lifetime stats

+

Loading...

+
+
+ +
+
+

Device

+

Loading...

+
+
+
+ +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/server/app/templates/frames_empty.html b/server/app/templates/frames_empty.html new file mode 100644 index 0000000..d398f79 --- /dev/null +++ b/server/app/templates/frames_empty.html @@ -0,0 +1,20 @@ +{% extends "app_base.html" %} + +{% block title %}ESPresso Frame{% endblock %} +{% block page_title %}Welcome{% endblock %} + +{% block content %} +
+

No frames yet

+

Set up a frame and it'll appear in the sidebar:

+

1. Power the frame on -- it opens a WiFi network named + ESPRESSO_XXXXXX and shows join instructions on its panel.

+

2. Join that network and fill in your WiFi details plus this + server's address.

+

3. Your browser lands on this server's claim page and links + the frame to your account automatically.

+

Already provisioned? Ask whoever + claimed it (or an admin) to link your account, or scan the frame's + on-panel manage QR.

+
+{% endblock %} diff --git a/server/app/templates/index.html b/server/app/templates/index.html deleted file mode 100644 index 8d8b072..0000000 --- a/server/app/templates/index.html +++ /dev/null @@ -1,828 +0,0 @@ -{% extends "base.html" %} - -{% block subtitle %} -

Point your frame's "Tools Server" field at this server's host:port.

-{% endblock %} - -{% block content %} - {% if immich_url %} -
Immich: {{ immich_url }} (API key configured). Set via - IMMICH_URL/IMMICH_API_KEY in docker-compose.yml -- see - docker-compose.yml.example.
- {% else %} -
Immich isn't configured yet. Set IMMICH_URL and - IMMICH_API_KEY in docker-compose.yml (copy - docker-compose.yml.example) and restart the server.
- {% endif %} - -
-
-
-

Settings

-
- - - - -
- - -
-
- - -
- - - -

Quiet hours times above are - interpreted in this timezone. The device may still wake once right - at the start of quiet hours -- it can't know ahead of time -- but - goes right back to sleep until they end.

- - - -
-
-
- -
- Stats -

Loading...

-
-
- -
-
-

Now displaying

-

Loading...

-
- -
-

Device

-

Loading...

-
- -
-

Battery history

-

Loading...

-
- -
-

Firmware update

-

- {% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }} - {% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %} -

-

- {% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame - updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %} -

- - - -
-

Gitea repo: {{ cfg.firmware_update_repo_url }} - -

-
- -
- - -
- - - - -
-
-
- -
-

Upcoming

-

Drag a photo to reorder (on touch, hold briefly first so a - normal scroll still works), "Show next" to jump it to the front, or - the × to remove it from rotation entirely.

-
-
-{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index cbd5990..640116f 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -1,10 +1,7 @@ -{% extends "base.html" %} +{% extends "app_base.html" %} -{% block page_class %}page-narrow{% endblock %} - -{% block subtitle %} -

Your account

-{% endblock %} +{% block title %}Settings{% endblock %} +{% block page_title %}Your account{% endblock %} {% block content %} {% if saved %}
Saved.
{% endif %}