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
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:
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 — 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 — reconnect to
|
take you to the server to claim your frame — reconnect to
|
||||||
your normal WiFi if it doesn't happen automatically.</p>
|
your normal WiFi if it doesn't happen automatically.</p>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
1.4.1
|
1.4.2
|
||||||
|
|||||||
+8
-11
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -109,20 +109,12 @@
|
|||||||
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
· 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" }})
|
||||||
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
||||||
{% if f.legacy_token_enabled %}· <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 }}">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user