diff --git a/CLAUDE.md b/CLAUDE.md
index c5e3dc6..9a431c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,8 +15,7 @@ Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
server talk (sequence diagram, boot flow).
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
- (data model, grid placement, compositor, button-action dispatch). Notes
- a known gap at the bottom (legacy `Frame` columns not yet dropped).
+ (data model, grid placement, compositor, button-action dispatch).
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
-- per-component setup, config, and a lot of accumulated gotchas
diff --git a/docs/widgets.md b/docs/widgets.md
index a08aaa9..f1b7f2a 100644
--- a/docs/widgets.md
+++ b/docs/widgets.md
@@ -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
at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one
-full-panel renderer; that column (and the other now-dead per-mode `Frame`
-columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
-is still physically present but unused, pending a final cleanup migration
-(see "Known gaps" below).
+full-panel renderer; that column and the other per-mode `Frame` columns
+it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
+dropped in migration 41, once every phase of the rollout had shipped
+(see "Known gaps" below for what's still open).
The device-facing contract is unchanged by any of this: `GET /frame/image`,
`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
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
reliable yet, treat it as experimental if extending it.
diff --git a/firmware/README.md b/firmware/README.md
index 3dd62aa..f56aff2 100644
--- a/firmware/README.md
+++ b/firmware/README.md
@@ -147,10 +147,9 @@ two-step setup screen:
portal's config page (`http://192.168.4.1/` by default), for a
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
-Immich server; see below for the `https://` form), and an optional
-"Access Token" (see below -- usually blank). Saving hands your browser
+Immich server; see below for the `https://` form). Saving hands your browser
off to the server's claim page (after ~7 seconds, giving your phone
time to rejoin its normal WiFi while the device reboots) so the frame
gets linked to your account; the device meanwhile connects to your home
@@ -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
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
Wire a momentary push button between GPIO2 and GND (internal pull-up,
diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c
index 712ff14..c6d9389 100644
--- a/firmware/main/frame_client.c
+++ b/firmware/main/frame_client.c
@@ -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),
- * appending cfg->access_token as ?token= if one's set. toolsserver is
- * normally a bare "host:port", defaulting to plain http; it may instead
- * carry an explicit "http://" or "https://" prefix to pick the scheme,
- * e.g. "https://frame.example.com" if a reverse proxy is terminating
- * TLS in front of the tools server. Every URL carries ?id= (the device's
- * MAC-derived identity -- how a multi-frame server tells frames apart
- * and how an unknown frame self-registers) plus &token=: the server-
- * issued per-frame device token once one has been delivered via
- * /frame/config, else the provisioned access token (the legacy shared
- * 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. */
+ * appending cfg->device_token as &token= once one's been delivered.
+ * toolsserver is normally a bare "host:port", defaulting to plain http;
+ * it may instead carry an explicit "http://" or "https://" prefix to
+ * pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
+ * is terminating TLS in front of the tools server. Every URL carries
+ * ?id= (the device's MAC-derived identity -- how a multi-frame server
+ * tells frames apart and how an unknown frame self-registers) plus
+ * &token=. 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)
{
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);
}
- const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
- if (token[0] != '\0' && len < out_size) {
- snprintf(out + len, out_size - len, "&token=%s", token);
+ if (cfg->device_token[0] != '\0' && len < out_size) {
+ snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
}
}
diff --git a/firmware/main/ota_update.c b/firmware/main/ota_update.c
index 9fd6c7d..3c11fac 100644
--- a/firmware/main/ota_update.c
+++ b/firmware/main/ota_update.c
@@ -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);
}
- const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
- if (token[0] != '\0' && len < out_size) {
- snprintf(out + len, out_size - len, "&token=%s", token);
+ if (cfg->device_token[0] != '\0' && len < out_size) {
+ snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
}
}
diff --git a/firmware/main/root.html b/firmware/main/root.html
index 4cfdebf..e3785fc 100644
--- a/firmware/main/root.html
+++ b/firmware/main/root.html
@@ -97,11 +97,6 @@
-
-
-
-
-
After saving, this page will
take you to the server to claim your frame — reconnect to
your normal WiFi if it doesn't happen automatically.
diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c
index 7ace5c7..37b6fb3 100644
--- a/firmware/main/wifi_provisioning.c
+++ b/firmware/main/wifi_provisioning.c
@@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
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
* (see frame_config_set_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) {
nvs_close(handle);
return token_err;
@@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
if (err == ESP_OK) {
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) {
/* Re-provisioning restarts the identity handshake: the server
* (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_pass");
nvs_erase_key(handle, "toolsserver");
- nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once");
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, "password", cfg.sta_password, sizeof(cfg.sta_password));
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) {
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;
}
- ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
- strlen(cfg.access_token) ? "set" : "none");
+ ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
/* 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
diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h
index a9869b6..a6f7e07 100644
--- a/firmware/main/wifi_provisioning.h
+++ b/firmware/main/wifi_provisioning.h
@@ -17,11 +17,10 @@ typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_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
- * this device first introduces itself by id -- preferred over
- * access_token once present (see frame_client.c's build_url). Not
- * set at the captive portal; empty until the server pushes one. */
+ * this device first introduces itself by id (see frame_client.c's
+ * build_url). Not set at the captive portal; empty until the server
+ * pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t;
diff --git a/firmware/version.txt b/firmware/version.txt
index 347f583..9df886c 100644
--- a/firmware/version.txt
+++ b/firmware/version.txt
@@ -1 +1 @@
-1.4.1
+1.4.2
diff --git a/server/README.md b/server/README.md
index bbc0fb5..f4af2c3 100644
--- a/server/README.md
+++ b/server/README.md
@@ -37,13 +37,10 @@ algorithm itself -- it just streams the response straight to the panel.
instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server
issues it on the frame's first check-in, so there's nothing to
- configure. The captive portal's **Access Token** field only matters
- when pointing new firmware at an old (pre-multi-frame) server.
- `MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
- *migration* credential: a frame flashed with pre-multi-frame
- firmware authenticates with it until it's updated and bound (the
- Admin page shows the migration state per frame and a "Close legacy
- window" button for when it's done).
+ configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
+ only matters pre-setup: if set, it's the credential that gates who
+ gets to be the one to run first-run setup on a freshly deployed
+ server, before any admin account exists.
6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets
@@ -214,9 +211,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
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,
both are opened by a phone that has no way to supply `?id=`/`?token=`),
- and `MANAGEMENT_TOKEN` survives only as the migration credential for
- pre-multi-frame firmware.
-- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
+ and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
+ step 5 above).
+- The calendar widget (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
@@ -234,7 +231,7 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
-- 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`
file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small
diff --git a/server/app/auth.py b/server/app/auth.py
index fe960f0..1c2550d 100644
--- a/server/app/auth.py
+++ b/server/app/auth.py
@@ -1,14 +1,18 @@
-"""Authentication: password hashing, user sessions + CSRF, the legacy
-shared-token gate, and device resolution.
+"""Authentication: password hashing, user sessions + CSRF, the pre-setup
+claim gate, and device resolution.
Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans.
-- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
- browser routes so the deployed frame's on-panel manage QR (which
- embeds ?token=) keeps working until Phase C replaces it with the
- limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
- credential, not an ambient cookie a cross-site request could ride).
+- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
+ account exists yet (fresh install, or freshly migrated, before
+ /setup has been run): if set, it gates who gets to be the one to run
+ /setup and claim the first admin account; once a user exists, sessions
+ 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).
"""
@@ -250,17 +254,16 @@ def require_frame_control(
def management_token() -> str:
- """The legacy shared secret. Env-only, never stored -- same as the old
- server, where the env var overrode anything on disk on every load."""
+ """The pre-setup claim-gate secret. Env-only, never stored -- same as
+ the old server, where the env var overrode anything on disk on every
+ load."""
return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool:
- """The legacy shared-token check. No MANAGEMENT_TOKEN configured means
- token-holders don't exist -- but unlike Phase A this no longer means
- "open": once users exist, sessions are the primary gate and this is
- only the compatibility path for the deployed frame's manage QR
- (?token=) until Phase C. Empty token => not valid (sessions rule)."""
+ """Whether the request carries the current MANAGEMENT_TOKEN, via
+ query param or cookie. Only meaningful pre-setup (see require_browser
+ below) -- empty configured token => not valid (nothing to match)."""
token = management_token()
if not token:
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:
"""Dependency for the web UI's /api/* routes: a real user session
- (CSRF-checked on mutations, returns the User), or the legacy shared
- token (returns None -- token bearers act as an anonymous operator,
- exactly the pre-user model). While NO users exist yet (fresh install
- or freshly migrated, before /setup has been run) the API stays open
- if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
- since there's nobody to log in as yet."""
+ (CSRF-checked on mutations, returns the User). While NO users exist
+ yet (fresh install, or freshly migrated, before /setup has been run)
+ the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
+ to whoever supplies it if one is -- there's nobody to log in as yet,
+ so this is purely the claim gate for who gets to run /setup. Once a
+ user exists, only a session gets in."""
session = current_session(request, db)
if session is not None:
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)
if user is not None:
return user
- if browser_token_valid(request):
- return None
- if not users_exist(db) and not management_token():
- return None
+ if not users_exist(db):
+ if not management_token() or browser_token_valid(request):
+ return None
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:
"""Resolves and authenticates the frame behind a /frame/* request.
-
- New firmware sends ?id=<12-hex-mac>&token=.
- Deployed legacy firmware sends only ?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.
- """
+ Firmware sends ?id=<12-hex-mac>&token=."""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
- legacy = management_token()
- legacy_ok = not legacy or token == legacy
- if device_id:
- frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
- if frame is None:
- legacy_frame = db.scalars(
- select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
- ).first()
- 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")
+ if not device_id:
+ raise HTTPException(401, "Missing device id")
+
+ frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
+ if frame is None:
+ frame = _register_frame(db, device_id)
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")
- frame = db.scalars(
- select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
- ).first()
- if frame is None:
- # Nothing to resolve a no-id request to. migration.py always
- # creates frame #1 at startup, so this only happens if it was
- # deleted -- treat like an unknown device.
- raise HTTPException(401, "No frame accepts legacy credentials")
+ # else: 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.
frame.last_seen = time.time()
db.commit()
diff --git a/server/app/main.py b/server/app/main.py
index f3be7cc..8221624 100644
--- a/server/app/main.py
+++ b/server/app/main.py
@@ -110,26 +110,16 @@ def service_worker() -> FileResponse:
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
- own credentials (new firmware: ?id=&token=; deployed firmware:
- ?token=). Those scans get the frame's limited
- 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."""
+ own credentials (?id=&token=). Those scans get the frame's limited
+ manage page -- never the full UI, which requires a login."""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
if device_id and token:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and token == frame.device_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
@@ -140,7 +130,7 @@ def index(request: Request):
else is walked through setup/login."""
with SessionLocal() as db:
have_users = users_exist(db)
- manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
+ manage_redirect = _device_credential_redirect(request, db)
if manage_redirect is not None:
return RedirectResponse(manage_redirect, status_code=303)
diff --git a/server/app/migration.py b/server/app/migration.py
index 19bccfb..11b6f24 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -22,13 +22,9 @@ from .db import SessionLocal, engine
from .models import (
Base,
BatteryLog,
- CalendarWidgetConfig,
Frame,
- FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
- TaskWidgetConfig,
- WhiteboardWidgetConfig,
Widget,
)
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"))
+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 = [
(1, _migration_1),
(2, _migration_2),
@@ -943,6 +1198,7 @@ MIGRATIONS = [
(38, _migration_38),
(39, _migration_39),
(40, _migration_40),
+ (41, _migration_41),
]
@@ -959,18 +1215,53 @@ def run_migrations() -> None:
# already added (e.g. "duplicate column name"). Jump
# straight to the latest version instead.
_migration_1(conn)
- latest = MIGRATIONS[-1][0]
- conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
+ current = MIGRATIONS[-1][0]
+ conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
else:
current = row[0]
- for version, fn in MIGRATIONS:
- if version > current:
- logger.info("Applying schema migration %d", version)
- fn(conn)
- conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
+
+ # Each migration commits in its own transaction (rather than the
+ # whole batch sharing one, like this used to) so that _migration_41
+ # can get a connection with no transaction pending on it yet --
+ # 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_server_settings()
- _ensure_widgets_backfilled()
_ensure_frame_calendars_rekeyed()
@@ -985,11 +1276,11 @@ def new_manage_token() -> str:
def _ensure_frame_one() -> None:
"""First boot only (frames table empty): create frame #1 -- imported
verbatim from a legacy config.json if one exists, otherwise fresh
- defaults. Either way it's the legacy-token frame: the deployed
- firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
- and require_device resolves those requests here. The frames-nonempty
- guard makes this idempotent; config.json is left untouched as the
- rollback path."""
+ defaults -- plus a single full-panel photos widget carrying over
+ whatever photo-queue state that file had (the widget system's
+ equivalent of what used to live directly on Frame; see migration
+ 41). The frames-nonempty guard makes this idempotent; config.json is
+ left untouched as the rollback path."""
with SessionLocal() as db:
if db.scalars(select(Frame).limit(1)).first() is not None:
return
@@ -1002,26 +1293,15 @@ def _ensure_frame_one() -> None:
device_id=None,
device_token=new_device_token(),
manage_token=new_manage_token(),
- legacy_token_enabled=True,
created_at=time.time(),
immich_url=cfg.immich_url,
immich_api_key=cfg.immich_api_key,
- album_id=cfg.album_id,
- order=cfg.order,
refresh_interval_s=cfg.refresh_interval_s,
quiet_hours_enabled=cfg.quiet_hours_enabled,
quiet_hours_start=cfg.quiet_hours_start,
quiet_hours_end=cfg.quiet_hours_end,
timezone=cfg.timezone,
- display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
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_as_of=cfg.battery_as_of,
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,
)
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:
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()
# The single legacy firmware slot becomes frame #1's per-frame slot.
@@ -1078,168 +1378,6 @@ def _ensure_server_settings() -> None:
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:
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
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
live configuration.
- Deliberately NOT a numbered migration: this needs each frame's
- calendar widget to already exist to know what to re-key against, and
- those widget rows aren't created by a schema migration at all --
- they come from _ensure_widgets_backfilled() above, which (like this
- function) runs unconditionally after every startup rather than being
- tracked by schema_version. Running this as a numbered migration
- would execute it *before* that backfill during a real upgrade (the
- numbered-migration loop runs first, see run_migrations), silently
- dropping every row -- caught by test_migrations.py actually exercising
- the raw-SQL upgrade path instead of the fresh-install create_all()
- shortcut every other test in that file takes.
+ Deliberately NOT a numbered migration: every frame's calendar widget
+ must already exist to know what to re-key against, and for a genuine
+ pre-widget-system database those rows only exist once _migration_41's
+ own backfill has run (a step inside that migration, not before it).
+ A numbered migration for this would race ahead of that backfill (the
+ numbered-migration loop runs top to bottom in one pass, see
+ run_migrations), silently dropping every row -- caught by
+ test_migrations.py actually exercising the raw-SQL upgrade path
+ instead of the fresh-install create_all() shortcut every other test
+ in that file takes.
- Runs unconditionally after every startup, like _ensure_widgets_
- backfilled; a no-op the moment frame_calendars is already
- widget_id-shaped (every fresh install, and any existing install
- after its first run past this code) -- SQLite can't ALTER a column's
- FK target or drop a column that's part of an index/FK constraint, so
- when it isn't a no-op this is the standard SQLite "rebuild" pattern:
- create the new-shape table, copy matching rows across (joining to
- find each row's calendar widget), drop the old table, rename the new
- one into place."""
+ Runs unconditionally after every startup instead; a no-op the moment
+ frame_calendars is already widget_id-shaped (every fresh install,
+ and any existing install after its first run past this code) --
+ SQLite can't ALTER a column's FK target or drop a column that's part
+ of an index/FK constraint, so when it isn't a no-op this is the
+ standard SQLite "rebuild" pattern: create the new-shape table, copy
+ matching rows across (joining to find each row's calendar widget),
+ drop the old table, rename the new one into place."""
inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
if "widget_id" in columns:
diff --git a/server/app/models.py b/server/app/models.py
index cf01e61..4f2b734 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -117,14 +117,10 @@ class Frame(Base):
__tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True)
- # 12 lowercase hex chars of the device's full STA MAC. NULL only for
- # the migrated legacy frame until its device first reports an id.
+ # 12 lowercase hex chars of the device's full STA MAC. NULL until its
+ # device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
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.
owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
@@ -138,11 +134,6 @@ class Frame(Base):
# pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
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)
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_api_key: Mapped[str] = mapped_column(String, default="")
- # -- settings (attribute names match the old FrameConfig fields) --
- album_id: Mapped[str] = mapped_column(String, default="")
- order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
+ # -- settings --
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
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")
- queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
# 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.
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 --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
diff --git a/server/app/routers/device.py b/server/app/routers/device.py
index 1350ab0..ee84283 100644
--- a/server/app/routers/device.py
+++ b/server/app/routers/device.py
@@ -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).
"hold_duration_ms": locked.hold_duration_ms,
}
- # Per-frame token push: only once the device has introduced itself
- # by id (so the response to pure-legacy firmware stays byte-
- # compatible with its 256-byte parse buffer), and only until the
- # device has authenticated with the token once (device_token_ack).
- if locked.device_id is not None and not locked.device_token_ack:
+ # Per-frame token push: only until the device has authenticated
+ # with it once (device_token_ack) -- no reason to keep sending it
+ # on every wake once the device has it.
+ if not locked.device_token_ack:
response["device_token"] = locked.device_token
return response
diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py
index 23aba1a..779bcae 100644
--- a/server/app/routers/pages.py
+++ b/server/app/routers/pages.py
@@ -707,26 +707,6 @@ def admin_link_user(
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)
def admin_smtp_save(
request: Request,
diff --git a/server/app/templates/admin.html b/server/app/templates/admin.html
index d7db74d..e036696 100644
--- a/server/app/templates/admin.html
+++ b/server/app/templates/admin.html
@@ -109,20 +109,12 @@
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
· token ack: {{ "yes" if f.device_token_ack else "no" }}
- {% if f.legacy_token_enabled %}· legacy token window OPEN{% endif %}
- {% if f.legacy_token_enabled %}
-
- {% endif %}