Drop the last legacy widget-system and shared-token auth scaffolding
Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s

Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.

Firmware: the captive portal's "Access Token" field and its NVS/
build_url plumbing only ever mattered for pointing new firmware at an
old pre-multi-frame server -- gone along with the server-side fallback
it fed. Version bump to publish the change.
This commit is contained in:
2026-08-04 18:33:29 +00:00
parent 2868087467
commit 1d39e439ff
23 changed files with 599 additions and 611 deletions
+1 -2
View File
@@ -15,8 +15,7 @@ Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and - [`docs/architecture.md`](docs/architecture.md) -- how firmware and
server talk (sequence diagram, boot flow). server talk (sequence diagram, boot flow).
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system - [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
(data model, grid placement, compositor, button-action dispatch). Notes (data model, grid placement, compositor, button-action dispatch).
a known gap at the bottom (legacy `Frame` columns not yet dropped).
- [`docs/hardware.md`](docs/hardware.md) -- wiring. - [`docs/hardware.md`](docs/hardware.md) -- wiring.
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md) - [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
-- per-component setup, config, and a lot of accumulated gotchas -- per-component setup, config, and a lot of accumulated gotchas
+13 -17
View File
@@ -6,10 +6,10 @@ weather/battery), like arranging icons on an Android home screen. A frame
can hold several widgets of the same type (e.g. two photo widgets pointed can hold several widgets of the same type (e.g. two photo widgets pointed
at different Immich albums side by side). at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column and the other per-mode `Frame` columns
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
is still physically present but unused, pending a final cleanup migration dropped in migration 41, once every phase of the rollout had shipped
(see "Known gaps" below). (see "Known gaps" below for what's still open).
The device-facing contract is unchanged by any of this: `GET /frame/image`, The device-facing contract is unchanged by any of this: `GET /frame/image`,
`POST /frame/advance`, `POST /frame/back` are the same frozen paths `POST /frame/advance`, `POST /frame/back` are the same frozen paths
@@ -563,20 +563,16 @@ piece of code with its own fixed small size, not shared with this
widget, but drawing from the same thresholds/colors so a battery glyph widget, but drawing from the same thresholds/colors so a battery glyph
reads the same wherever one shows up on a panel. reads the same wherever one shows up on a panel.
## Known gaps (Phase 6, not yet done) ## Known gaps
The original 8-phase rollout plan's last phase is still open: The original 8-phase rollout plan's last phase is done: migration 41
dropped the legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, `queue`, etc.)
-- see its own docstring in `app/migration.py` for the raw-SQL backfill
safety net that ran first, and `server/README.md` no longer describes
photos/calendar/whiteboard as per-frame "modes".
Still open:
- Legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are
still physically present in the schema but no longer read or written
anywhere -- they need a dedicated final migration to drop them. Left in
place deliberately through the widget-system rollout (a much larger
blast radius cutover than this project's usual same-migration-drop
convention) but there's no reason to keep carrying them now that every
phase has shipped.
- `server/README.md` still describes photos/calendar/whiteboard as
per-frame "modes" in several places rather than widgets -- needs a pass
once the column drop above is safely deployed.
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully - Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
reliable yet, treat it as experimental if extending it. reliable yet, treat it as experimental if extending it.
+2 -16
View File
@@ -147,10 +147,9 @@ two-step setup screen:
portal's config page (`http://192.168.4.1/` by default), for a portal's config page (`http://192.168.4.1/` by default), for a
one-scan shortcut once you've joined the AP. one-scan shortcut once you've joined the AP.
The config page asks for your home WiFi SSID/password, the "Tools The config page asks for your home WiFi SSID/password and the "Tools
Server" address (`host:port` of the [server](../server/) -- **not** your Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server; see below for the `https://` form), and an optional Immich server; see below for the `https://` form). Saving hands your browser
"Access Token" (see below -- usually blank). Saving hands your browser
off to the server's claim page (after ~7 seconds, giving your phone 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 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 gets linked to your account; the device meanwhile connects to your home
@@ -212,19 +211,6 @@ certificate was actually issued for -- a bare LAN IP address
(`https://192.168.1.50`) will fail the handshake even against a (`https://192.168.1.50`) will fail the handshake even against a
perfectly valid cert for a different name. perfectly valid cert for a different name.
## Access token
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 ## Skipping to the next photo
Wire a momentary push button between GPIO2 and GND (internal pull-up, Wire a momentary push button between GPIO2 and GND (internal pull-up,
+11 -15
View File
@@ -95,18 +95,15 @@ static void save_wifi_cache(esp_netif_t *netif)
} }
/* Builds a full URL from cfg->toolsserver + a path (no leading slash), /* Builds a full URL from cfg->toolsserver + a path (no leading slash),
* appending cfg->access_token as ?token= if one's set. toolsserver is * appending cfg->device_token as &token= once one's been delivered.
* normally a bare "host:port", defaulting to plain http; it may instead * toolsserver is normally a bare "host:port", defaulting to plain http;
* carry an explicit "http://" or "https://" prefix to pick the scheme, * it may instead carry an explicit "http://" or "https://" prefix to
* e.g. "https://frame.example.com" if a reverse proxy is terminating * pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
* TLS in front of the tools server. Every URL carries ?id= (the device's * is terminating TLS in front of the tools server. Every URL carries
* MAC-derived identity -- how a multi-frame server tells frames apart * ?id= (the device's MAC-derived identity -- how a multi-frame server
* and how an unknown frame self-registers) plus &token=: the server- * tells frames apart and how an unknown frame self-registers) plus
* issued per-frame device token once one has been delivered via * &token=. This is the one chokepoint all requests go through, so every
* /frame/config, else the provisioned access token (the legacy shared * caller gets both for free instead of needing to remember to add them. */
* secret, also what a pre-multi-frame server still expects). This is
* the one chokepoint all requests go through, so every caller gets both
* for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path) static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{ {
const char *toolsserver = cfg->toolsserver; const char *toolsserver = cfg->toolsserver;
@@ -123,9 +120,8 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id); len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
} }
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token; if (cfg->device_token[0] != '\0' && len < out_size) {
if (token[0] != '\0' && len < out_size) { snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
snprintf(out + len, out_size - len, "&token=%s", token);
} }
} }
+2 -3
View File
@@ -36,9 +36,8 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id); len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
} }
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token; if (cfg->device_token[0] != '\0' && len < out_size) {
if (token[0] != '\0' && len < out_size) { snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
snprintf(out + len, out_size - len, "&token=%s", token);
} }
} }
-5
View File
@@ -97,11 +97,6 @@
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required> <input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required>
</div> </div>
<div class="input-group">
<label for="access_token">Access Token (optional &mdash; only for older servers)</label>
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
</div>
<p style="font-size: 13px; color: #555;">After saving, this page will <p style="font-size: 13px; color: #555;">After saving, this page will
take you to the server to claim your frame &mdash; reconnect to take you to the server to claim your frame &mdash; reconnect to
your normal WiFi if it doesn't happen automatically.</p> your normal WiFi if it doesn't happen automatically.</p>
+2 -18
View File
@@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
return pass_err; return pass_err;
} }
/* Also optional -- most deployments won't set a server-side
* MANAGEMENT_TOKEN at all, in which case this stays empty and the
* manage-menu QR just links to the page with no ?token=. */
len = sizeof(out->access_token);
esp_err_t token_err = nvs_get_str(handle, "access_token", out->access_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
}
/* Optional: absent until the server has pushed a per-frame token /* Optional: absent until the server has pushed a per-frame token
* (see frame_config_set_device_token). */ * (see frame_config_set_device_token). */
len = sizeof(out->device_token); len = sizeof(out->device_token);
token_err = nvs_get_str(handle, "device_token", out->device_token, &len); esp_err_t token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) { if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle); nvs_close(handle);
return token_err; return token_err;
@@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
if (err == ESP_OK) { if (err == ESP_OK) {
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver); err = nvs_set_str(handle, "toolsserver", cfg->toolsserver);
} }
if (err == ESP_OK) {
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) { if (err == ESP_OK) {
/* Re-provisioning restarts the identity handshake: the server /* Re-provisioning restarts the identity handshake: the server
* (possibly a different one now) re-issues a device token when * (possibly a different one now) re-issues a device token when
@@ -191,7 +178,6 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_ssid"); nvs_erase_key(handle, "sta_ssid");
nvs_erase_key(handle, "sta_pass"); nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver"); nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token"); nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once"); nvs_erase_key(handle, "connected_once");
nvs_commit(handle); nvs_commit(handle);
@@ -452,7 +438,6 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid)); extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password)); extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password));
extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver)); extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver));
extract_form_value(body, "access_token", cfg.access_token, sizeof(cfg.access_token));
if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) { if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required"); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required");
@@ -466,8 +451,7 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
return ESP_FAIL; return ESP_FAIL;
} }
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver, ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
strlen(cfg.access_token) ? "set" : "none");
/* The success page hands the browser off to the server's claim page, /* The success page hands the browser off to the server's claim page,
* carrying this device's id -- how a frame gets linked to a user * carrying this device's id -- how a frame gets linked to a user
+3 -4
View File
@@ -17,11 +17,10 @@ typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1]; char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1]; char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1]; char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
/* Per-frame token issued by the server via GET /frame/config after /* Per-frame token issued by the server via GET /frame/config after
* this device first introduces itself by id -- preferred over * this device first introduces itself by id (see frame_client.c's
* access_token once present (see frame_client.c's build_url). Not * build_url). Not set at the captive portal; empty until the server
* set at the captive portal; empty until the server pushes one. */ * pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t; } frame_config_t;
+1 -1
View File
@@ -1 +1 @@
1.4.1 1.4.2
+8 -11
View File
@@ -37,13 +37,10 @@ algorithm itself -- it just streams the response straight to the panel.
instead (see `firmware/README.md`'s HTTPS section). instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server 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 issues it on the frame's first check-in, so there's nothing to
configure. The captive portal's **Access Token** field only matters configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
when pointing new firmware at an old (pre-multi-frame) server. only matters pre-setup: if set, it's the credential that gates who
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the gets to be the one to run first-run setup on a freshly deployed
*migration* credential: a frame flashed with pre-multi-frame server, before any admin account exists.
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 6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml` pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets builds both supported boards and publishes them as release assets
@@ -214,9 +211,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
scan-to-download QR both use the frame's own `manage_token` (device scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware, tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`), both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` survives only as the migration credential for and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
pre-multi-frame firmware. step 5 above).
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events - The calendar widget (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-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 which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import, dependency here. It's used as an ordinary `pip install` runtime import,
@@ -234,7 +231,7 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so. re-evaluate that tradeoff for their own situation before doing so.
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`) - The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard` fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/ file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small files), not an image -- and renders it via `render-service/`, a small
+44 -76
View File
@@ -1,14 +1,18 @@
"""Authentication: password hashing, user sessions + CSRF, the legacy """Authentication: password hashing, user sessions + CSRF, the pre-setup
shared-token gate, and device resolution. claim gate, and device resolution.
Three independent credential classes: Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per- - User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans. session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on - MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
browser routes so the deployed frame's on-panel manage QR (which account exists yet (fresh install, or freshly migrated, before
embeds ?token=) keeps working until Phase C replaces it with the /setup has been run): if set, it gates who gets to be the one to run
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request /setup and claim the first admin account; once a user exists, sessions
credential, not an ambient cookie a cross-site request could ride). are the only way in. Not a standing bearer credential -- the on-panel
manage QR now embeds a frame's own per-frame manage_token (/m/, see
routers/manage.py) rather than this shared one; CSRF doesn't apply to
it either way (it's an explicit per-request credential, not an ambient
cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below). - Device credentials (?id= + ?token=, see require_device below).
""" """
@@ -250,17 +254,16 @@ def require_frame_control(
def management_token() -> str: def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old """The pre-setup claim-gate secret. Env-only, never stored -- same as
server, where the env var overrode anything on disk on every load.""" the old server, where the env var overrode anything on disk on every
load."""
return os.environ.get("MANAGEMENT_TOKEN", "") return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool: def browser_token_valid(request: Request) -> bool:
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means """Whether the request carries the current MANAGEMENT_TOKEN, via
token-holders don't exist -- but unlike Phase A this no longer means query param or cookie. Only meaningful pre-setup (see require_browser
"open": once users exist, sessions are the primary gate and this is below) -- empty configured token => not valid (nothing to match)."""
only the compatibility path for the deployed frame's manage QR
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
token = management_token() token = management_token()
if not token: if not token:
return False return False
@@ -270,12 +273,12 @@ def browser_token_valid(request: Request) -> bool:
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None: def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session """Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User), or the legacy shared (CSRF-checked on mutations, returns the User). While NO users exist
token (returns None -- token bearers act as an anonymous operator, yet (fresh install, or freshly migrated, before /setup has been run)
exactly the pre-user model). While NO users exist yet (fresh install the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
or freshly migrated, before /setup has been run) the API stays open to whoever supplies it if one is -- there's nobody to log in as yet,
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior -- so this is purely the claim gate for who gets to run /setup. Once a
since there's nobody to log in as yet.""" user exists, only a session gets in."""
session = current_session(request, db) session = current_session(request, db)
if session is not None: if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session): if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
@@ -283,10 +286,9 @@ def require_browser(request: Request, db: Session = Depends(get_db)) -> User | N
user = db.get(User, session.user_id) user = db.get(User, session.user_id)
if user is not None: if user is not None:
return user return user
if browser_token_valid(request): if not users_exist(db):
return None if not management_token() or browser_token_valid(request):
if not users_exist(db) and not management_token(): return None
return None
raise HTTPException(401, "Not logged in") raise HTTPException(401, "Not logged in")
@@ -326,63 +328,29 @@ def _register_frame(db: Session, device_id: str) -> Frame:
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame: def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request. """Resolves and authenticates the frame behind a /frame/* request.
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
(or nothing, on an open server) -- those requests resolve to the
unique legacy_token_enabled frame for as long as that migration
window stays open. The first id-bearing request arriving with legacy
credentials while the legacy frame has no device_id yet BINDS that id
to it -- that's the moment the deployed frame comes back up on new
firmware after its OTA, and it must not register as a second frame.
"""
device_id = request.query_params.get("id", "").strip().lower() device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "") token = request.query_params.get("token", "")
legacy = management_token()
legacy_ok = not legacy or token == legacy
if device_id: if not device_id:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() raise HTTPException(401, "Missing device id")
if frame is None:
legacy_frame = db.scalars( frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 if frame is None:
).first() frame = _register_frame(db, device_id)
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
legacy_frame.device_id = device_id
frame = legacy_frame
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
else:
frame = _register_frame(db, device_id)
else:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok:
if frame.legacy_token_enabled and legacy_ok:
pass
elif not frame.device_token_ack:
# Handshake window: the device registered but hasn't
# received its token yet (the wake cycle fetches the
# image BEFORE polling /frame/config, where the token
# is delivered) -- the id stays the credential, same
# trust level as the open registration that created
# the row. Closes permanently on the first
# authenticated request.
pass
else:
raise HTTPException(401, "Missing or invalid access token")
else: else:
if not legacy_ok: token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
elif not token_ok and frame.device_token_ack:
raise HTTPException(401, "Missing or invalid access token") raise HTTPException(401, "Missing or invalid access token")
frame = db.scalars( # else: handshake window -- the device registered but hasn't
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 # received its token yet (the wake cycle fetches the image
).first() # BEFORE polling /frame/config, where the token is delivered) --
if frame is None: # the id stays the credential, same trust level as the open
# Nothing to resolve a no-id request to. migration.py always # registration that created the row. Closes permanently on the
# creates frame #1 at startup, so this only happens if it was # first authenticated request.
# deleted -- treat like an unknown device.
raise HTTPException(401, "No frame accepts legacy credentials")
frame.last_seen = time.time() frame.last_seen = time.time()
db.commit() db.commit()
+4 -14
View File
@@ -110,26 +110,16 @@ def service_worker() -> FileResponse:
return FileResponse("app/static/sw.js", media_type="application/javascript") return FileResponse("app/static/sw.js", media_type="application/javascript")
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None: def _device_credential_redirect(request: Request, db) -> str | None:
"""The on-frame manage QR points at the server root with the device's """The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware: own credentials (?id=&token=). Those scans get the frame's limited
?token=<legacy shared token>). Those scans get the frame's limited manage page -- never the full UI, which 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."""
device_id = request.query_params.get("id", "").strip().lower() device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "") token = request.query_params.get("token", "")
if device_id and token: if device_id and token:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and token == frame.device_token: if frame is not None and token == frame.device_token:
return f"/m/{frame.manage_token}" return f"/m/{frame.manage_token}"
if allow_legacy and token and management_token() and token == management_token():
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is not None:
return f"/m/{frame.manage_token}"
return None return None
@@ -140,7 +130,7 @@ def index(request: Request):
else is walked through setup/login.""" else is walked through setup/login."""
with SessionLocal() as db: with SessionLocal() as db:
have_users = users_exist(db) have_users = users_exist(db)
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users) manage_redirect = _device_credential_redirect(request, db)
if manage_redirect is not None: if manage_redirect is not None:
return RedirectResponse(manage_redirect, status_code=303) return RedirectResponse(manage_redirect, status_code=303)
+347 -211
View File
@@ -22,13 +22,9 @@ from .db import SessionLocal, engine
from .models import ( from .models import (
Base, Base,
BatteryLog, BatteryLog,
CalendarWidgetConfig,
Frame, Frame,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
TaskWidgetConfig,
WhiteboardWidgetConfig,
Widget, Widget,
) )
from .widgets import default_button_actions from .widgets import default_button_actions
@@ -902,6 +898,265 @@ def _migration_40(conn) -> None:
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0")) conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None:
"""Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets --
called from _migration_41 while the legacy Frame columns it reads
still physically exist, for the rare frame (if any) that somehow
reached this migration without ever getting a Widget during the long
window _ensure_widgets_backfilled ran unconditionally at every
startup between migration 16 and this one. Same mode dispatch,
including the calendar_photo_inlay two-widget split and the legacy
tasks-source carryover. Has to be hand-rolled in raw SQL rather than
reusing the old ORM helpers, since those read these columns off
models.Frame, which no longer declares them as of this migration."""
frame_id = frame_row["id"]
orientation = frame_row["orientation"] or "landscape"
cols, rows = grid.grid_dims(orientation)
mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos"
def insert_widget(x, y, w, h, widget_type, sort_order):
# border_style/border_thickness/border_color_index/font_scale
# spelled out explicitly (migrations 26/40's own defaults)
# rather than relied on implicitly -- they're real SQL-level
# DEFAULTs in any database that reached this migration through
# the normal upgrade path, but this stays correct even if that
# ever stops being true.
result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
"border_style, border_thickness, border_color_index, font_scale) "
"VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, "
"'none', 3, 0, 1.0)"
), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h,
"sort_order": sort_order, "created_at": now})
return result.lastrowid
def insert_photo_config(widget_id):
conn.execute(text(
"INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, "
"queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, "
"excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, "
":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, "
":history, :excluded_asset_ids, 0)"
), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"],
"display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"],
"current_asset_id": frame_row["current_asset_id"],
"current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"],
"queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"],
"excluded_asset_ids": frame_row["excluded_asset_ids"]})
def insert_calendar_config(widget_id):
conn.execute(text(
"INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, "
"cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, "
"weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) "
"VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, "
":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, "
":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')"
), {"widget_id": widget_id, "view": frame_row["calendar_view"],
"week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"],
"checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"],
"fetch_summary": frame_row["calendar_fetch_summary"],
"weather_enabled": frame_row["calendar_weather_enabled"],
"weather_units": frame_row["calendar_weather_units"],
"weather_cities": frame_row["calendar_weather_cities"],
"weather_checked_at": frame_row["calendar_weather_checked_at"],
"weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"],
"week_layout": frame_row["calendar_week_layout"],
"week_start_offset": frame_row["calendar_week_start_offset"]})
def insert_whiteboard_config(widget_id):
conn.execute(text(
"INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, "
"render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')"
), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"],
"checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]})
def insert_button_actions(widget_id, widget_type):
if widget_type == "whiteboard":
pairs = [("next", "check_now"), ("back", "check_now")]
elif widget_type in ("photos", "calendar"):
pairs = [("next", "advance"), ("back", "back")]
else:
pairs = []
for button, action in pairs:
conn.execute(text(
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
"VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)"
), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action,
"created_at": now})
def maybe_add_tasks_widget(existing_rects, next_sort_order):
if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame_id
)
return
x, y, w, h = rect
widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order)
conn.execute(text(
"INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, "
"render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')"
), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"],
"cached": frame_row["calendar_tasks_cached"]})
conn.execute(text(
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
"VALUES (:widget_id, :user_id, :calendar_key, 1)"
), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"],
"calendar_key": frame_row["calendar_tasks_calendar_key"]})
if mode == "calendar" and frame_row["calendar_photo_inlay"]:
half = cols // 2
cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0)
photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1)
insert_calendar_config(cal_widget_id)
insert_photo_config(photo_widget_id)
insert_button_actions(cal_widget_id, "calendar")
maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2)
return
widget_id = insert_widget(0, 0, cols, rows, mode, 0)
if mode == "photos":
insert_photo_config(widget_id)
elif mode == "calendar":
insert_calendar_config(widget_id)
elif mode == "whiteboard":
insert_whiteboard_config(widget_id)
insert_button_actions(widget_id, mode)
if mode == "calendar":
maybe_add_tasks_widget([(0, 0, cols, rows)], 1)
def _migration_41(conn) -> None:
"""Drops the legacy per-mode Frame columns the widget system
(migration 16) superseded -- mode, the photo-queue fields (album_id/
photo_order/display_mode/queue_target_len/current_asset_id/
current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids),
every calendar_* field, every whiteboard_* field, and
legacy_token_enabled (models.py's own removal, alongside auth.py
dropping the shared MANAGEMENT_TOKEN device/browser fallback it
gated -- see auth.py's module docstring) -- see docs/widgets.md's
Known Gaps, which deliberately left this open as a much larger blast
radius than this project's usual same-migration-drop convention.
_ensure_widgets_backfilled ran unconditionally at the end of every
startup from migration 16 until this one, so in practice every frame
already has a Widget built from these columns' values by now; the
backfill loop below (_raw_backfill_frame_widgets) is the same safety
net migration 17/18 used for their own column drops, covering the
edge case of a frame that somehow reached this point with none (e.g.
a very old, never-restarted backup).
Guarded on "mode" existing, same reasoning as migration 26/27/29/
30/40's own comments: frames IS dropped/recreated here (unlike
widgets/photo_widget_configs, which those migrations left alone),
but a fresh-install create_all() copy already reflects today's
models.py -- i.e. the post-this-migration shape, missing "mode"
entirely -- so a test replaying migrations 16+ from an old
schema_version without also reconstructing frames' pre-41 columns
would otherwise hit "no such column: mode" here even though it has
nothing to do with what that test is actually exercising."""
if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}:
return
now = time.time()
frame_rows = conn.execute(text(
"SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, "
"current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, "
"calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, "
"calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, "
"calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, "
"calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, "
"calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, "
"calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, "
"whiteboard_cached_image FROM frames"
)).mappings().all()
for row in frame_rows:
has_widget = conn.execute(
text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]}
).first()
if has_widget is None:
_raw_backfill_frame_widgets(conn, row, now)
conn.execute(text(
"CREATE TABLE frames_new ("
"id INTEGER PRIMARY KEY, "
"device_id TEXT UNIQUE, "
"name TEXT NOT NULL DEFAULT '', "
"owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"device_token TEXT NOT NULL, "
"device_token_ack INTEGER NOT NULL DEFAULT 0, "
"manage_token TEXT NOT NULL UNIQUE, "
"claimed_at REAL, "
"created_at REAL NOT NULL DEFAULT 0.0, "
"immich_url TEXT NOT NULL DEFAULT '', "
"immich_api_key TEXT NOT NULL DEFAULT '', "
"refresh_interval_s INTEGER NOT NULL DEFAULT 3600, "
"quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, "
"quiet_hours_start TEXT NOT NULL DEFAULT '22:00', "
"quiet_hours_end TEXT NOT NULL DEFAULT '07:00', "
"timezone TEXT NOT NULL DEFAULT 'UTC', "
"orientation TEXT NOT NULL DEFAULT 'landscape', "
"palette_rgb TEXT, "
"color_boost REAL NOT NULL DEFAULT 1.0, "
"contrast_boost REAL NOT NULL DEFAULT 1.0, "
"dither_strength REAL NOT NULL DEFAULT 1.0, "
"photo_palette_rgb TEXT, "
"photo_dither_strength REAL NOT NULL DEFAULT 1.0, "
"theme TEXT NOT NULL DEFAULT 'classic', "
"battery_percent INTEGER NOT NULL DEFAULT -1, "
"battery_as_of REAL NOT NULL DEFAULT 0.0, "
"battery_history TEXT NOT NULL DEFAULT '[]', "
"last_seen REAL NOT NULL DEFAULT 0.0, "
"device_firmware_version TEXT NOT NULL DEFAULT '', "
"device_board_variant TEXT NOT NULL DEFAULT '', "
"battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, "
"battery_alert_sent INTEGER NOT NULL DEFAULT 0, "
"firmware_available_version TEXT NOT NULL DEFAULT '', "
"firmware_update_repo_url TEXT NOT NULL DEFAULT '', "
"firmware_auto_update INTEGER NOT NULL DEFAULT 0, "
"firmware_update_token TEXT NOT NULL DEFAULT '', "
"firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, "
"firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', "
"hold_duration_ms INTEGER NOT NULL DEFAULT 3000, "
"next_hold_action TEXT, "
"back_hold_action TEXT, "
"last_cycled_layout_id INTEGER, "
"last_displayed_image BLOB, "
"last_displayed_at REAL NOT NULL DEFAULT 0.0, "
"stats_first_seen REAL NOT NULL DEFAULT 0.0, "
"stats_device_wakes INTEGER NOT NULL DEFAULT 0, "
"stats_photos_displayed INTEGER NOT NULL DEFAULT 0, "
"stats_photos_removed INTEGER NOT NULL DEFAULT 0, "
"stats_battery_reports INTEGER NOT NULL DEFAULT 0, "
"stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, "
"stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, "
"stats_config_saves INTEGER NOT NULL DEFAULT 0)"
))
kept_columns = (
"id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, "
"manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, "
"quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, "
"color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, "
"battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, "
"device_board_variant, battery_alert_threshold_pct, battery_alert_sent, "
"firmware_available_version, firmware_update_repo_url, firmware_auto_update, "
"firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, "
"hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, "
"last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, "
"stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, "
"stats_ota_updates_applied, stats_config_saves"
)
conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames"))
conn.execute(text("DROP TABLE frames"))
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -943,6 +1198,7 @@ MIGRATIONS = [
(38, _migration_38), (38, _migration_38),
(39, _migration_39), (39, _migration_39),
(40, _migration_40), (40, _migration_40),
(41, _migration_41),
] ]
@@ -959,18 +1215,53 @@ def run_migrations() -> None:
# already added (e.g. "duplicate column name"). Jump # already added (e.g. "duplicate column name"). Jump
# straight to the latest version instead. # straight to the latest version instead.
_migration_1(conn) _migration_1(conn)
latest = MIGRATIONS[-1][0] current = MIGRATIONS[-1][0]
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest}) conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
else: else:
current = row[0] current = row[0]
for version, fn in MIGRATIONS:
if version > current: # Each migration commits in its own transaction (rather than the
logger.info("Applying schema migration %d", version) # whole batch sharing one, like this used to) so that _migration_41
fn(conn) # can get a connection with no transaction pending on it yet --
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) # SQLite only honors toggling PRAGMA foreign_keys when issued as a
# connection's literal first statement, and it needs that off for
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
# for widgets/frame_button_actions/user_frames/etc, so leaving
# enforcement on there would cascade-delete every frame's widgets,
# not just the columns that migration means to drop). A crash
# partway through now simply leaves schema_version at the last
# migration that actually completed, same as it always could
# between separate runs of this function.
for version, fn in MIGRATIONS:
if version <= current:
continue
logger.info("Applying schema migration %d", version)
with engine.connect() as conn:
if fn is _migration_41:
# Executing this before anything else auto-begins
# SQLAlchemy's own Transaction bookkeeping too, so an
# explicit conn.begin() below would conflict with it --
# fn(conn) and the version UPDATE just ride that same
# auto-begun transaction, committed explicitly at the end.
conn.execute(text("PRAGMA foreign_keys=OFF"))
fn(conn)
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
conn.commit()
if fn is _migration_41:
# Restore it before this connection goes back to the
# pool -- otherwise a later checkout of the same
# underlying DBAPI connection (the connect-event
# listener in db.py only fires for a genuinely new one)
# would silently run with enforcement off. Has to
# happen AFTER commit(), same "no pending transaction"
# requirement as the OFF toggle above -- issuing it
# before the commit is exactly the mid-transaction
# no-op this migration exists to work around in the
# first place, just in the other direction.
conn.execute(text("PRAGMA foreign_keys=ON"))
_ensure_frame_one() _ensure_frame_one()
_ensure_server_settings() _ensure_server_settings()
_ensure_widgets_backfilled()
_ensure_frame_calendars_rekeyed() _ensure_frame_calendars_rekeyed()
@@ -985,11 +1276,11 @@ def new_manage_token() -> str:
def _ensure_frame_one() -> None: def _ensure_frame_one() -> None:
"""First boot only (frames table empty): create frame #1 -- imported """First boot only (frames table empty): create frame #1 -- imported
verbatim from a legacy config.json if one exists, otherwise fresh verbatim from a legacy config.json if one exists, otherwise fresh
defaults. Either way it's the legacy-token frame: the deployed defaults -- plus a single full-panel photos widget carrying over
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN, whatever photo-queue state that file had (the widget system's
and require_device resolves those requests here. The frames-nonempty equivalent of what used to live directly on Frame; see migration
guard makes this idempotent; config.json is left untouched as the 41). The frames-nonempty guard makes this idempotent; config.json is
rollback path.""" left untouched as the rollback path."""
with SessionLocal() as db: with SessionLocal() as db:
if db.scalars(select(Frame).limit(1)).first() is not None: if db.scalars(select(Frame).limit(1)).first() is not None:
return return
@@ -1002,26 +1293,15 @@ def _ensure_frame_one() -> None:
device_id=None, device_id=None,
device_token=new_device_token(), device_token=new_device_token(),
manage_token=new_manage_token(), manage_token=new_manage_token(),
legacy_token_enabled=True,
created_at=time.time(), created_at=time.time(),
immich_url=cfg.immich_url, immich_url=cfg.immich_url,
immich_api_key=cfg.immich_api_key, immich_api_key=cfg.immich_api_key,
album_id=cfg.album_id,
order=cfg.order,
refresh_interval_s=cfg.refresh_interval_s, refresh_interval_s=cfg.refresh_interval_s,
quiet_hours_enabled=cfg.quiet_hours_enabled, quiet_hours_enabled=cfg.quiet_hours_enabled,
quiet_hours_start=cfg.quiet_hours_start, quiet_hours_start=cfg.quiet_hours_start,
quiet_hours_end=cfg.quiet_hours_end, quiet_hours_end=cfg.quiet_hours_end,
timezone=cfg.timezone, timezone=cfg.timezone,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
orientation=cfg.orientation, orientation=cfg.orientation,
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
battery_percent=cfg.battery_percent, battery_percent=cfg.battery_percent,
battery_as_of=cfg.battery_as_of, battery_as_of=cfg.battery_as_of,
battery_history=[list(pair) for pair in cfg.battery_history], battery_history=[list(pair) for pair in cfg.battery_history],
@@ -1044,11 +1324,31 @@ def _ensure_frame_one() -> None:
stats_config_saves=cfg.stats.config_saves, stats_config_saves=cfg.stats.config_saves,
) )
db.add(frame) db.add(frame)
db.flush() # assign frame.id for the battery log rows db.flush() # assign frame.id for the battery log rows + widget FK
for pair in cfg.battery_log: for pair in cfg.battery_log:
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1])) db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
cols, rows = grid.grid_dims(frame.orientation)
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush() # assign widget.id for the config row's FK
db.add(PhotoWidgetConfig(
widget_id=widget.id,
album_id=cfg.album_id,
order=cfg.order,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
))
db.add_all(default_button_actions(frame.id, widget.id, "photos"))
db.commit() db.commit()
# The single legacy firmware slot becomes frame #1's per-frame slot. # The single legacy firmware slot becomes frame #1's per-frame slot.
@@ -1078,168 +1378,6 @@ def _ensure_server_settings() -> None:
db.commit() db.commit()
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
return PhotoWidgetConfig(
widget_id=widget_id,
album_id=frame.album_id,
order=frame.order,
display_mode=frame.display_mode,
queue_target_len=frame.queue_target_len,
current_asset_id=frame.current_asset_id,
current_asset_set_at=frame.current_asset_set_at,
queue=list(frame.queue),
queue_cursor=frame.queue_cursor,
history=list(frame.history),
excluded_asset_ids=list(frame.excluded_asset_ids),
)
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
return CalendarWidgetConfig(
widget_id=widget_id,
view=frame.calendar_view,
week_start=frame.calendar_week_start,
browse_offset=frame.calendar_browse_offset,
checked_at=frame.calendar_checked_at,
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
fetch_summary=frame.calendar_fetch_summary,
weather_enabled=frame.calendar_weather_enabled,
weather_units=frame.calendar_weather_units,
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
weather_checked_at=frame.calendar_weather_checked_at,
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
week_days=frame.calendar_week_days,
week_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset,
# tasks_* deliberately not carried over -- see
# _task_config_and_list_from_frame, a sibling standalone widget
# now, not part of this config.
)
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
"""Only ever called for a frame whose legacy calendar_tasks_* columns
(see Frame's own docstring on those -- a dead pre-widget-system
field set, same status as calendar_photo_inlay below) still carry a
configured source -- i.e. a database jumping straight from before
the widget system existed to after tasks became their own
multi-list widget type in a single upgrade, skipping both
intermediate periods where it would have lived on
CalendarWidgetConfig (_migration_17's extraction) and then a
single-source TaskWidgetConfig (_migration_18's extraction) instead.
Reproduces the same shape those two migrations arrive at directly:
a bare cache-state config plus one included FrameTaskList row."""
cfg = TaskWidgetConfig(
widget_id=widget_id,
checked_at=frame.calendar_tasks_checked_at,
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
)
task_list = FrameTaskList(
widget_id=widget_id,
user_id=frame.calendar_tasks_user_id,
calendar_key=frame.calendar_tasks_calendar_key,
included=True,
)
return cfg, task_list
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
return WhiteboardWidgetConfig(
widget_id=widget_id,
user_id=frame.whiteboard_user_id,
url=frame.whiteboard_url,
checked_at=frame.whiteboard_checked_at,
cached_image=frame.whiteboard_cached_image,
)
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
in one upgrade (see _task_config_and_list_from_frame) --
frame.calendar_tasks_* is the dead legacy field set otherwise.
Requires both calendar_key and user_id (FrameTaskList.user_id is
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
Auto-placed in whatever open space is left after the widget(s) above
it in _backfill_frame_widgets claimed theirs, same find_open_rect
logic a manual "add widget" uses; silently dropped (logged) if none
fits, same as this migration having nowhere else to put it either."""
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame.id
)
return
x, y, w, h = rect
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
sort_order=next_sort_order, created_at=time.time())
db.add(task_widget)
db.flush()
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
db.add(cfg)
db.add(task_list)
def _backfill_frame_widgets(db, frame: Frame) -> None:
cols, rows = grid.grid_dims(frame.orientation)
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
if mode == "calendar" and frame.calendar_photo_inlay:
# Reproduces the old fixed 50/50 inlay split as two independent
# widgets instead of silently dropping half of what the frame was
# showing -- see models.py's CalendarWidgetConfig docstring on why
# "photo inlay" isn't a widget-system concept anymore otherwise.
half = cols // 2
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
db.add_all([cal_widget, photo_widget])
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
return
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush()
if mode == "photos":
db.add(_photo_config_from_frame(frame, widget.id))
elif mode == "calendar":
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
def _ensure_widgets_backfilled() -> None:
"""Every frame needs at least one Widget once the widget system is
live -- runs unconditionally after every startup (both a from-scratch
_ensure_frame_one() install and an existing-install upgrade past
_migration_16 land here) and is a no-op for any frame that already
has one. Builds a widget that reproduces the frame's current mode/
settings/state exactly, so upgrading never changes what a frame
displays or what its physical buttons do on its own."""
with SessionLocal() as db:
for frame in db.scalars(select(Frame)).all():
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
if has_widget is not None:
continue
_backfill_frame_widgets(db, frame)
db.commit()
def _ensure_frame_calendars_rekeyed() -> None: def _ensure_frame_calendars_rekeyed() -> None:
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can """Re-keys frame_calendars from frame_id to widget_id -- a frame can
hold more than one independent calendar widget (see the widget hold more than one independent calendar widget (see the widget
@@ -1252,27 +1390,25 @@ def _ensure_frame_calendars_rekeyed() -> None:
and savable even while a frame's old `mode` was "photos"), not real and savable even while a frame's old `mode` was "photos"), not real
live configuration. live configuration.
Deliberately NOT a numbered migration: this needs each frame's Deliberately NOT a numbered migration: every frame's calendar widget
calendar widget to already exist to know what to re-key against, and must already exist to know what to re-key against, and for a genuine
those widget rows aren't created by a schema migration at all -- pre-widget-system database those rows only exist once _migration_41's
they come from _ensure_widgets_backfilled() above, which (like this own backfill has run (a step inside that migration, not before it).
function) runs unconditionally after every startup rather than being A numbered migration for this would race ahead of that backfill (the
tracked by schema_version. Running this as a numbered migration numbered-migration loop runs top to bottom in one pass, see
would execute it *before* that backfill during a real upgrade (the run_migrations), silently dropping every row -- caught by
numbered-migration loop runs first, see run_migrations), silently test_migrations.py actually exercising the raw-SQL upgrade path
dropping every row -- caught by test_migrations.py actually exercising instead of the fresh-install create_all() shortcut every other test
the raw-SQL upgrade path instead of the fresh-install create_all() in that file takes.
shortcut every other test in that file takes.
Runs unconditionally after every startup, like _ensure_widgets_ Runs unconditionally after every startup instead; a no-op the moment
backfilled; a no-op the moment frame_calendars is already frame_calendars is already widget_id-shaped (every fresh install,
widget_id-shaped (every fresh install, and any existing install and any existing install after its first run past this code) --
after its first run past this code) -- SQLite can't ALTER a column's SQLite can't ALTER a column's FK target or drop a column that's part
FK target or drop a column that's part of an index/FK constraint, so of an index/FK constraint, so when it isn't a no-op this is the
when it isn't a no-op this is the standard SQLite "rebuild" pattern: standard SQLite "rebuild" pattern: create the new-shape table, copy
create the new-shape table, copy matching rows across (joining to matching rows across (joining to find each row's calendar widget),
find each row's calendar widget), drop the old table, rename the new drop the old table, rename the new one into place."""
one into place."""
inspector = inspect(engine) inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("frame_calendars")} columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
if "widget_id" in columns: if "widget_id" in columns:
+3 -125
View File
@@ -117,14 +117,10 @@ class Frame(Base):
__tablename__ = "frames" __tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# 12 lowercase hex chars of the device's full STA MAC. NULL only for # 12 lowercase hex chars of the device's full STA MAC. NULL until its
# the migrated legacy frame until its device first reports an id. # device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="") name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
# and routers/common.py FRAME_MODES).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed. # Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column( owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True ForeignKey("users.id", ondelete="SET NULL"), nullable=True
@@ -138,11 +134,6 @@ class Frame(Base):
# pushing it in /frame/config responses. # pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False) device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
manage_token: Mapped[str] = mapped_column(String, unique=True) manage_token: Mapped[str] = mapped_column(String, unique=True)
# Migration window: this frame also accepts the legacy shared
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
# migrated frame #1; cleared from /admin once the device is on
# per-frame auth.
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True) claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, default=time.time) created_at: Mapped[float] = mapped_column(Float, default=time.time)
@@ -153,19 +144,13 @@ class Frame(Base):
immich_url: Mapped[str] = mapped_column(String, default="") immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="") immich_api_key: Mapped[str] = mapped_column(String, default="")
# -- settings (attribute names match the old FrameConfig fields) -- # -- settings --
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600) refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False) quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00") quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00") quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC") timezone: Mapped[str] = mapped_column(String, default="UTC")
# How a photo's aspect ratio is reconciled with the panel's -- see
# image_pipeline.DISPLAY_MODES.
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
orientation: Mapped[str] = mapped_column(String, default="landscape") orientation: Mapped[str] = mapped_column(String, default="landscape")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/ # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding # blue/green, matching image_pipeline.PANEL_CODES order) overriding
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
@@ -192,113 +177,6 @@ class Frame(Base):
# Widgets rendered in classic (PIL) style ignore this entirely. # Widgets rendered in classic (PIL) style ignore this entirely.
theme: Mapped[str] = mapped_column(String, default="classic") theme: Mapped[str] = mapped_column(String, default="classic")
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
# which day week/month views start their grid on.
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
# Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
# How many periods (unit depends on calendar_view: days/weeks/months)
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
# (non-button) /frame/image request, and whenever calendar_view
# itself changes -- a stale offset means something different in a
# different view's units.
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
# Throttled merge-fetch cache (see routers/common.py's
# get_or_refresh_calendar_events) -- same shape as the
# firmware_update_checked_at/firmware_gitea_latest_version pattern
# below. One shared cache for every included user's merged events,
# not per-user.
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
# calendars unavailable" -- never names which user's feed failed, a
# shared household display shouldn't call out a specific person's
# outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# Optional weather strip, agenda/today & tomorrow/week views only --
# never month, there's no room (see calendar_render.py's _BUILDERS).
# Off by default.
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
# weather.geocode_city() when added from the Calendar tab.
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Throttled per-city forecast cache (see routers/common.py's
# get_or_refresh_weather) -- same shape idiom as
# calendar_checked_at/calendar_cached_events above.
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Week view: how many days to show (2-10, default 7 -- the original
# fixed behavior) and whether they're laid out as side-by-side
# columns or stacked bands (see calendar_render.py's _build_week).
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
# Only used when calendar_week_days != 7 -- calendar_week_start's
# fixed-weekday anchor ("start on the most recent Monday") stops
# making sense once the view isn't a literal calendar week, so a
# non-7-day view instead starts this many days from today (0 =
# starts today, negative = starts in the past, positive = starts in
# the future). Ignored (calendar_week_start governs instead) at the
# default 7 days, so this has no effect until someone actually
# changes the day count.
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# Optional task list, week view only -- takes the space of one day
# slot rather than adding an extra one (see calendar_render.py's
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
# something a plain ICS subscription meaningfully has); source is
# one specific linked user's own CalDAV calendar, same
# owner-controls-their-own-data permission split as FrameCalendar.
# calendar_tasks_user_id
# SET NULL on the user's deletion clears the source rather than
# leaving a dangling reference (checked_at isn't reset by that, but
# the next refresh attempt finds no source and just returns []).
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
# already filtered to outstanding (not-completed) tasks and sorted
# by due date -- see caldav_client.fetch_tasks.
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
# whiteboard_user_id may point the frame at their own account, since
# it's their credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
# account-has-several-calendars case, a WebDAV account doesn't need
# a picker step here since the user already knows which one file).
whiteboard_url: Mapped[str] = mapped_column(String, default="")
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
# BLOB rather than the JSON columns the rest of this cache-pattern
# family uses, since this is binary image data, not JSON-shaped.
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
# -- telemetry -- # -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1) battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0) battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
+4 -5
View File
@@ -282,11 +282,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
# firmware/main/next_button.c, app/global_actions.py). # firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms, "hold_duration_ms": locked.hold_duration_ms,
} }
# Per-frame token push: only once the device has introduced itself # Per-frame token push: only until the device has authenticated
# by id (so the response to pure-legacy firmware stays byte- # with it once (device_token_ack) -- no reason to keep sending it
# compatible with its 256-byte parse buffer), and only until the # on every wake once the device has it.
# device has authenticated with the token once (device_token_ack). if not locked.device_token_ack:
if locked.device_id is not None and not locked.device_token_ack:
response["device_token"] = locked.device_token response["device_token"] = locked.device_token
return response return response
-20
View File
@@ -707,26 +707,6 @@ def admin_link_user(
notice=f"Linked '{target.username}' to frame #{frame_id}.") notice=f"Linked '{target.username}' to frame #{frame_id}.")
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
def admin_end_legacy(
frame_id: int,
request: Request,
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
"""Closes the legacy-token migration window once the device is
confirmed on per-frame auth (device_token_ack + recent last_seen in
the frames table below)."""
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
frame = db.get(Frame, frame_id)
if frame is None:
return _render_admin(request, db, admin, error="No such frame.")
frame.legacy_token_enabled = False
db.commit()
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
@router.post("/admin/smtp", response_class=HTMLResponse) @router.post("/admin/smtp", response_class=HTMLResponse)
def admin_smtp_save( def admin_smtp_save(
request: Request, request: Request,
-8
View File
@@ -109,20 +109,12 @@
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br> &middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }}) firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }} &middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
{% if f.legacy_token_enabled %}&middot; <strong>legacy token window OPEN</strong>{% endif %}
</p> </p>
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form"> <form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="username" placeholder="Link user by name" required> <input type="text" name="username" placeholder="Link user by name" required>
<button type="submit" class="secondary btn-inline">Link</button> <button type="submit" class="secondary btn-inline">Link</button>
</form> </form>
{% if f.legacy_token_enabled %}
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Close legacy window</button>
</form>
{% endif %}
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form" <form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');"> onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+5 -6
View File
@@ -10,12 +10,11 @@ services:
- CONFIG_PATH=/data/config.json - CONFIG_PATH=/data/config.json
- IMMICH_URL=http://your-immich-host:2283 - IMMICH_URL=http://your-immich-host:2283
- IMMICH_API_KEY=your-immich-api-key-here - IMMICH_API_KEY=your-immich-api-key-here
# Optional: gates the entire server -- the web UI (/, /api/*) AND # Optional: gates first-run /setup on a freshly deployed server --
# every device-facing /frame/* endpoint -- behind this shared secret. # whoever supplies this value is the one who gets to create the
# Leave unset to keep it all open on a trusted LAN, same as before. # first admin account. Meaningless once that account exists (every
# Paste the same value into the ESP32's captive portal setup form # other route always requires a real login), so leave unset unless
# (Access Token field) so it's sent on every device request and gets # you're worried about someone else reaching /setup before you do.
# embedded automatically in the manage-menu/share QR codes.
- MANAGEMENT_TOKEN=changeme - MANAGEMENT_TOKEN=changeme
# Optional: only needed if the Gitea repo configured in the web UI's # Optional: only needed if the Gitea repo configured in the web UI's
# "Firmware Gitea repo URL" field is private. A read-only PAT is # "Firmware Gitea repo URL" field is private. A read-only PAT is
+13
View File
@@ -112,6 +112,19 @@ def link_user(db: Session, user: User, frame: Frame) -> None:
db.flush() db.flush()
def claim_device(db: Session, frame: Frame, device_id: str = "001122334455",
token: str = "devtok-1") -> str:
"""Gives `frame` device credentials and returns the "id=...&token=..."
query string real firmware always sends -- require_device has no
fallback for a bare /frame/* request without ?id= (the old shared-
MANAGEMENT_TOKEN/no-id path this project used to resolve to a single
legacy frame is gone), so any device-facing test needs this."""
frame.device_id = device_id
frame.device_token = token
db.commit()
return f"id={device_id}&token={token}"
def login(client: TestClient, username: str, password: str = "testpass123") -> None: def login(client: TestClient, username: str, password: str = "testpass123") -> None:
resp = client.post("/login", data={"username": username, "password": password}) resp = client.post("/login", data={"username": username, "password": password})
assert resp.status_code == 303, resp.text assert resp.status_code == 303, resp.text
+13 -10
View File
@@ -26,6 +26,8 @@ from app.models import (
Widget, Widget,
) )
from .conftest import claim_device
EXPECTED_BYTES = 800 * 480 // 2 EXPECTED_BYTES = 800 * 480 // 2
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}] _ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
@@ -41,16 +43,17 @@ def _mock_immich(monkeypatch):
def test_unclaimed_frame_shows_placeholder(client, db_session): def test_unclaimed_frame_shows_placeholder(client, db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
frame.owner_user_id = None frame.owner_user_id = None
db_session.commit() creds = claim_device(db_session, frame)
resp = client.get("/frame/image") resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session): def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/frame/image") creds = claim_device(db_session, db_session.get(Frame, 1))
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
@@ -66,24 +69,24 @@ def test_configured_photo_widget_renders_and_advances_via_button(client, db_sess
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id) cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1" cfg.album_id = "album-1"
db_session.commit() creds = claim_device(db_session, frame)
_mock_immich(monkeypatch) _mock_immich(monkeypatch)
resp = client.get("/frame/image") resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg) db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" assert cfg.current_asset_id == "asset-1"
resp = client.post("/frame/advance") resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg) db_session.refresh(cfg)
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
resp = client.post("/frame/back") resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
db_session.refresh(cfg) db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" # back undid it assert cfg.current_asset_id == "asset-1" # back undid it
@@ -94,11 +97,11 @@ def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1" db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
db_session.commit() creds = claim_device(db_session, frame)
_mock_immich(monkeypatch) _mock_immich(monkeypatch)
plain = client.get("/frame/image").content plain = client.get(f"/frame/image?{creds}").content
with_manage = client.get("/frame/image?manage=1").content with_manage = client.get(f"/frame/image?{creds}&manage=1").content
assert len(with_manage) == EXPECTED_BYTES assert len(with_manage) == EXPECTED_BYTES
assert with_manage != plain # the manage-QR overlay actually got composited in assert with_manage != plain # the manage-QR overlay actually got composited in
+9 -8
View File
@@ -19,7 +19,7 @@ from app.models import (
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
) )
from .conftest import csrf_headers, link_user, login, make_user from .conftest import claim_device, csrf_headers, link_user, login, make_user
EXPECTED_BYTES = 800 * 480 // 2 EXPECTED_BYTES = 800 * 480 // 2
@@ -113,7 +113,8 @@ def test_save_logged_out_401s(client, db_session):
def test_global_next_is_a_noop_when_unset(client, db_session): def test_global_next_is_a_noop_when_unset(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/frame/global-next") creds = claim_device(db_session, db_session.get(Frame, 1))
resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
@@ -123,9 +124,9 @@ def test_global_next_runs_the_configured_action(client, db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.next_hold_action = "toggle_all_photo_locks" frame.next_hold_action = "toggle_all_photo_locks"
db_session.commit() creds = claim_device(db_session, frame)
resp = client.post("/frame/global-next") resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
@@ -136,9 +137,9 @@ def test_global_back_runs_the_configured_action(client, db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.back_hold_action = "toggle_all_photo_locks" frame.back_hold_action = "toggle_all_photo_locks"
db_session.commit() creds = claim_device(db_session, frame)
resp = client.post("/frame/global-back") resp = client.post(f"/frame/global-back?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
@@ -149,9 +150,9 @@ def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_ses
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
frame.next_hold_action = "no_longer_exists" frame.next_hold_action = "no_longer_exists"
db_session.commit() creds = claim_device(db_session, frame)
resp = client.post("/frame/global-next") resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
+105 -29
View File
@@ -28,6 +28,60 @@ from app.models import (
from .conftest import make_user from .conftest import make_user
# Columns migration 41 drops from `frames` -- a fresh-install create_all()
# copy (what every db_session fixture starts from) already reflects
# today's models.py, i.e. the post-41 shape without these, so a test that
# wants to simulate a pre-41 database has to add them back itself before
# setting schema_version below 41 and calling run_migrations() -- same
# "frames isn't dropped/recreated by these replay tests" situation
# test_migration_29/30's own comments describe, just for columns being
# removed instead of added.
_LEGACY_FRAME_COLUMNS = [
"mode TEXT NOT NULL DEFAULT 'photos'",
"album_id TEXT NOT NULL DEFAULT ''",
"photo_order TEXT NOT NULL DEFAULT 'sequential'",
"display_mode TEXT NOT NULL DEFAULT 'crop_faces'",
"queue_target_len INTEGER NOT NULL DEFAULT 20",
"current_asset_id TEXT NOT NULL DEFAULT ''",
"current_asset_set_at REAL NOT NULL DEFAULT 0.0",
"queue TEXT NOT NULL DEFAULT '[]'",
"queue_cursor INTEGER NOT NULL DEFAULT 0",
"history TEXT NOT NULL DEFAULT '[]'",
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]'",
"calendar_view TEXT NOT NULL DEFAULT 'agenda'",
"calendar_week_start INTEGER NOT NULL DEFAULT 0",
"calendar_photo_inlay INTEGER NOT NULL DEFAULT 0",
"calendar_browse_offset INTEGER NOT NULL DEFAULT 0",
"calendar_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_cached_events TEXT",
"calendar_fetch_summary TEXT NOT NULL DEFAULT ''",
"calendar_weather_enabled INTEGER NOT NULL DEFAULT 0",
"calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'",
"calendar_weather_cities TEXT",
"calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_weather_cached TEXT",
"calendar_week_days INTEGER NOT NULL DEFAULT 7",
"calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'",
"calendar_week_start_offset INTEGER NOT NULL DEFAULT 0",
"calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0",
"calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
"calendar_tasks_calendar_key TEXT",
"calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_tasks_cached TEXT",
"whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
"whiteboard_url TEXT NOT NULL DEFAULT ''",
"whiteboard_checked_at REAL NOT NULL DEFAULT 0.0",
"whiteboard_cached_image BLOB",
"legacy_token_enabled INTEGER NOT NULL DEFAULT 0",
]
def _add_legacy_frame_columns(conn) -> None:
existing = {c["name"] for c in inspect(db_module.engine).get_columns("frames")}
for col_def in _LEGACY_FRAME_COLUMNS:
if col_def.split()[0] not in existing:
conn.execute(text(f"ALTER TABLE frames ADD COLUMN {col_def}"))
def test_migrations_list_is_sequential_and_unique(): def test_migrations_list_is_sequential_and_unique():
versions = [v for v, _ in MIGRATIONS] versions = [v for v, _ in MIGRATIONS]
@@ -72,8 +126,6 @@ def test_expected_columns_exist_on_current_schema():
assert "webdav_base_url" in user_columns # migration 15 assert "webdav_base_url" in user_columns # migration 15
assert "webdav_username" in user_columns # migration 14 assert "webdav_username" in user_columns # migration 14
assert "calendar_caldav_url" in user_columns assert "calendar_caldav_url" in user_columns
assert "whiteboard_cached_image" in frame_columns # migration 14
assert "calendar_week_start_offset" in frame_columns
assert "name" in task_widget_columns # migration 19 assert "name" in task_widget_columns # migration 19
assert "static_widget_configs" in inspector.get_table_names() # migration 20 assert "static_widget_configs" in inspector.get_table_names() # migration 20
assert "text_widget_configs" in inspector.get_table_names() # migration 21 assert "text_widget_configs" in inspector.get_table_names() # migration 21
@@ -106,14 +158,16 @@ def test_expected_columns_exist_on_current_schema():
assert "render_style" in calendar_widget_columns # migration 38 assert "render_style" in calendar_widget_columns # migration 38
assert "theme" in frame_columns # migration 39 assert "theme" in frame_columns # migration 39
assert "font_scale" in widget_columns # migration 40 assert "font_scale" in widget_columns # migration 40
assert not {"mode", "album_id", "current_asset_id", "calendar_view", "whiteboard_url",
"legacy_token_enabled"} & frame_columns # migration 41
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system: fresh-install default widget, and migration 41's
# raw-SQL backfill safety net for a pre-widget-system database ---
def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_session): def test_fresh_install_creates_a_default_photos_widget_with_default_buttons(db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
assert frame.mode == "photos"
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
assert len(widgets) == 1 assert len(widgets) == 1
@@ -123,7 +177,7 @@ def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_sessi
config = db_session.get(PhotoWidgetConfig, widget.id) config = db_session.get(PhotoWidgetConfig, widget.id)
assert config is not None assert config is not None
assert config.album_id == frame.album_id assert config.album_id == ""
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"} assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
@@ -140,17 +194,26 @@ def test_rerunning_migrations_does_not_duplicate_widgets(db_session):
def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session): def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
"""Reproduces the old fixed 50/50 inlay split as two independent, """Reproduces the old fixed 50/50 inlay split as two independent,
non-overlapping widgets instead of silently dropping the photo half non-overlapping widgets instead of silently dropping the photo half
on upgrade -- see models.py's CalendarWidgetConfig docstring.""" on upgrade -- see models.py's CalendarWidgetConfig docstring. Exercises
frame = Frame( _migration_41's raw-SQL backfill safety net: a frame whose legacy
name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay", Frame columns (pre-widget-system) still carry real data but which
mode="calendar", orientation="landscape", calendar_view="week", somehow has no Widget yet."""
calendar_photo_inlay=True, album_id="album-123", frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
current_asset_id="asset-1", queue=["asset-1", "asset-2"], orientation="landscape", created_at=time.time())
created_at=time.time(),
)
db_session.add(frame) db_session.add(frame)
db_session.flush()
frame_id = frame.id
db_session.commit() db_session.commit()
with db_module.engine.begin() as conn:
_add_legacy_frame_columns(conn)
conn.execute(text(
"UPDATE frames SET mode='calendar', calendar_view='week', calendar_photo_inlay=1, "
"album_id='album-123', current_asset_id='asset-1', queue='[\"asset-1\", \"asset-2\"]' "
"WHERE id = :id"
), {"id": frame_id})
conn.execute(text("UPDATE schema_version SET version = 40"))
run_migrations() run_migrations()
widgets = db_session.scalars( widgets = db_session.scalars(
@@ -180,15 +243,24 @@ def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session): def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session):
frame = Frame( """Exercises _migration_41's raw-SQL backfill safety net for a
name="WB Frame", device_token="tok-wb", manage_token="mtok-wb", whiteboard-mode legacy frame -- same shape as the calendar-inlay case
mode="whiteboard", orientation="portrait", above, just the simpler single-widget mode dispatch branch."""
whiteboard_url="https://example.com/board.whiteboard", frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
created_at=time.time(), orientation="portrait", created_at=time.time())
)
db_session.add(frame) db_session.add(frame)
db_session.flush()
frame_id = frame.id
db_session.commit() db_session.commit()
with db_module.engine.begin() as conn:
_add_legacy_frame_columns(conn)
conn.execute(text(
"UPDATE frames SET mode='whiteboard', "
"whiteboard_url='https://example.com/board.whiteboard' WHERE id = :id"
), {"id": frame_id})
conn.execute(text("UPDATE schema_version SET version = 40"))
run_migrations() run_migrations()
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
@@ -249,19 +321,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
conn.execute(text( conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
)) ))
_add_legacy_frame_columns(conn)
conn.execute(text("UPDATE schema_version SET version = 15")) conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame( frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up", created_at=time.time())
mode="photos", album_id="legacy-album", current_asset_id="legacy-asset",
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
db_session.add(frame) db_session.add(frame)
db_session.flush() db_session.flush()
user = make_user(db_session, "legacy-owner") user = make_user(db_session, "legacy-owner")
db_session.commit() db_session.commit()
frame_id, user_id = frame.id, user.id frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text(
"UPDATE frames SET mode='photos', album_id='legacy-album', current_asset_id='legacy-asset', "
"queue='[\"legacy-asset\", \"next-asset\"]' WHERE id = :id"
), {"id": frame_id})
# An orphaned frame_calendars row (this frame's mode was never # An orphaned frame_calendars row (this frame's mode was never
# "calendar", so it has no calendar widget for _ensure_frame_ # "calendar", so it has no calendar widget for _ensure_frame_
# calendars_rekeyed to attach it to) -- exercises that it's dropped # calendars_rekeyed to attach it to) -- exercises that it's dropped
@@ -533,12 +609,11 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
conn.execute(text( conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
)) ))
_add_legacy_frame_columns(conn)
conn.execute(text("UPDATE schema_version SET version = 15")) conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame( frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal", created_at=time.time())
mode="calendar", created_at=time.time(),
)
db_session.add(frame) db_session.add(frame)
db_session.flush() db_session.flush()
user = make_user(db_session, "cal-owner") user = make_user(db_session, "cal-owner")
@@ -546,6 +621,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
frame_id, user_id = frame.id, user.id frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id})
conn.execute(text( conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) " "INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) "
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)" "VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)"
+9 -7
View File
@@ -14,7 +14,7 @@ from PIL import Image
from app.image_pipeline import logical_render_size from app.image_pipeline import logical_render_size
from app.models import Frame from app.models import Frame
from .conftest import link_user, login, make_user from .conftest import claim_device, link_user, login, make_user
def test_now_displaying_404s_before_any_device_fetch(client, db_session): def test_now_displaying_404s_before_any_device_fetch(client, db_session):
@@ -27,8 +27,9 @@ def test_now_displaying_404s_before_any_device_fetch(client, db_session):
def test_frame_image_records_now_displaying(client, db_session): def test_frame_image_records_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get("/frame/image") resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = client.get("/api/frames/1/now-displaying") resp = client.get("/api/frames/1/now-displaying")
@@ -43,18 +44,18 @@ def test_frame_image_records_now_displaying(client, db_session):
def test_advance_and_back_also_update_now_displaying(client, db_session): def test_advance_and_back_also_update_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
db_session.get(Frame, 1) creds = claim_device(db_session, db_session.get(Frame, 1))
client.get("/frame/image") client.get(f"/frame/image?{creds}")
first = client.get("/api/frames/1/now-displaying") first = client.get("/api/frames/1/now-displaying")
assert first.status_code == 200 assert first.status_code == 200
resp = client.post("/frame/advance") resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
after_advance = client.get("/api/frames/1/now-displaying") after_advance = client.get("/api/frames/1/now-displaying")
assert after_advance.status_code == 200 assert after_advance.status_code == 200
resp = client.post("/frame/back") resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
after_back = client.get("/api/frames/1/now-displaying") after_back = client.get("/api/frames/1/now-displaying")
assert after_back.status_code == 200 assert after_back.status_code == 200
@@ -66,7 +67,8 @@ def test_now_displaying_visible_to_linked_user(client, db_session):
bob = make_user(db_session, "bob") bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame) link_user(db_session, bob, frame)
client.get("/frame/image") creds = claim_device(db_session, frame)
client.get(f"/frame/image?{creds}")
client.cookies.clear() client.cookies.clear()
login(client, "bob") login(client, "bob")