5 Commits
Author SHA1 Message Date
tfaour 1d39e439ff Drop the last legacy widget-system and shared-token auth scaffolding
Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.

Firmware: the captive portal's "Access Token" field and its NVS/
build_url plumbing only ever mattered for pointing new firmware at an
old pre-multi-frame server -- gone along with the server-side fallback
it fed. Version bump to publish the change.
2026-08-04 18:33:29 +00:00
tfaour 2868087467 Add a per-widget text-size picker for calendar/tasks legibility
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m24s
Calendar and tasks pack the most body text at the smallest default
sizes, so those two gear-icon dialogs get a "Text size" card (Normal/
Large/X-Large) alongside the existing Border card -- a new Widget-level
font_scale column with its own POST .../font-scale endpoint, same
Widget-property-not-config-field shape as border_style. Threaded through
every classic (calendar_render.py) and modern (html_render.py/
calendar_html_render.py) size calc via one shared panel_style.
scaled_size() so row heights/max_rows already derived from font size
re-fit around the bigger text automatically.
2026-08-02 03:23:27 +00:00
tfaour 09119e775f Deploy: retry "docker compose up -d" instead of guessing at a stale container
Build and push server image / test (push) Successful in 41s
Build and push server image / build-and-push (push) Successful in 3m56s
Build and push server image / deploy (push) Failing after 1m37s
The previous fix (kill anything on port 8420 before up) didn't help --
confirmed nothing was actually squatting on the port. The real cause,
per the maintainer: "up -d" run manually a few seconds after "down"
always succeeds, but scripted straight through (down && pull && up,
pull sometimes a no-op if the image is already cached) fails every
time. That's "down" returning before the OS/docker-proxy has actually
released port 8420 yet, not an orphaned container -- a timing race, not
a stuck process. Retrying "up -d" a few times with a short pause rides
out that race without needing to guess a fixed sleep long enough to
always cover it.
2026-08-01 12:46:05 +00:00
tfaour f209880fd0 Deploy: kill anything holding port 8420 before bringing the new container up
Build and push server image / test (push) Successful in 47s
Build and push server image / build-and-push (push) Successful in 4m14s
Build and push server image / deploy (push) Failing after 1m27s
"docker compose down" before "pull/up" (previous commit) didn't fix the
port conflict -- it only tears down containers this compose project
itself tracks, so a stale/orphaned container from an earlier deploy (or
anything else bound to 8420, especially with restart: unless-stopped
fighting back) slips through untouched and the new "up" fails with
"port is already allocated". This is root-cause-agnostic instead: find
and stop/remove *any* container publishing 8420, compose-managed or
not, right before pull/up. --remove-orphans on the down step too, for
services that used to be in the compose file and aren't anymore.
2026-08-01 12:05:58 +00:00
tfaour 455020cb1f Redo modern-style widgets in a "bold minimal" language, not just a reskin
Build and push server image / test (push) Successful in 55s
Build and push server image / build-and-push (push) Successful in 4m40s
Build and push server image / deploy (push) Failing after 1m33s
The first modern-style rollout translated each widget's existing classic
layout into HTML/CSS -- same gradient headers, same rounded-shadowed
card, prettier chrome around an unchanged composition. This actually
redesigns weather (current/daily), calendar (all four views), tasks, and
battery: no card/shadow anywhere, a slim accent-colored rule instead of
a full gradient banner (and only that rule dithers at the richer accent
amplitude now, not the header text sitting on it), and a dominant hero
value (temperature/percent) instead of a centered icon+number of equal
weight. Padding and type sizes scale as a clamped proportion of widget
size instead of fixed pixel values. Text and static/whiteboard are left
alone -- text already had zero chrome and its styling is user content,
not this system's to redesign; framed_image's card was already minimal.

Direction was picked from three divergent mockups reviewed with the
maintainer, then verified against the real render pipeline (actual
Chromium render, actual ordered dithering, actual theme system) rather
than just eyeballed -- that caught a day-section/month-grid divider
color (#e2e6ec) that's nowhere near this panel's 6-color palette and was
dithering to invisible white; fixed with a real black hairline in the
one place (month view) that still needed one.
2026-08-01 11:53:42 +00:00
51 changed files with 1488 additions and 924 deletions
+22 -2
View File
@@ -68,5 +68,25 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
espressoframe_deployer@"$DEPLOY_HOST" \
'cd ~/espresso-frame && docker compose down && docker compose pull && docker compose up -d'
espressoframe_deployer@"$DEPLOY_HOST" bash -s <<'REMOTE'
set -e
cd ~/espresso-frame
docker compose down --remove-orphans
docker compose pull
# "down" returning doesn't guarantee the OS/docker-proxy has
# actually released port 8420 yet -- an immediate "up -d" right
# after (especially with "pull" a no-op because the image was
# already cached) can lose that race and fail with "port is
# already allocated", even though the exact same "up -d" run a
# few seconds later succeeds every time. Retry instead of
# guessing at a fixed sleep long enough to always cover it.
for i in $(seq 1 10); do
if docker compose up -d; then
exit 0
fi
echo "docker compose up -d failed (attempt $i/10) -- retrying in 3s"
sleep 3
done
echo "docker compose up -d did not succeed after 10 attempts"
exit 1
REMOTE
+1 -2
View File
@@ -15,8 +15,7 @@ Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and
server talk (sequence diagram, boot flow).
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
(data model, grid placement, compositor, button-action dispatch). Notes
a known gap at the bottom (legacy `Frame` columns not yet dropped).
(data model, grid placement, compositor, button-action dispatch).
- [`docs/hardware.md`](docs/hardware.md) -- wiring.
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
-- per-component setup, config, and a lot of accumulated gotchas
+110 -35
View File
@@ -6,10 +6,10 @@ weather/battery), like arranging icons on an Android home screen. A frame
can hold several widgets of the same type (e.g. two photo widgets pointed
at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
is still physically present but unused, pending a final cleanup migration
(see "Known gaps" below).
full-panel renderer; that column and the other per-mode `Frame` columns
it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
dropped in migration 41, once every phase of the rollout had shipped
(see "Known gaps" below for what's still open).
The device-facing contract is unchanged by any of this: `GET /frame/image`,
`POST /frame/advance`, `POST /frame/back` are the same frozen paths
@@ -42,6 +42,20 @@ a button press does.
`api_widget_config_save`) since that endpoint's per-type dispatch is
keyed on a config row via `widget_locked`, and border fields live on
`Widget` itself, not any per-type config table.
Also carries `font_scale` (one of `panel_style.FONT_SCALE_CHOICES` --
`1.0`/`1.25`/`1.5`, labeled Normal/Large/X-Large), a per-widget
legibility control: calendar and tasks widgets pack in the most body
text at the smallest default sizes, so their gear-icon dialogs get a
"Text size" card (`_widget_font_scale_fields.html`) the other types
don't. Same Widget-level-property-not-config-field reasoning as
border, and its own `POST .../widgets/{id}/font-scale` endpoint for
the same reason. `panel_style.scaled_size(value, font_scale)` is the
one shared multiply-and-round point every classic (`calendar_render.py`)
and modern (`html_render.py`/`calendar_html_render.py`) size calc
routes through immediately after its own tier lookup/floor, so row
heights and per-view row caps (already derived from the font size, not
a fixed constant) automatically re-fit around the bigger text instead
of overflowing their box.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
@@ -210,7 +224,70 @@ Per-widget-type notes:
either widget type has ever had (classic draws the image with zero
frame/card at all) -- a rounded-corner, shadowed card
(`framed_image.html.jinja`, shared between the two) wrapping the
already-composed image.
already-composed image. Left alone by the "bold minimal" pass below --
it never had the reskinned-classic problem the other widgets did.
### "Bold minimal": a real redesign, not just a reskin
The initial modern-style rollout (above) mostly translated each widget's
*existing* classic layout into HTML/CSS -- same gradient header banner,
same rounded-shadowed white card, prettier chrome around an unchanged
composition. A second pass reworked weather (`current`/`daily`),
calendar (all four views), tasks, and battery into an actual different
visual language, picked from several divergent directions rendered
through the real pipeline and reviewed with the maintainer (not chosen
unilaterally -- see the "Reverted e-ink quantization attempt"-style
caution about visual changes needing more than one look). Text and
static/whiteboard were deliberately left as they were (see their notes
just above) -- text already had zero chrome and its styling is
user-authored content, not this system's to redesign; the framed-image
card was already minimal.
What changed, as a consistent language across every redesigned widget:
- **No card.** No rounded-corner white box, no drop shadow, no outer
border -- content sits directly on the shared white canvas. `theme
["radius"]`/`theme["shadow"]` are now unused by every redesigned
widget's builder (still resolved, for signature uniformity with
`resolve_theme`, but nothing reads them) -- a theme's radius/shadow
fields now only affect the *un*-redesigned modern widgets (static
image/whiteboard's `framed_image.html.jinja`).
- **A slim accent rule instead of a gradient banner.** Every widget that
used to have a colored header bar with white text on it (weather's
`build_daily`, tasks, calendar's four views) now has a thin (~4-8px)
accent-colored rounded rule, with the header text as plain ink below
it instead of white text on top of it -- only that thin rule dithers
at the theme's richer `accent_amplitude` via `ordered_dither_regions`
now, not the header text sitting on it, which reads as a legibility
improvement, not just a visual one (see "Rich accent hues" below).
- **A dominant hero value, not a centered icon+number of equal weight.**
Weather's `build_current` and battery's icon+percent used to be drawn
at roughly the same size, centered as a unit; both now put the numeric
value (temperature / battery percent) at a clearly dominant size, with
the icon small and secondary above it -- closer to a phone home-screen
widget than a dashboard tile.
- **Padding/type sizes as a proportion of widget size, clamped to a
floor/ceiling, not a fixed pixel value.** So a 1-2 grid-cell widget
doesn't get comically large padding relative to its content, and a
near-full-panel widget doesn't get comically small padding either --
see `html_render._clamp` and every redesigned `build_*`'s own
`pad`/size calculations (`base = min(target_w, target_h)`, then a
fraction of `base` clamped to tuned floor/ceiling values).
**A hairline color this palette can't actually render.** Auditing the
month view's grid during this pass turned up a real, pre-existing bug
carried forward unnoticed since the very first modern-style rollout:
`.day-cell`/`.day-section`/`.col` divider borders used a pale gray
(`#e2e6ec`) -- but `DEFAULT_PALETTE_RGB` has no gray in it at all (black/
white/yellow/red/blue/green only), so a color that close to white always
nearest-matches to pure white regardless of Bayer bias, at any amplitude
-- confirmed by sampling actual rendered pixels, not just eyeballing a
screenshot. The month grid's week-row dividers now use real solid black
(`RULE`-equivalent, matching how the *classic* PIL renderer always drew
them -- see `calendar_render.RULE`); the day-section/week-column dividers
were simply dropped instead, since the accent rule + spacing at the
start of the next section/column already read as a clear boundary
without a line at all once you could actually render one.
### Themes for modern-style widgets
@@ -227,16 +304,17 @@ three-layer CSS custom-property theme system -- this is an original
reimplementation of that *architecture*, not a copy of its token file
(see this repo's `CLAUDE.md` on copyleft dependencies).
**What a theme actually changes, in practice**: the header/accent color
is the most visible change, but `font_family` applies to *every* text
element in the widget, not just the header title -- day labels,
temperatures, task rows, event times, day numbers all switch fonts too
(e.g. "Moss" is serif, "Ochre" a slab serif), often more noticeable than
the accent color on text-heavy widgets. `radius`/`shadow` change the
card's corners and whether it has a drop shadow at all (e.g. "Slate" is
square-cornered with no shadow). Battery and text/static/whiteboard have
no header, so for those a theme only shows up as font/radius/shadow
differences, not an accent color.
**What a theme actually changes, in practice**: the accent color (now a
slim rule rather than a full header band -- see "Bold minimal" above) is
still the most visible change on widgets that have one, but `font_family`
applies to *every* text element in the widget, not just the header title
-- day labels, temperatures, task rows, event times, day numbers all
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
more noticeable than the accent color on text-heavy widgets. `radius`/
`shadow` only affect static image/whiteboard's card now (every other
modern-style widget dropped its card in the "bold minimal" pass); text
never used them (no card from the start) and weather/battery/tasks/
calendar no longer have a card for them to apply to either.
**A theme is purely stylistic, never functional color-coding.** Battery's
charge-level red/yellow/green, calendar/tasks' per-owner event color
@@ -279,13 +357,14 @@ fall back to black, though none of their templates currently have an
accent-colored surface for it to visibly affect.
Which widgets get the richer accent-region treatment: weather's
`build_daily` (the header bar, when `city_label` is set), tasks, and
calendar's four view builders (each already computed a `header_h` in
Python for layout, reused as the accent rect). Weather's `build_current`,
battery, and static/whiteboard's shared `build_framed_image` are
theme-aware for font/radius/shadow only -- no header/accent region to
dither richer, so they call plain `ordered_dither` exactly as before
themes existed.
`build_daily` (the slim rule, when `city_label` is set), tasks, and
calendar's four view builders -- each computes its own small accent-rule
pixel rect (a fixed-height band, not the old full header_h) and passes
just that to `ordered_dither_regions`. Weather's `build_current` and
battery have no accent surface at all (no header of any kind -- see
"Bold minimal" above) and static/whiteboard's shared `build_framed_image`
is unchanged from the original rollout; all three call plain
`ordered_dither` with no accent region.
## Button actions
@@ -484,20 +563,16 @@ piece of code with its own fixed small size, not shared with this
widget, but drawing from the same thresholds/colors so a battery glyph
reads the same wherever one shows up on a panel.
## Known gaps (Phase 6, not yet done)
## Known gaps
The original 8-phase rollout plan's last phase is still open:
The original 8-phase rollout plan's last phase is done: migration 41
dropped the legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, `queue`, etc.)
-- see its own docstring in `app/migration.py` for the raw-SQL backfill
safety net that ran first, and `server/README.md` no longer describes
photos/calendar/whiteboard as per-frame "modes".
Still open:
- Legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are
still physically present in the schema but no longer read or written
anywhere -- they need a dedicated final migration to drop them. Left in
place deliberately through the widget-system rollout (a much larger
blast radius cutover than this project's usual same-migration-drop
convention) but there's no reason to keep carrying them now that every
phase has shipped.
- `server/README.md` still describes photos/calendar/whiteboard as
per-frame "modes" in several places rather than widgets -- needs a pass
once the column drop above is safely deployed.
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
reliable yet, treat it as experimental if extending it.
+2 -16
View File
@@ -147,10 +147,9 @@ two-step setup screen:
portal's config page (`http://192.168.4.1/` by default), for a
one-scan shortcut once you've joined the AP.
The config page asks for your home WiFi SSID/password, the "Tools
The config page asks for your home WiFi SSID/password and the "Tools
Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server; see below for the `https://` form), and an optional
"Access Token" (see below -- usually blank). Saving hands your browser
Immich server; see below for the `https://` form). Saving hands your browser
off to the server's claim page (after ~7 seconds, giving your phone
time to rejoin its normal WiFi while the device reboots) so the frame
gets linked to your account; the device meanwhile connects to your home
@@ -212,19 +211,6 @@ certificate was actually issued for -- a bare LAN IP address
(`https://192.168.1.50`) will fail the handshake even against a
perfectly valid cert for a different name.
## Access token
Usually blank. Current servers issue each frame its own private token
automatically on first contact (delivered via `GET /frame/config`,
persisted in NVS, preferred by `build_url()` from then on -- and baked
into the manage-menu/share QR codes so scanning them just works). The
captive portal's "Access Token" field only matters when pointing this
firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN`
is set: paste that shared value and the device sends it (`?token=...`)
until a newer server replaces it with a per-frame one. Re-provisioning
clears any stored per-frame token -- a fresh identity handshake with
whatever server you point it at next.
## Skipping to the next photo
Wire a momentary push button between GPIO2 and GND (internal pull-up,
+11 -15
View File
@@ -95,18 +95,15 @@ static void save_wifi_cache(esp_netif_t *netif)
}
/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
* appending cfg->access_token as ?token= if one's set. toolsserver is
* normally a bare "host:port", defaulting to plain http; it may instead
* carry an explicit "http://" or "https://" prefix to pick the scheme,
* e.g. "https://frame.example.com" if a reverse proxy is terminating
* TLS in front of the tools server. Every URL carries ?id= (the device's
* MAC-derived identity -- how a multi-frame server tells frames apart
* and how an unknown frame self-registers) plus &token=: the server-
* issued per-frame device token once one has been delivered via
* /frame/config, else the provisioned access token (the legacy shared
* secret, also what a pre-multi-frame server still expects). This is
* the one chokepoint all requests go through, so every caller gets both
* for free instead of needing to remember to add them. */
* appending cfg->device_token as &token= once one's been delivered.
* toolsserver is normally a bare "host:port", defaulting to plain http;
* it may instead carry an explicit "http://" or "https://" prefix to
* pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
* is terminating TLS in front of the tools server. Every URL carries
* ?id= (the device's MAC-derived identity -- how a multi-frame server
* tells frames apart and how an unknown frame self-registers) plus
* &token=. This is the one chokepoint all requests go through, so every
* caller gets both for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
const char *toolsserver = cfg->toolsserver;
@@ -123,9 +120,8 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
if (token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", token);
if (cfg->device_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
}
}
+2 -3
View File
@@ -36,9 +36,8 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
}
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
if (token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", token);
if (cfg->device_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
}
}
-5
View File
@@ -97,11 +97,6 @@
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required>
</div>
<div class="input-group">
<label for="access_token">Access Token (optional &mdash; only for older servers)</label>
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
</div>
<p style="font-size: 13px; color: #555;">After saving, this page will
take you to the server to claim your frame &mdash; reconnect to
your normal WiFi if it doesn't happen automatically.</p>
+2 -18
View File
@@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
return pass_err;
}
/* Also optional -- most deployments won't set a server-side
* MANAGEMENT_TOKEN at all, in which case this stays empty and the
* manage-menu QR just links to the page with no ?token=. */
len = sizeof(out->access_token);
esp_err_t token_err = nvs_get_str(handle, "access_token", out->access_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
}
/* Optional: absent until the server has pushed a per-frame token
* (see frame_config_set_device_token). */
len = sizeof(out->device_token);
token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
esp_err_t token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
@@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
if (err == ESP_OK) {
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver);
}
if (err == ESP_OK) {
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) {
/* Re-provisioning restarts the identity handshake: the server
* (possibly a different one now) re-issues a device token when
@@ -191,7 +178,6 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_ssid");
nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once");
nvs_commit(handle);
@@ -452,7 +438,6 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password));
extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver));
extract_form_value(body, "access_token", cfg.access_token, sizeof(cfg.access_token));
if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required");
@@ -466,8 +451,7 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
return ESP_FAIL;
}
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
strlen(cfg.access_token) ? "set" : "none");
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
/* The success page hands the browser off to the server's claim page,
* carrying this device's id -- how a frame gets linked to a user
+3 -4
View File
@@ -17,11 +17,10 @@ typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
/* Per-frame token issued by the server via GET /frame/config after
* this device first introduces itself by id -- preferred over
* access_token once present (see frame_client.c's build_url). Not
* set at the captive portal; empty until the server pushes one. */
* this device first introduces itself by id (see frame_client.c's
* build_url). Not set at the captive portal; empty until the server
* pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t;
+1 -1
View File
@@ -1 +1 @@
1.4.1
1.4.2
+8 -11
View File
@@ -37,13 +37,10 @@ algorithm itself -- it just streams the response straight to the panel.
instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server
issues it on the frame's first check-in, so there's nothing to
configure. The captive portal's **Access Token** field only matters
when pointing new firmware at an old (pre-multi-frame) server.
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
*migration* credential: a frame flashed with pre-multi-frame
firmware authenticates with it until it's updated and bound (the
Admin page shows the migration state per frame and a "Close legacy
window" button for when it's done).
configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
only matters pre-setup: if set, it's the credential that gates who
gets to be the one to run first-run setup on a freshly deployed
server, before any admin account exists.
6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets
@@ -214,9 +211,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` survives only as the migration credential for
pre-multi-frame firmware.
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
step 5 above).
- The calendar widget (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
@@ -234,7 +231,7 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
- The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small
+44 -76
View File
@@ -1,14 +1,18 @@
"""Authentication: password hashing, user sessions + CSRF, the legacy
shared-token gate, and device resolution.
"""Authentication: password hashing, user sessions + CSRF, the pre-setup
claim gate, and device resolution.
Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
browser routes so the deployed frame's on-panel manage QR (which
embeds ?token=) keeps working until Phase C replaces it with the
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
credential, not an ambient cookie a cross-site request could ride).
- MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
account exists yet (fresh install, or freshly migrated, before
/setup has been run): if set, it gates who gets to be the one to run
/setup and claim the first admin account; once a user exists, sessions
are the only way in. Not a standing bearer credential -- the on-panel
manage QR now embeds a frame's own per-frame manage_token (/m/, see
routers/manage.py) rather than this shared one; CSRF doesn't apply to
it either way (it's an explicit per-request credential, not an ambient
cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below).
"""
@@ -250,17 +254,16 @@ def require_frame_control(
def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old
server, where the env var overrode anything on disk on every load."""
"""The pre-setup claim-gate secret. Env-only, never stored -- same as
the old server, where the env var overrode anything on disk on every
load."""
return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool:
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
token-holders don't exist -- but unlike Phase A this no longer means
"open": once users exist, sessions are the primary gate and this is
only the compatibility path for the deployed frame's manage QR
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
"""Whether the request carries the current MANAGEMENT_TOKEN, via
query param or cookie. Only meaningful pre-setup (see require_browser
below) -- empty configured token => not valid (nothing to match)."""
token = management_token()
if not token:
return False
@@ -270,12 +273,12 @@ def browser_token_valid(request: Request) -> bool:
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User), or the legacy shared
token (returns None -- token bearers act as an anonymous operator,
exactly the pre-user model). While NO users exist yet (fresh install
or freshly migrated, before /setup has been run) the API stays open
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
since there's nobody to log in as yet."""
(CSRF-checked on mutations, returns the User). While NO users exist
yet (fresh install, or freshly migrated, before /setup has been run)
the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
to whoever supplies it if one is -- there's nobody to log in as yet,
so this is purely the claim gate for who gets to run /setup. Once a
user exists, only a session gets in."""
session = current_session(request, db)
if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
@@ -283,10 +286,9 @@ def require_browser(request: Request, db: Session = Depends(get_db)) -> User | N
user = db.get(User, session.user_id)
if user is not None:
return user
if browser_token_valid(request):
return None
if not users_exist(db) and not management_token():
return None
if not users_exist(db):
if not management_token() or browser_token_valid(request):
return None
raise HTTPException(401, "Not logged in")
@@ -326,63 +328,29 @@ def _register_frame(db: Session, device_id: str) -> Frame:
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request.
New firmware sends ?id=<12-hex-mac>&token=<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.
"""
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
legacy = management_token()
legacy_ok = not legacy or token == legacy
if device_id:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
legacy_frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
legacy_frame.device_id = device_id
frame = legacy_frame
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
else:
frame = _register_frame(db, device_id)
else:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok:
if frame.legacy_token_enabled and legacy_ok:
pass
elif not frame.device_token_ack:
# Handshake window: the device registered but hasn't
# received its token yet (the wake cycle fetches the
# image BEFORE polling /frame/config, where the token
# is delivered) -- the id stays the credential, same
# trust level as the open registration that created
# the row. Closes permanently on the first
# authenticated request.
pass
else:
raise HTTPException(401, "Missing or invalid access token")
if not device_id:
raise HTTPException(401, "Missing device id")
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None:
frame = _register_frame(db, device_id)
else:
if not legacy_ok:
token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack:
frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id)
elif not token_ok and frame.device_token_ack:
raise HTTPException(401, "Missing or invalid access token")
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
# Nothing to resolve a no-id request to. migration.py always
# creates frame #1 at startup, so this only happens if it was
# deleted -- treat like an unknown device.
raise HTTPException(401, "No frame accepts legacy credentials")
# else: handshake window -- the device registered but hasn't
# received its token yet (the wake cycle fetches the image
# BEFORE polling /frame/config, where the token is delivered) --
# the id stays the credential, same trust level as the open
# registration that created the row. Closes permanently on the
# first authenticated request.
frame.last_seen = time.time()
db.commit()
+71 -51
View File
@@ -71,24 +71,32 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
header bar -- dithered at the theme's accent_amplitude via
ordered_dither_regions."""
weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda.
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
are unused, same carve-out as weather's build_current/build_daily --
see docs/widgets.md). The day header is plain ink text under a slim
accent-colored rule instead of white text on a full gradient band --
only that thin rule dithers at the theme's richer accent_amplitude
now, not the header text sitting on top of it, which is a legibility
improvement over the old design, not just a visual one."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = max(14, min(target_w, target_h) // 12)
body_size = max(11, min(target_w, target_h) // 20)
title_size = panel_style.scaled_size(max(14, min(target_w, target_h) // 12), font_scale)
body_size = panel_style.scaled_size(max(11, min(target_w, target_h) // 20), font_scale)
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
# Single day -- no cross-section alignment concern, so header_h can
# simply reflect whether THIS day actually has weather (unlike
# build_today_tomorrow/build_week's vertical layout, which must
# reserve the same header_h for every stacked section regardless).
has_weather = bool(_weather_row(weather_cities, day, weather_units))
header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0)
header_h = accent_h + 10 + title_size + ((weather_size + 10) if has_weather else 0)
owners_seen: list[str] = []
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
@@ -96,16 +104,16 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header=data["header"], title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"],
header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], weather_entries=data["weather_entries"],
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
more_count=data["more_count"], row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
)
@@ -113,25 +121,28 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image:
weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data), each with its own
header bar dithered richer via ordered_dither_regions."""
-- two day-sections stacked (see _day_section_data). Bold-minimal, no
card (see build_agenda's docstring) -- each section's own slim accent
rule dithers richer via ordered_dither_regions, not its header text."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
title_size = max(13, section_h // 8)
body_size = max(10, min(target_w, target_h) // 26)
title_size = panel_style.scaled_size(max(13, section_h // 8), font_scale)
body_size = panel_style.scaled_size(max(10, min(target_w, target_h) // 26), font_scale)
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
day_dates = [start_day + timedelta(days=i) for i in range(2)]
# Uniform across both stacked sections regardless of which day(s)
# actually have weather -- see _day_section_data's own docstring for
# why a per-day header height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0)
header_h = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
owners_seen: list[str] = []
days = [
@@ -142,16 +153,16 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=days, title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"])
for i in range(len(days))
]
@@ -161,7 +172,7 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both
the vertical (stacked day-sections, reusing build_today_tomorrow's
template with an arbitrary day count) and horizontal (side-by-side
@@ -180,16 +191,17 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical":
section_h = target_h // days
title_size = max(11, min(20, section_h // 6))
body_size = max(9, min(target_w, target_h) // (18 + days))
title_size = panel_style.scaled_size(max(11, min(20, section_h // 6)), font_scale)
body_size = panel_style.scaled_size(max(9, min(target_w, target_h) // (18 + days)), font_scale)
weather_size = max(8, body_size - 2)
row_h = body_size + 10
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
# Uniform across all `days` stacked sections -- see
# _day_section_data's own docstring for why a per-day header
# height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0)
header_h = accent_h + 6 + title_size + ((weather_size + 6) if any_weather else 0)
day_sections = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h)
@@ -197,32 +209,33 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=day_sections, title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"])
for i in range(len(day_sections))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6))
header_size = panel_style.scaled_size(max(10, min(16, (target_w // days) // 6)), font_scale)
chip_size = max(9, header_size - 3)
weather_size = max(8, chip_size - 1)
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
row_h = chip_size + 8
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
# Reserve weather-line room in every column's header uniformly
# (whether or not THIS specific day has a cached forecast) -- a
# per-column height that depends on that day's own data would
# misalign where each column's event rows start across the week
# grid the moment any single day lacks a forecast entry.
header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h)
header_h = header_size + 8 + (weather_size + 4 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - header_h) // row_h)
cols = []
for i in range(days):
@@ -242,27 +255,34 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to
unreadable on a 6-color dithered e-ink panel). The per-owner event
dots are identity-coding (like every other calendar view's chips) and
are never touched by a theme; only the weekday-name row (a flat
accent background, no gradient in this view) dithers richer via
ordered_dither_regions."""
are never touched by a theme.
Bold-minimal: no card (see build_agenda's docstring); the old flat
accent-colored weekday-name band is now a slim accent rule above
plain bold weekday labels, matching every other calendar view's
header treatment -- only that rule dithers at the theme's richer
accent_amplitude via ordered_dither_regions. "Today" is still called
out with a small accent-filled pill around its day number (a
genuinely small accent surface, not a band, so it was left alone)."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
@@ -272,9 +292,10 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
)
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
header_size = max(11, min(16, target_h // 30))
day_size = max(10, min(15, target_w // 55))
header_size = panel_style.scaled_size(max(11, min(16, target_h // 30)), font_scale)
day_size = panel_style.scaled_size(max(10, min(15, target_w // 55)), font_scale)
dot_size = max(4, day_size // 2)
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
owners_seen: list[str] = []
weeks = []
@@ -291,14 +312,13 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks,
day_names=day_names, weeks=weeks, accent_h=accent_h,
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
weekday_row_h = header_size + 12
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb,
accent_regions=[(accent_rect, theme["accent_amplitude"])])
@@ -306,7 +326,7 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, theme_name: str | None = None) -> Image.Image:
week_start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""Dispatches to the right build_* -- mirrors calendar_render._build's
exact "month falls back to agenda when it doesn't fit" resolution, so
a narrow month-mode widget set to modern style still gets a sensible
@@ -317,11 +337,11 @@ def build(events: list[dict], view: str, browse_offset: int, target_w: int, targ
if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
weather_units, theme_name, font_scale)
if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name)
weather_units, theme_name, font_scale)
if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset, theme_name)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)
weather_units, week_days, week_layout, week_start_offset, theme_name, font_scale)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)
+33 -23
View File
@@ -512,10 +512,12 @@ _AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
@@ -533,7 +535,7 @@ _TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small":
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image:
weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically (below each other
rather than side-by-side -- narrower than tall doesn't leave enough
width per day for the event-row text at smaller sizes). browse_offset
@@ -542,7 +544,9 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
both views."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size)
@@ -572,7 +576,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image:
start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
@@ -597,9 +601,9 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical":
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = panel_style.font_bold(max(14, title_base - days))
body_font = panel_style.font_regular(max(11, body_base - days))
weather_font = panel_style.font_regular(max(9, weather_base - days))
title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
section_h = ch // days
for i in range(days):
section_y0 = cy0 + i * section_h
@@ -611,7 +615,9 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_cities, weather_font, weather_units)
return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_size, chip_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
)
header_font = panel_style.font_bold(header_size)
chip_font = panel_style.font_regular(chip_size)
weather_font = panel_style.font_regular(weather_size)
@@ -667,7 +673,7 @@ _MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None) -> Image.Image:
week_start: int, palette_rgb: list | None = None, font_scale: float = 1.0) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
@@ -677,7 +683,9 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
comment above MARGIN/BG/FG."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
header_size, day_size = (
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
)
header_font = panel_style.font_bold(header_size)
day_font_in_month = panel_style.font_bold(day_size)
day_font_out_of_month = panel_style.font_regular(day_size)
@@ -750,7 +758,7 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image:
week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
@@ -758,13 +766,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
if effective_view == "agenda":
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
weather_cities, weather_units, font_scale)
elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
weather_cities, weather_units, font_scale)
elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, week_start_offset)
weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
elif effective_view == "month":
# Never given weather -- no room for it at typical month-cell
# size, same reasoning that already keeps this view to density
@@ -772,10 +780,10 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
# docstring). Colors are still passed through, though -- that's
# a different concern (legibility of individual events) than
# needing a whole extra strip of content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units)
weather_cities, weather_units, font_scale)
if fetch_summary:
# Drawn as a final overlay onto the already-composited img (not
@@ -814,13 +822,13 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> bytes:
week_start_offset: int = 0, font_scale: float = 1.0) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
@@ -835,12 +843,14 @@ _TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks") -> Image.Image:
title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old
week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box."""
img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_size, body_size = (
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size)
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
@@ -860,12 +870,12 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks") -> bytes:
manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
+137 -42
View File
@@ -87,6 +87,20 @@ CATEGORY_EMOJI = {
"thunderstorm": "⛈️",
}
# Spelled-out condition word for the "bold minimal" current-mode layout --
# classic's build_current never needed one (icon + temp only), but the
# redesigned modern layout has room for a secondary line under the temp.
CATEGORY_LABEL = {
"clear": "Clear",
"partly_cloudy": "Partly cloudy",
"cloudy": "Cloudy",
"fog": "Fog",
"rain": "Rain",
"snow": "Snow",
"thunderstorm": "Thunderstorm",
}
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb)
@@ -98,6 +112,15 @@ def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
def _clamp(value: float, lo: float, hi: float) -> float:
"""Keeps a size/spacing value proportional to widget dimensions
(`value` is always some fraction of target_w/target_h) while still
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
ceiling (stops padding/type from just growing forever on a
near-full-panel widget -- see build_current's docstring)."""
return max(lo, min(hi, value))
# --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None
@@ -268,29 +291,65 @@ def _day_label(day_date: date) -> str:
return day_date.strftime("%a")
def _short_city(city_label: str) -> str:
"""geocode_city (see docs/widgets.md's Weather widget section) hands
back a full "City, Region, Country" string -- fine for classic's
build_current (just drawn as one line, however wide) but wrong for
the bold-minimal layout's small top-row label, where a 1-2 grid-
cell widget has no room for the whole thing. Every phone-homescreen
weather widget this style is drawing from shows just the city, so
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
the template as a safety net for a custom single-segment label
that's itself too long, not as the primary truncation strategy."""
return city_label.split(",")[0].strip()
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image
(see ordered_dither). No header/accent region here (just a centered
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
(no ordered_dither_regions call)."""
(see ordered_dither).
"Bold minimal" layout: the temperature itself is the graphic --
city label + icon in a top row, the temp (dominant) and spelled-out
condition anchored to the bottom, no card/border/shadow at all. This
is a deliberate departure from every other modern-style widget's
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
for a "card" to visually separate from here, so `theme["radius"]`/
`theme["shadow"]` have no effect on this template; still theme-aware
for font_family only, same as before. No header/accent region either
(see ordered_dither_regions' docstring) -- a themed accent has
nothing to attach to in a chrome-free layout.
Every size below is a fraction of `base` (the widget's shorter side),
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
widget doesn't get comically oversized padding relative to its
content, and a near-full-panel widget doesn't get comically large
padding relative to *its* content either. Floors/ceilings are tuned
by eye against real widget sizes, not derived from anything."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry:
return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
icon_size = max(28, min(target_w, target_h) // 3)
base = min(target_w, target_h)
pad = _clamp(base * 0.09, 10, 26)
icon_size = _clamp(base * 0.20, 22, 60)
temp_size = _clamp(base * 0.46, 30, 150)
deg_size = _clamp(temp_size * 0.28, 12, 40)
cond_size = _clamp(base * 0.075, 11, 20)
city_size = _clamp(base * 0.06, 10, 15)
template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
w=target_w, h=target_h, pad=round(pad),
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
label_size=max(12, icon_size // 3),
condition=CATEGORY_LABEL.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
cond_size=round(cond_size), city_size=round(city_size),
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
@@ -300,9 +359,20 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see
ordered_dither). Has a header bar -- dithered at the theme's
accent_amplitude via ordered_dither_regions, richer than the rest of
the widget (see that function's docstring for why)."""
ordered_dither).
"Bold minimal" layout, matching build_current: no card/border/
shadow, a row of day columns each carrying its own high (dominant)
/ low (muted) temp the same way build_current makes the current
temp dominant. The old full-width gradient banner is gone --
city_label, when set, is a slim accent-colored rule (not a block)
with the city name understated beneath it, so there's still
somewhere for a theme's accent hue to show up (dithered richer via
ordered_dither_regions, same mechanism as before) without dragging
back the "card with a colored header" chrome this redesign is
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
here for the same reason as build_current -- no card for them to
apply to."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items())
if not days:
@@ -310,9 +380,16 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
col_w = max(1, target_w // len(days))
icon_size = max(16, min(col_w // 2, 36))
base = min(target_w, target_h)
pad = round(_clamp(base * 0.08, 10, 22))
col_w = max(1, (target_w - pad * 2) // len(days))
col_gap = round(_clamp(col_w * 0.12, 4, 16))
icon_size = round(_clamp(col_w * 0.30, 16, 32))
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
high_size = round(_clamp(col_w * 0.32, 16, 32))
low_size = round(max(9, high_size * 0.55))
city_size = round(_clamp(base * 0.055, 10, 14))
accent_h = round(_clamp(base * 0.025, 4, 8))
day_entries = [
{
"label": _day_label(date.fromisoformat(day_str)),
@@ -324,18 +401,17 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
]
template = _jinja_env.get_template("weather_daily.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
city_label=city_label, header_h=header_h,
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
unit_suffix=unit_suffix,
city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
)
rendered = render_html_to_image(html, target_w, target_h)
if header_h <= 0:
if not city_label:
return ordered_dither(rendered, palette_rgb)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
@@ -375,12 +451,12 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
base = min(target_w, target_h)
icon_h = max(16, int(base // 3 * scale))
pct_size = max(14, int(base // 3 * scale))
line_size = max(9, int(base // 9 * scale))
gap = 8
icon_h = max(14, int(base * 0.15 * scale))
pct_size = max(20, int(base * 0.42 * scale))
line_size = max(9, int(base * 0.085 * scale))
gap = max(4, int(base * 0.035 * scale))
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
@@ -390,18 +466,26 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
resolved by the caller (widgets/battery.py's _lines_for(), shared
with the classic path so the estimate/age formatting only lives in
one place). Returns an already-palette-exact RGB image (see
ordered_dither). Theme-aware for font/radius only -- the charge-level
ordered_dither). Theme-aware for font only -- the charge-level
fill_color below is a functional status signal (not a style choice)
and is never touched by a theme, and there's no header/accent region
to dither richer via ordered_dither_regions.
Bold-minimal: no card (theme["radius"] unused, same carve-out as
weather's build_current -- see docs/widgets.md); the percent is the
hero value anchored toward the bottom, same treatment build_current
gives the temperature, with the icon small and secondary above it
instead of both competing at the same size like the old centered
layout did.
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
the whole stack actually fits the available height -- the classic
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
problem by truncating lines that don't fit; scaling down instead
keeps every resolved line visible, which reads better for a widget
that only ever has at most 2 short caption lines to begin with."""
avail_h = target_h - panel_style.GUTTER * 2
pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
avail_h = target_h - pad * 2
num_lines = len(lines)
scale = 1.0
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
@@ -426,13 +510,13 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
nub_w = max(3, icon_w // 10)
template = _jinja_env.get_template("battery.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
w=target_w, h=target_h, pad=pad,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
pct_size=sizes["pct_size"], line_size=sizes["line_size"],
pct_size=sizes["pct_size"], line_size=sizes["line_size"], line_gap=sizes["gap"],
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
@@ -495,24 +579,35 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
# --- Tasks "modern" style ---------------------------------------------------
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks", theme_name: str | None = None) -> Image.Image:
title: str = "Tasks", theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
same header+checklist shape. Reuses calendar_render's own
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format
logic the classic renderer uses) so a task's color chip/due string
matches classic style exactly; only the drawing differs -- and a
theme's accent never touches those per-owner chip colors (identity-
coding, not style). Has a header bar -- dithered at the theme's
accent_amplitude via ordered_dither_regions. Returns an already-
palette-exact RGB image (see ordered_dither)."""
coding, not style) or the done-checkbox fill (a completion state
signal, not a style choice -- it happens to reuse the accent color,
but that's incidental, same as before this redesign).
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
carve-out as calendar's redesigned views -- see docs/widgets.md).
The old gradient header banner is now a slim accent rule + plain
bold title, matching every calendar view's day-header language --
only the rule dithers at the theme's richer accent_amplitude, not
the title text sitting on it. Returns an already-palette-exact RGB
image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
header_h = max(28, min(target_w, target_h) // 8)
body_size = max(11, min(target_w, target_h) // 20)
base = min(target_w, target_h)
accent_h = round(_clamp(base * 0.025, 3, 6))
title_size = panel_style.scaled_size(max(14, base // 12), font_scale)
body_size = panel_style.scaled_size(max(11, base // 20), font_scale)
row_h = body_size + 14
box_size = max(10, body_size - 4)
avail_h = target_h - header_h - 16
header_h = accent_h + 6 + title_size
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
max_rows = max(0, avail_h // row_h)
owners_seen: list[str] = []
@@ -531,15 +626,15 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
template = _jinja_env.get_template("tasks.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
title=title, header_h=header_h, title_size=max(14, header_h - 12),
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
accent_start=theme["accent_hex"],
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
)
rendered = render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
+4 -14
View File
@@ -110,26 +110,16 @@ def service_worker() -> FileResponse:
return FileResponse("app/static/sw.js", media_type="application/javascript")
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
def _device_credential_redirect(request: Request, db) -> str | None:
"""The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware:
?token=<legacy shared token>). Those scans get the frame's limited
manage page -- never the full UI, which requires a login.
allow_legacy is False before /setup has run: at that point a bare
?token= hit is the admin coming through the token prompt to do
first-run setup, not a QR scan."""
own credentials (?id=&token=). Those scans get the frame's limited
manage page -- never the full UI, which requires a login."""
device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "")
if device_id and token:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and token == frame.device_token:
return f"/m/{frame.manage_token}"
if allow_legacy and token and management_token() and token == management_token():
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is not None:
return f"/m/{frame.manage_token}"
return None
@@ -140,7 +130,7 @@ def index(request: Request):
else is walked through setup/login."""
with SessionLocal() as db:
have_users = users_exist(db)
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
manage_redirect = _device_credential_redirect(request, db)
if manage_redirect is not None:
return RedirectResponse(manage_redirect, status_code=303)
+361 -211
View File
@@ -22,13 +22,9 @@ from .db import SessionLocal, engine
from .models import (
Base,
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
TaskWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from .widgets import default_button_actions
@@ -889,6 +885,278 @@ def _migration_39(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
def _migration_40(conn) -> None:
"""Per-widget text-size multiplier (models.Widget.font_scale, see
panel_style.FONT_SCALE_CHOICES) -- a Widget-level column like
border_style/border_thickness/border_color_index, not a per-type
config field, since any widget type with body text can use it. Every
existing widget defaults to 1.0 (unchanged size) until its own
dialog's "Text size" picker sets it. Guarded per-column, same
reasoning as every prior migration's own comment."""
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
if "font_scale" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None:
"""Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets --
called from _migration_41 while the legacy Frame columns it reads
still physically exist, for the rare frame (if any) that somehow
reached this migration without ever getting a Widget during the long
window _ensure_widgets_backfilled ran unconditionally at every
startup between migration 16 and this one. Same mode dispatch,
including the calendar_photo_inlay two-widget split and the legacy
tasks-source carryover. Has to be hand-rolled in raw SQL rather than
reusing the old ORM helpers, since those read these columns off
models.Frame, which no longer declares them as of this migration."""
frame_id = frame_row["id"]
orientation = frame_row["orientation"] or "landscape"
cols, rows = grid.grid_dims(orientation)
mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos"
def insert_widget(x, y, w, h, widget_type, sort_order):
# border_style/border_thickness/border_color_index/font_scale
# spelled out explicitly (migrations 26/40's own defaults)
# rather than relied on implicitly -- they're real SQL-level
# DEFAULTs in any database that reached this migration through
# the normal upgrade path, but this stays correct even if that
# ever stops being true.
result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
"border_style, border_thickness, border_color_index, font_scale) "
"VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, "
"'none', 3, 0, 1.0)"
), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h,
"sort_order": sort_order, "created_at": now})
return result.lastrowid
def insert_photo_config(widget_id):
conn.execute(text(
"INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, "
"queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, "
"excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, "
":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, "
":history, :excluded_asset_ids, 0)"
), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"],
"display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"],
"current_asset_id": frame_row["current_asset_id"],
"current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"],
"queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"],
"excluded_asset_ids": frame_row["excluded_asset_ids"]})
def insert_calendar_config(widget_id):
conn.execute(text(
"INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, "
"cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, "
"weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) "
"VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, "
":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, "
":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')"
), {"widget_id": widget_id, "view": frame_row["calendar_view"],
"week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"],
"checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"],
"fetch_summary": frame_row["calendar_fetch_summary"],
"weather_enabled": frame_row["calendar_weather_enabled"],
"weather_units": frame_row["calendar_weather_units"],
"weather_cities": frame_row["calendar_weather_cities"],
"weather_checked_at": frame_row["calendar_weather_checked_at"],
"weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"],
"week_layout": frame_row["calendar_week_layout"],
"week_start_offset": frame_row["calendar_week_start_offset"]})
def insert_whiteboard_config(widget_id):
conn.execute(text(
"INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, "
"render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')"
), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"],
"checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]})
def insert_button_actions(widget_id, widget_type):
if widget_type == "whiteboard":
pairs = [("next", "check_now"), ("back", "check_now")]
elif widget_type in ("photos", "calendar"):
pairs = [("next", "advance"), ("back", "back")]
else:
pairs = []
for button, action in pairs:
conn.execute(text(
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
"VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)"
), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action,
"created_at": now})
def maybe_add_tasks_widget(existing_rects, next_sort_order):
if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame_id
)
return
x, y, w, h = rect
widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order)
conn.execute(text(
"INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, "
"render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')"
), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"],
"cached": frame_row["calendar_tasks_cached"]})
conn.execute(text(
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
"VALUES (:widget_id, :user_id, :calendar_key, 1)"
), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"],
"calendar_key": frame_row["calendar_tasks_calendar_key"]})
if mode == "calendar" and frame_row["calendar_photo_inlay"]:
half = cols // 2
cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0)
photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1)
insert_calendar_config(cal_widget_id)
insert_photo_config(photo_widget_id)
insert_button_actions(cal_widget_id, "calendar")
maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2)
return
widget_id = insert_widget(0, 0, cols, rows, mode, 0)
if mode == "photos":
insert_photo_config(widget_id)
elif mode == "calendar":
insert_calendar_config(widget_id)
elif mode == "whiteboard":
insert_whiteboard_config(widget_id)
insert_button_actions(widget_id, mode)
if mode == "calendar":
maybe_add_tasks_widget([(0, 0, cols, rows)], 1)
def _migration_41(conn) -> None:
"""Drops the legacy per-mode Frame columns the widget system
(migration 16) superseded -- mode, the photo-queue fields (album_id/
photo_order/display_mode/queue_target_len/current_asset_id/
current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids),
every calendar_* field, every whiteboard_* field, and
legacy_token_enabled (models.py's own removal, alongside auth.py
dropping the shared MANAGEMENT_TOKEN device/browser fallback it
gated -- see auth.py's module docstring) -- see docs/widgets.md's
Known Gaps, which deliberately left this open as a much larger blast
radius than this project's usual same-migration-drop convention.
_ensure_widgets_backfilled ran unconditionally at the end of every
startup from migration 16 until this one, so in practice every frame
already has a Widget built from these columns' values by now; the
backfill loop below (_raw_backfill_frame_widgets) is the same safety
net migration 17/18 used for their own column drops, covering the
edge case of a frame that somehow reached this point with none (e.g.
a very old, never-restarted backup).
Guarded on "mode" existing, same reasoning as migration 26/27/29/
30/40's own comments: frames IS dropped/recreated here (unlike
widgets/photo_widget_configs, which those migrations left alone),
but a fresh-install create_all() copy already reflects today's
models.py -- i.e. the post-this-migration shape, missing "mode"
entirely -- so a test replaying migrations 16+ from an old
schema_version without also reconstructing frames' pre-41 columns
would otherwise hit "no such column: mode" here even though it has
nothing to do with what that test is actually exercising."""
if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}:
return
now = time.time()
frame_rows = conn.execute(text(
"SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, "
"current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, "
"calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, "
"calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, "
"calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, "
"calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, "
"calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, "
"calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, "
"whiteboard_cached_image FROM frames"
)).mappings().all()
for row in frame_rows:
has_widget = conn.execute(
text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]}
).first()
if has_widget is None:
_raw_backfill_frame_widgets(conn, row, now)
conn.execute(text(
"CREATE TABLE frames_new ("
"id INTEGER PRIMARY KEY, "
"device_id TEXT UNIQUE, "
"name TEXT NOT NULL DEFAULT '', "
"owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"device_token TEXT NOT NULL, "
"device_token_ack INTEGER NOT NULL DEFAULT 0, "
"manage_token TEXT NOT NULL UNIQUE, "
"claimed_at REAL, "
"created_at REAL NOT NULL DEFAULT 0.0, "
"immich_url TEXT NOT NULL DEFAULT '', "
"immich_api_key TEXT NOT NULL DEFAULT '', "
"refresh_interval_s INTEGER NOT NULL DEFAULT 3600, "
"quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, "
"quiet_hours_start TEXT NOT NULL DEFAULT '22:00', "
"quiet_hours_end TEXT NOT NULL DEFAULT '07:00', "
"timezone TEXT NOT NULL DEFAULT 'UTC', "
"orientation TEXT NOT NULL DEFAULT 'landscape', "
"palette_rgb TEXT, "
"color_boost REAL NOT NULL DEFAULT 1.0, "
"contrast_boost REAL NOT NULL DEFAULT 1.0, "
"dither_strength REAL NOT NULL DEFAULT 1.0, "
"photo_palette_rgb TEXT, "
"photo_dither_strength REAL NOT NULL DEFAULT 1.0, "
"theme TEXT NOT NULL DEFAULT 'classic', "
"battery_percent INTEGER NOT NULL DEFAULT -1, "
"battery_as_of REAL NOT NULL DEFAULT 0.0, "
"battery_history TEXT NOT NULL DEFAULT '[]', "
"last_seen REAL NOT NULL DEFAULT 0.0, "
"device_firmware_version TEXT NOT NULL DEFAULT '', "
"device_board_variant TEXT NOT NULL DEFAULT '', "
"battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, "
"battery_alert_sent INTEGER NOT NULL DEFAULT 0, "
"firmware_available_version TEXT NOT NULL DEFAULT '', "
"firmware_update_repo_url TEXT NOT NULL DEFAULT '', "
"firmware_auto_update INTEGER NOT NULL DEFAULT 0, "
"firmware_update_token TEXT NOT NULL DEFAULT '', "
"firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, "
"firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', "
"hold_duration_ms INTEGER NOT NULL DEFAULT 3000, "
"next_hold_action TEXT, "
"back_hold_action TEXT, "
"last_cycled_layout_id INTEGER, "
"last_displayed_image BLOB, "
"last_displayed_at REAL NOT NULL DEFAULT 0.0, "
"stats_first_seen REAL NOT NULL DEFAULT 0.0, "
"stats_device_wakes INTEGER NOT NULL DEFAULT 0, "
"stats_photos_displayed INTEGER NOT NULL DEFAULT 0, "
"stats_photos_removed INTEGER NOT NULL DEFAULT 0, "
"stats_battery_reports INTEGER NOT NULL DEFAULT 0, "
"stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, "
"stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, "
"stats_config_saves INTEGER NOT NULL DEFAULT 0)"
))
kept_columns = (
"id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, "
"manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, "
"quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, "
"color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, "
"battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, "
"device_board_variant, battery_alert_threshold_pct, battery_alert_sent, "
"firmware_available_version, firmware_update_repo_url, firmware_auto_update, "
"firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, "
"hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, "
"last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, "
"stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, "
"stats_ota_updates_applied, stats_config_saves"
)
conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames"))
conn.execute(text("DROP TABLE frames"))
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -929,6 +1197,8 @@ MIGRATIONS = [
(37, _migration_37),
(38, _migration_38),
(39, _migration_39),
(40, _migration_40),
(41, _migration_41),
]
@@ -945,18 +1215,53 @@ def run_migrations() -> None:
# already added (e.g. "duplicate column name"). Jump
# straight to the latest version instead.
_migration_1(conn)
latest = MIGRATIONS[-1][0]
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
current = MIGRATIONS[-1][0]
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
else:
current = row[0]
for version, fn in MIGRATIONS:
if version > current:
logger.info("Applying schema migration %d", version)
fn(conn)
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
# Each migration commits in its own transaction (rather than the
# whole batch sharing one, like this used to) so that _migration_41
# can get a connection with no transaction pending on it yet --
# SQLite only honors toggling PRAGMA foreign_keys when issued as a
# connection's literal first statement, and it needs that off for
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
# for widgets/frame_button_actions/user_frames/etc, so leaving
# enforcement on there would cascade-delete every frame's widgets,
# not just the columns that migration means to drop). A crash
# partway through now simply leaves schema_version at the last
# migration that actually completed, same as it always could
# between separate runs of this function.
for version, fn in MIGRATIONS:
if version <= current:
continue
logger.info("Applying schema migration %d", version)
with engine.connect() as conn:
if fn is _migration_41:
# Executing this before anything else auto-begins
# SQLAlchemy's own Transaction bookkeeping too, so an
# explicit conn.begin() below would conflict with it --
# fn(conn) and the version UPDATE just ride that same
# auto-begun transaction, committed explicitly at the end.
conn.execute(text("PRAGMA foreign_keys=OFF"))
fn(conn)
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
conn.commit()
if fn is _migration_41:
# Restore it before this connection goes back to the
# pool -- otherwise a later checkout of the same
# underlying DBAPI connection (the connect-event
# listener in db.py only fires for a genuinely new one)
# would silently run with enforcement off. Has to
# happen AFTER commit(), same "no pending transaction"
# requirement as the OFF toggle above -- issuing it
# before the commit is exactly the mid-transaction
# no-op this migration exists to work around in the
# first place, just in the other direction.
conn.execute(text("PRAGMA foreign_keys=ON"))
_ensure_frame_one()
_ensure_server_settings()
_ensure_widgets_backfilled()
_ensure_frame_calendars_rekeyed()
@@ -971,11 +1276,11 @@ def new_manage_token() -> str:
def _ensure_frame_one() -> None:
"""First boot only (frames table empty): create frame #1 -- imported
verbatim from a legacy config.json if one exists, otherwise fresh
defaults. Either way it's the legacy-token frame: the deployed
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
and require_device resolves those requests here. The frames-nonempty
guard makes this idempotent; config.json is left untouched as the
rollback path."""
defaults -- plus a single full-panel photos widget carrying over
whatever photo-queue state that file had (the widget system's
equivalent of what used to live directly on Frame; see migration
41). The frames-nonempty guard makes this idempotent; config.json is
left untouched as the rollback path."""
with SessionLocal() as db:
if db.scalars(select(Frame).limit(1)).first() is not None:
return
@@ -988,26 +1293,15 @@ def _ensure_frame_one() -> None:
device_id=None,
device_token=new_device_token(),
manage_token=new_manage_token(),
legacy_token_enabled=True,
created_at=time.time(),
immich_url=cfg.immich_url,
immich_api_key=cfg.immich_api_key,
album_id=cfg.album_id,
order=cfg.order,
refresh_interval_s=cfg.refresh_interval_s,
quiet_hours_enabled=cfg.quiet_hours_enabled,
quiet_hours_start=cfg.quiet_hours_start,
quiet_hours_end=cfg.quiet_hours_end,
timezone=cfg.timezone,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
orientation=cfg.orientation,
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
battery_percent=cfg.battery_percent,
battery_as_of=cfg.battery_as_of,
battery_history=[list(pair) for pair in cfg.battery_history],
@@ -1030,11 +1324,31 @@ def _ensure_frame_one() -> None:
stats_config_saves=cfg.stats.config_saves,
)
db.add(frame)
db.flush() # assign frame.id for the battery log rows
db.flush() # assign frame.id for the battery log rows + widget FK
for pair in cfg.battery_log:
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
cols, rows = grid.grid_dims(frame.orientation)
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush() # assign widget.id for the config row's FK
db.add(PhotoWidgetConfig(
widget_id=widget.id,
album_id=cfg.album_id,
order=cfg.order,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
))
db.add_all(default_button_actions(frame.id, widget.id, "photos"))
db.commit()
# The single legacy firmware slot becomes frame #1's per-frame slot.
@@ -1064,168 +1378,6 @@ def _ensure_server_settings() -> None:
db.commit()
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
return PhotoWidgetConfig(
widget_id=widget_id,
album_id=frame.album_id,
order=frame.order,
display_mode=frame.display_mode,
queue_target_len=frame.queue_target_len,
current_asset_id=frame.current_asset_id,
current_asset_set_at=frame.current_asset_set_at,
queue=list(frame.queue),
queue_cursor=frame.queue_cursor,
history=list(frame.history),
excluded_asset_ids=list(frame.excluded_asset_ids),
)
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
return CalendarWidgetConfig(
widget_id=widget_id,
view=frame.calendar_view,
week_start=frame.calendar_week_start,
browse_offset=frame.calendar_browse_offset,
checked_at=frame.calendar_checked_at,
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
fetch_summary=frame.calendar_fetch_summary,
weather_enabled=frame.calendar_weather_enabled,
weather_units=frame.calendar_weather_units,
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
weather_checked_at=frame.calendar_weather_checked_at,
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
week_days=frame.calendar_week_days,
week_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset,
# tasks_* deliberately not carried over -- see
# _task_config_and_list_from_frame, a sibling standalone widget
# now, not part of this config.
)
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
"""Only ever called for a frame whose legacy calendar_tasks_* columns
(see Frame's own docstring on those -- a dead pre-widget-system
field set, same status as calendar_photo_inlay below) still carry a
configured source -- i.e. a database jumping straight from before
the widget system existed to after tasks became their own
multi-list widget type in a single upgrade, skipping both
intermediate periods where it would have lived on
CalendarWidgetConfig (_migration_17's extraction) and then a
single-source TaskWidgetConfig (_migration_18's extraction) instead.
Reproduces the same shape those two migrations arrive at directly:
a bare cache-state config plus one included FrameTaskList row."""
cfg = TaskWidgetConfig(
widget_id=widget_id,
checked_at=frame.calendar_tasks_checked_at,
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
)
task_list = FrameTaskList(
widget_id=widget_id,
user_id=frame.calendar_tasks_user_id,
calendar_key=frame.calendar_tasks_calendar_key,
included=True,
)
return cfg, task_list
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
return WhiteboardWidgetConfig(
widget_id=widget_id,
user_id=frame.whiteboard_user_id,
url=frame.whiteboard_url,
checked_at=frame.whiteboard_checked_at,
cached_image=frame.whiteboard_cached_image,
)
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
in one upgrade (see _task_config_and_list_from_frame) --
frame.calendar_tasks_* is the dead legacy field set otherwise.
Requires both calendar_key and user_id (FrameTaskList.user_id is
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
Auto-placed in whatever open space is left after the widget(s) above
it in _backfill_frame_widgets claimed theirs, same find_open_rect
logic a manual "add widget" uses; silently dropped (logged) if none
fits, same as this migration having nowhere else to put it either."""
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame.id
)
return
x, y, w, h = rect
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
sort_order=next_sort_order, created_at=time.time())
db.add(task_widget)
db.flush()
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
db.add(cfg)
db.add(task_list)
def _backfill_frame_widgets(db, frame: Frame) -> None:
cols, rows = grid.grid_dims(frame.orientation)
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
if mode == "calendar" and frame.calendar_photo_inlay:
# Reproduces the old fixed 50/50 inlay split as two independent
# widgets instead of silently dropping half of what the frame was
# showing -- see models.py's CalendarWidgetConfig docstring on why
# "photo inlay" isn't a widget-system concept anymore otherwise.
half = cols // 2
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
db.add_all([cal_widget, photo_widget])
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
return
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush()
if mode == "photos":
db.add(_photo_config_from_frame(frame, widget.id))
elif mode == "calendar":
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
def _ensure_widgets_backfilled() -> None:
"""Every frame needs at least one Widget once the widget system is
live -- runs unconditionally after every startup (both a from-scratch
_ensure_frame_one() install and an existing-install upgrade past
_migration_16 land here) and is a no-op for any frame that already
has one. Builds a widget that reproduces the frame's current mode/
settings/state exactly, so upgrading never changes what a frame
displays or what its physical buttons do on its own."""
with SessionLocal() as db:
for frame in db.scalars(select(Frame)).all():
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
if has_widget is not None:
continue
_backfill_frame_widgets(db, frame)
db.commit()
def _ensure_frame_calendars_rekeyed() -> None:
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
hold more than one independent calendar widget (see the widget
@@ -1238,27 +1390,25 @@ def _ensure_frame_calendars_rekeyed() -> None:
and savable even while a frame's old `mode` was "photos"), not real
live configuration.
Deliberately NOT a numbered migration: this needs each frame's
calendar widget to already exist to know what to re-key against, and
those widget rows aren't created by a schema migration at all --
they come from _ensure_widgets_backfilled() above, which (like this
function) runs unconditionally after every startup rather than being
tracked by schema_version. Running this as a numbered migration
would execute it *before* that backfill during a real upgrade (the
numbered-migration loop runs first, see run_migrations), silently
dropping every row -- caught by test_migrations.py actually exercising
the raw-SQL upgrade path instead of the fresh-install create_all()
shortcut every other test in that file takes.
Deliberately NOT a numbered migration: every frame's calendar widget
must already exist to know what to re-key against, and for a genuine
pre-widget-system database those rows only exist once _migration_41's
own backfill has run (a step inside that migration, not before it).
A numbered migration for this would race ahead of that backfill (the
numbered-migration loop runs top to bottom in one pass, see
run_migrations), silently dropping every row -- caught by
test_migrations.py actually exercising the raw-SQL upgrade path
instead of the fresh-install create_all() shortcut every other test
in that file takes.
Runs unconditionally after every startup, like _ensure_widgets_
backfilled; a no-op the moment frame_calendars is already
widget_id-shaped (every fresh install, and any existing install
after its first run past this code) -- SQLite can't ALTER a column's
FK target or drop a column that's part of an index/FK constraint, so
when it isn't a no-op this is the standard SQLite "rebuild" pattern:
create the new-shape table, copy matching rows across (joining to
find each row's calendar widget), drop the old table, rename the new
one into place."""
Runs unconditionally after every startup instead; a no-op the moment
frame_calendars is already widget_id-shaped (every fresh install,
and any existing install after its first run past this code) --
SQLite can't ALTER a column's FK target or drop a column that's part
of an index/FK constraint, so when it isn't a no-op this is the
standard SQLite "rebuild" pattern: create the new-shape table, copy
matching rows across (joining to find each row's calendar widget),
drop the old table, rename the new one into place."""
inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
if "widget_id" in columns:
+9 -125
View File
@@ -117,14 +117,10 @@ class Frame(Base):
__tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True)
# 12 lowercase hex chars of the device's full STA MAC. NULL only for
# the migrated legacy frame until its device first reports an id.
# 12 lowercase hex chars of the device's full STA MAC. NULL until its
# device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
# and routers/common.py FRAME_MODES).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
@@ -138,11 +134,6 @@ class Frame(Base):
# pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
manage_token: Mapped[str] = mapped_column(String, unique=True)
# Migration window: this frame also accepts the legacy shared
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
# migrated frame #1; cleared from /admin once the device is on
# per-frame auth.
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
@@ -153,19 +144,13 @@ class Frame(Base):
immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="")
# -- settings (attribute names match the old FrameConfig fields) --
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
# -- settings --
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC")
# How a photo's aspect ratio is reconciled with the panel's -- see
# image_pipeline.DISPLAY_MODES.
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
orientation: Mapped[str] = mapped_column(String, default="landscape")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
@@ -192,113 +177,6 @@ class Frame(Base):
# Widgets rendered in classic (PIL) style ignore this entirely.
theme: Mapped[str] = mapped_column(String, default="classic")
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
# which day week/month views start their grid on.
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
# Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
# How many periods (unit depends on calendar_view: days/weeks/months)
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
# (non-button) /frame/image request, and whenever calendar_view
# itself changes -- a stale offset means something different in a
# different view's units.
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
# Throttled merge-fetch cache (see routers/common.py's
# get_or_refresh_calendar_events) -- same shape as the
# firmware_update_checked_at/firmware_gitea_latest_version pattern
# below. One shared cache for every included user's merged events,
# not per-user.
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
# calendars unavailable" -- never names which user's feed failed, a
# shared household display shouldn't call out a specific person's
# outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# Optional weather strip, agenda/today & tomorrow/week views only --
# never month, there's no room (see calendar_render.py's _BUILDERS).
# Off by default.
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
# weather.geocode_city() when added from the Calendar tab.
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Throttled per-city forecast cache (see routers/common.py's
# get_or_refresh_weather) -- same shape idiom as
# calendar_checked_at/calendar_cached_events above.
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Week view: how many days to show (2-10, default 7 -- the original
# fixed behavior) and whether they're laid out as side-by-side
# columns or stacked bands (see calendar_render.py's _build_week).
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
# Only used when calendar_week_days != 7 -- calendar_week_start's
# fixed-weekday anchor ("start on the most recent Monday") stops
# making sense once the view isn't a literal calendar week, so a
# non-7-day view instead starts this many days from today (0 =
# starts today, negative = starts in the past, positive = starts in
# the future). Ignored (calendar_week_start governs instead) at the
# default 7 days, so this has no effect until someone actually
# changes the day count.
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# Optional task list, week view only -- takes the space of one day
# slot rather than adding an extra one (see calendar_render.py's
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
# something a plain ICS subscription meaningfully has); source is
# one specific linked user's own CalDAV calendar, same
# owner-controls-their-own-data permission split as FrameCalendar.
# calendar_tasks_user_id
# SET NULL on the user's deletion clears the source rather than
# leaving a dangling reference (checked_at isn't reset by that, but
# the next refresh attempt finds no source and just returns []).
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
# already filtered to outstanding (not-completed) tasks and sorted
# by due date -- see caldav_client.fetch_tasks.
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
# whiteboard_user_id may point the frame at their own account, since
# it's their credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
# account-has-several-calendars case, a WebDAV account doesn't need
# a picker step here since the user already knows which one file).
whiteboard_url: Mapped[str] = mapped_column(String, default="")
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
# BLOB rather than the JSON columns the rest of this cache-pattern
# family uses, since this is binary image data, not JSON-shaped.
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
# -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
@@ -499,6 +377,12 @@ class Widget(Base):
border_style: Mapped[str] = mapped_column(String, default="none")
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
# Text-size multiplier for this widget's own body/title text -- another
# Widget-level property regardless of widget_type, same reasoning as
# border_style above (any widget type with text can use it). One of
# panel_style.FONT_SCALE_CHOICES; 1.0 (unchanged size) for every
# existing widget until its dialog's "Text size" picker sets it.
font_scale: Mapped[float] = mapped_column(Float, default=1.0)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),)
+22
View File
@@ -36,6 +36,28 @@ CONTENT_MARGIN = 20
CARD_RADIUS = 12
CHIP_RADIUS = 4
# models.Widget.font_scale's allowed values -- a per-widget text-size
# multiplier (the Layout dialog's "Text size" picker), same "reject
# invalid, don't silently coerce" posture as border_style. Deliberately a
# small fixed set (a <select>, not a raw slider) rather than an arbitrary
# float: every _AGENDA_FONTS/_TASKS_FONTS-style tuple in calendar_render.py
# and every proportional size in html_render.py/calendar_html_render.py
# derives row heights/max_rows from the same scaled font size, so an
# unbounded scale risks a layout that no longer fits its own box.
FONT_SCALE_CHOICES = (1.0, 1.25, 1.5)
def scaled_size(value: float, font_scale: float) -> int:
"""Applies a widget's font_scale to a text-size value and rounds to
an int px, floored at 1 so an extreme scale can never zero out a
font. Shared by both the classic (PIL, calendar_render.py) and modern
(HTML/CSS, html_render.py/calendar_html_render.py) renderers so "make
this widget's text bigger" behaves identically regardless of
render_style -- every caller applies this immediately after its own
tier lookup/floor calc, so row heights/max_rows computed from the
result already account for the bigger text."""
return max(1, round(value * font_scale))
# Index constants into DEFAULT_PALETTE_RGB/a frame's own Frame.
# palette_rgb override -- same order as image_pipeline.PALETTE_LABELS.
BLACK, WHITE, YELLOW, RED, BLUE, GREEN = range(6)
+33 -6
View File
@@ -26,7 +26,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
from .. import calendar_render, grid, panel_style, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
@@ -96,7 +96,7 @@ def _widget_dict(w: Widget, locked: bool = False) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order, "border_style": w.border_style,
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
"locked": locked}
"font_scale": w.font_scale, "locked": locked}
def require_widget_view(
@@ -280,6 +280,31 @@ def api_widget_border(
return _widget_dict(widget)
class WidgetFontScaleRequest(BaseModel):
font_scale: float
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/font-scale")
def api_widget_font_scale(
body: WidgetFontScaleRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's text-size multiplier (the Layout dialog's "Text
size" picker) -- a shared Widget-level property (see models.Widget.
font_scale), not a per-type config field, since any widget type with
body text can use it. Its own endpoint for the same reason
api_widget_border has one: api_widget_config_save's per-type dispatch
edits a config row via widget_locked, and font_scale lives on Widget
itself, not any per-type config table."""
frame, widget = frame_widget
if body.font_scale not in panel_style.FONT_SCALE_CHOICES:
raise HTTPException(400, f"font_scale must be one of {panel_style.FONT_SCALE_CHOICES}")
with frame_locked(db, frame.id):
widget.font_scale = body.font_scale
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
@@ -876,7 +901,7 @@ def api_widget_preview_calendar(
img = calendar_html_render.build(
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
frame.theme,
frame.theme, widget.font_scale,
)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
@@ -887,7 +912,7 @@ def api_widget_preview_calendar(
week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset,
week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
)
return Response(content=png, media_type="image/png")
@@ -912,12 +937,14 @@ def api_widget_preview_tasks(
from .. import html_render
target_w, target_h = logical_render_size(frame.orientation)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
widget.font_scale)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, title=title)
palette_rgb=frame.palette_rgb, title=title,
font_scale=widget.font_scale)
return Response(content=png, media_type="image/png")
+4 -5
View File
@@ -282,11 +282,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
# firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
# compatible with its 256-byte parse buffer), and only until the
# device has authenticated with the token once (device_token_ack).
if locked.device_id is not None and not locked.device_token_ack:
# Per-frame token push: only until the device has authenticated
# with it once (device_token_ack) -- no reason to keep sending it
# on every wake once the device has it.
if not locked.device_token_ack:
response["device_token"] = locked.device_token
return response
+13 -3
View File
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import theme_tokens, weather
from .. import panel_style, theme_tokens, weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
@@ -258,6 +258,16 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"back_button_action": bindings.get("back", ""),
}
# The shared "Text size" card (_widget_font_scale_fields.html,
# models.Widget.font_scale) -- only included on the dialogs whose
# on-panel content is mostly body text (calendar/tasks; the other
# types are either image-only or, for text, already have their own
# richer per-widget font_size control -- see TextWidgetConfig).
font_scale_labels = {1.0: "Normal", 1.25: "Large", 1.5: "X-Large"}
font_scale_ctx = {
"font_scale_choices": [(v, font_scale_labels[v]) for v in panel_style.FONT_SCALE_CHOICES],
}
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
@@ -273,7 +283,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
**border_ctx, **button_ctx,
**border_ctx, **button_ctx, **font_scale_ctx,
})
if widget.widget_type == "tasks":
@@ -282,7 +292,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
**border_ctx, **button_ctx,
**border_ctx, **button_ctx, **font_scale_ctx,
})
if widget.widget_type == "static":
-20
View File
@@ -707,26 +707,6 @@ def admin_link_user(
notice=f"Linked '{target.username}' to frame #{frame_id}.")
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
def admin_end_legacy(
frame_id: int,
request: Request,
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
"""Closes the legacy-token migration window once the device is
confirmed on per-frame auth (device_token_ack + recent last_seen in
the frames table below)."""
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
frame = db.get(Frame, frame_id)
if frame is None:
return _render_admin(request, db, admin, error="No such frame.")
frame.legacy_token_enabled = False
db.commit()
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
@router.post("/admin/smtp", response_class=HTMLResponse)
def admin_smtp_save(
request: Request,
@@ -199,6 +199,7 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
initBorderFields();
initFontScaleFields();
initButtonActionFields();
}
@@ -0,0 +1,32 @@
// Shared "Text size" card (models.Widget.font_scale,
// _widget_font_scale_fields.html) -- only present on the calendar/tasks
// dialogs (see frame_pages.py's widget_dialog), same "one shared init
// function" shape as initBorderFields, just not included on every
// dialog since it isn't relevant to every widget type.
function initFontScaleFields() {
const select = document.getElementById('widget_font_scale');
if (!select) return; // dialog fragment didn't render the font-scale card -- shouldn't happen
document.getElementById('font-scale-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const resp = await fetch(`${window.FRAME_API}/font-scale`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ font_scale: Number(select.value) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Text size saved.');
// Whichever dialog is actually open -- both loadCalendarPreview and
// loadTasksPreview are always defined (plain script tags, not
// module-scoped), so checking the function's existence isn't
// enough; check for the <img> it actually targets instead (calling
// the wrong one throws setting .src on a null element).
if (document.getElementById('calendar-preview')) loadCalendarPreview();
if (document.getElementById('tasks-preview')) loadTasksPreview();
} catch (err) {
showStatus(false, err.message);
}
});
}
+1
View File
@@ -90,6 +90,7 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
initBorderFields();
initFontScaleFields();
initButtonActionFields();
}
@@ -135,6 +135,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
@@ -67,6 +67,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
@@ -0,0 +1,15 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Text size</h2>
<p class="sub">Scales this widget's own text up for easier reading on
the panel -- rows/columns re-fit around the bigger text automatically.</p>
<form id="font-scale-config-form">
<label>Size
<select id="widget_font_scale">
{% for value, label in font_scale_choices %}
<option value="{{ value }}" {% if widget.font_scale == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
-8
View File
@@ -109,20 +109,12 @@
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
{% if f.legacy_token_enabled %}&middot; <strong>legacy token window OPEN</strong>{% endif %}
</p>
<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="text" name="username" placeholder="Link user by name" required>
<button type="submit" class="secondary btn-inline">Link</button>
</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"
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+1
View File
@@ -77,6 +77,7 @@
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script>
<script src="/static/widget_dialog_font_scale.js"></script>
<script src="/static/widget_dialog_button_actions.js"></script>
<script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script>
@@ -1,19 +1,26 @@
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %}
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) %}
<div class="day-section">
{#- Fixed height (not auto) -- every stacked day-section's header
must be exactly this tall regardless of whether THIS particular
day has a weather entry, or days with/without weather misalign
where their event rows start (see calendar_week_horizontal's
identical fix/reasoning). #}
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;">
<div class="day-header-title">{{ header }}</div>
{% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %}
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}&deg;/{{ we.low }}&deg;{{ unit_suffix }}</span></div>
{% endfor %}
</div>
{% endif %}
identical fix/reasoning). Bold-minimal: a slim accent-colored
rule (not a full gradient band) carries the theme identity --
the header text itself is plain ink, dithered at the base
amplitude like everything else, not the richer accent amplitude
the old white-on-gradient text needed to stay legible. #}
<div class="day-header" style="height: {{ header_h }}px;">
<div class="accent-rule" style="height: {{ accent_h }}px; background: {{ accent_start }};"></div>
<div class="day-header-row">
<div class="day-header-title" style="font-size: {{ title_size }}px;">{{ header }}</div>
{% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %}
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}&deg;/{{ we.low }}&deg;{{ unit_suffix }}</span></div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="day-rows">
{% if not rows and not more_count %}
@@ -4,18 +4,9 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
.wrap {
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
display: flex; flex-direction: column; justify-content: space-between;
}
.icon-wrap { display: flex; align-items: center; }
.icon-body {
@@ -24,7 +15,6 @@
border: {{ stroke }}px solid #000000;
border-radius: {{ icon_radius }}px;
padding: {{ stroke }}px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.icon-fill {
width: {{ fill_pct }}%;
@@ -38,16 +28,19 @@
background: #000000;
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
}
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 1; color: {{ fill_color }}; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; }
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 0.85; color: {{ fill_color }}; letter-spacing: -0.02em; }
.lines { margin-top: {{ line_gap }}px; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1.3; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="wrap">
<div class="icon-wrap">
<div class="icon-body"><div class="icon-fill"></div></div>
<div class="icon-nub"></div>
</div>
<div class="pct">{{ percent }}%</div>
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
<div>
<div class="pct">{{ percent }}%</div>
<div class="lines">{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}</div>
</div>
</div>
</body></html>
@@ -4,33 +4,32 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; border-radius: 100px; }
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 6px; min-height: 0; }
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-weather-row { display: flex; gap: 14px; flex: 0 0 auto; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.day-rows { flex: 1 1 auto; padding: 8px 0; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
<div class="wrap">
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
</div>
</body></html>
@@ -4,22 +4,27 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; }
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; }
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.weekday-row { display: flex; flex: 0 0 auto; margin-top: 6px; }
.weekday-cell { flex: 1 1 0; color: #17233b; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; margin-top: 2px; }
.week-row { flex: 1 1 0; display: flex; }
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; }
{#- A real palette color (pure black), not a pale gray -- this panel's
6-color palette has no gray to dither toward, so #e2e6ec-style
"hairlines" don't survive at all (confirmed by sampling actual
rendered pixels: every one came back pure white). Horizontal
rules only, between week rows -- enough for the grid to scan
top-to-bottom without boxing every single day cell, which read
more like the old structured-dashboard mockup than bold-minimal. #}
.week-row + .week-row { border-top: 1px solid #000000; }
.day-cell { flex: 1 1 0; padding: 4px; min-width: 0; overflow: hidden; }
{#- Bold everywhere, including out-of-month -- de-emphasis is via
smaller size only, not weight or a gray color. Regular-weight and
gray text are both individually fragile under Bayer ordered
@@ -40,7 +45,8 @@
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="wrap">
<div class="accent-rule"></div>
<div class="weekday-row">
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
</div>
@@ -4,36 +4,39 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
.day-section + .day-section { border-top: 1px solid #e2e6ec; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
palette anyway (no gray to dither toward, confirmed by sampling
rendered pixels), and the accent rule + margin at the top of the
next section already reads as a clear boundary without one. #}
.day-section + .day-section { margin-top: 8px; }
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; border-radius: 100px; }
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 4px; min-height: 0; }
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-weather-row { display: flex; gap: 12px; flex: 0 0 auto; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; }
.day-rows { flex: 1 1 auto; padding: 4px 0; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
<div class="wrap">
{% for day in days %}
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
{% endfor %}
</div>
</body></html>
@@ -4,32 +4,36 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.cols { flex: 1 1 auto; display: flex; margin-top: 6px; min-height: 0; }
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
.col + .col { border-left: 1px solid #e2e6ec; }
{#- No divider line -- #e2e6ec was invisible on this panel's 6-color
palette anyway (no gray to dither toward, confirmed by sampling
rendered pixels); the shared accent rule above already frames the
whole week as one unit, and each column's own label anchors it. #}
.col + .col { margin-left: 4px; }
.col-header {
/* Fixed height (not auto) -- every column must be exactly this tall
regardless of whether THIS particular day has a weather entry, or
columns with/without weather misalign their event rows to
different starting Y positions across the week grid. */
{#- Fixed height (not auto) -- every column must be exactly this tall
regardless of whether THIS particular day has a weather entry, or
columns with/without weather misalign their event rows to
different starting Y positions across the week grid. #}
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
padding: 6px 6px;
padding: 2px 6px 4px;
overflow: hidden;
}
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; }
.col-header .label {
font-weight: 700; color: #17233b; font-size: {{ header_size }}px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.col-weather { display: flex; align-items: center; gap: 3px; color: #5b6674; font-size: {{ weather_size }}px; margin-top: 2px; }
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
@@ -41,20 +45,23 @@
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
{% for col in cols %}
<div class="col">
<div class="col-header">
<div class="label">{{ col.label }}</div>
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}&deg;/{{ col.weather.low }}&deg;{{ unit_suffix }}</span></div>{% endif %}
<div class="wrap">
<div class="accent-rule"></div>
<div class="cols">
{% for col in cols %}
<div class="col">
<div class="col-header">
<div class="label">{{ col.label }}</div>
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}&deg;/{{ col.weather.low }}&deg;{{ unit_suffix }}</span></div>{% endif %}
</div>
<div class="col-rows">
{% for row in col.rows %}
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
{% endfor %}
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
</div>
</div>
<div class="col-rows">
{% for row in col.rows %}
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
{% endfor %}
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</body></html>
@@ -4,27 +4,21 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.header {
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
.header { flex: 0 0 auto; height: {{ header_h }}px; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.title {
flex: 1 1 auto; display: flex; align-items: center;
color: #17233b; font-weight: 700; font-size: {{ title_size }}px; line-height: 1;
padding-top: 4px;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; line-height: 1; }
.rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.rows { flex: 1 1 auto; padding-top: 4px; overflow: hidden; }
.row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; }
.chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.chip span { flex: 1 1 0; }
@@ -33,7 +27,7 @@
border: 2px solid #17233b;
}
.box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; }
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.summary {
font-size: {{ body_size }}px; color: #17233b; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -42,8 +36,11 @@
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
</style></head>
<body>
<div class="card">
<div class="header"><div class="title">{{ title }}</div></div>
<div class="wrap">
<div class="header">
<div class="accent-rule"></div>
<div class="title">{{ title }}</div>
</div>
<div class="rows">
{% if not rows %}
<div class="empty">Nothing outstanding</div>
@@ -4,27 +4,34 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
.wrap {
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
display: flex; flex-direction: column; justify-content: space-between;
}
.icon { font-size: {{ icon_size }}px; line-height: 1; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.18)); }
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; }
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.city {
flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 700; font-size: {{ city_size }}px; line-height: 1.2;
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
}
.icon { flex: 0 0 auto; font-size: {{ icon_size }}px; line-height: 1; }
.temp-row { display: flex; align-items: flex-start; }
.temp {
font-weight: 700; font-size: {{ temp_size }}px; line-height: 0.85;
color: #17233b; letter-spacing: -0.03em;
}
.deg { font-weight: 400; font-size: {{ deg_size }}px; line-height: 1.3; color: #5b6674; }
.cond { font-weight: 400; font-size: {{ cond_size }}px; color: #5b6674; margin-top: {{ (pad * 0.25) | round(0, 'floor') }}px; }
</style></head>
<body>
<div class="card">
<div class="icon">{{ emoji }}</div>
<div class="temp">{{ temp }}&deg;{{ unit_suffix }}</div>
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
<div class="wrap">
<div class="top">
{% if city_label %}<div class="city">{{ city_label }}</div>{% else %}<div></div>{% endif %}
<div class="icon">{{ emoji }}</div>
</div>
<div>
<div class="temp-row"><div class="temp">{{ temp }}</div><div class="deg">&deg;{{ unit_suffix }}</div></div>
{% if condition %}<div class="cond">{{ condition }}</div>{% endif %}
</div>
</div>
</body></html>
@@ -4,46 +4,40 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
background: #ffffff;
.wrap { width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px; display: flex; flex-direction: column; }
.accent-bar {
flex: 0 0 auto; height: {{ accent_h }}px; width: 100%;
border-radius: {{ (accent_h / 2) | round(0, 'floor') }}px;
background: linear-gradient(90deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
}
.header {
height: {{ header_h }}px;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
.city {
flex: 0 0 auto; margin-top: 6px; font-weight: 700; font-size: {{ city_size }}px;
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; }
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; }
.col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
.days { flex: 1 1 auto; display: flex; align-items: center; gap: {{ col_gap }}px; }
.col { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
.day-label {
font-weight: 700; font-size: {{ day_label_size }}px; letter-spacing: 0.06em;
text-transform: uppercase; color: #5b6674;
}
.day { font-weight: 600; font-size: {{ label_size }}px; color: #17233b; }
.icon { font-size: {{ icon_size }}px; line-height: 1; }
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; }
.temps .low { color: #6b7788; font-weight: 400; }
.temps { display: flex; align-items: baseline; gap: 3px; }
.high { font-weight: 700; font-size: {{ high_size }}px; line-height: 1; color: #17233b; }
.low { font-weight: 400; font-size: {{ low_size }}px; line-height: 1; color: #5b6674; }
</style></head>
<body>
<div class="card">
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %}
<div class="body">
<div class="wrap">
{% if city_label %}
<div class="accent-bar"></div>
<div class="city">{{ city_label }}</div>
{% endif %}
<div class="days">
{% for d in days %}
<div class="col">
<div class="day">{{ d.label }}</div>
<div class="day-label">{{ d.label }}</div>
<div class="icon">{{ d.emoji }}</div>
<div class="temps">{{ d.high }}&deg;<span class="low">/{{ d.low }}&deg;{{ unit_suffix }}</span></div>
<div class="temps"><div class="high">{{ d.high }}&deg;</div><div class="low">{{ d.low }}&deg;{{ unit_suffix }}</div></div>
</div>
{% endfor %}
</div>
+2 -2
View File
@@ -59,14 +59,14 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
return calendar_html_render.build(
events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb,
weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset,
frame.theme,
frame.theme, widget.font_scale,
)
return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout,
week_days=cfg.week_days, week_layout=cfg.week_layout, font_scale=widget.font_scale,
week_start_offset=cfg.week_start_offset,
)
+3 -2
View File
@@ -43,8 +43,9 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
widget.font_scale)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, font_scale=widget.font_scale)
ACTIONS: dict = {}
+5 -6
View File
@@ -10,12 +10,11 @@ services:
- CONFIG_PATH=/data/config.json
- IMMICH_URL=http://your-immich-host:2283
- IMMICH_API_KEY=your-immich-api-key-here
# Optional: gates the entire server -- the web UI (/, /api/*) AND
# every device-facing /frame/* endpoint -- behind this shared secret.
# Leave unset to keep it all open on a trusted LAN, same as before.
# Paste the same value into the ESP32's captive portal setup form
# (Access Token field) so it's sent on every device request and gets
# embedded automatically in the manage-menu/share QR codes.
# Optional: gates first-run /setup on a freshly deployed server --
# whoever supplies this value is the one who gets to create the
# first admin account. Meaningless once that account exists (every
# other route always requires a real login), so leave unset unless
# you're worried about someone else reaching /setup before you do.
- MANAGEMENT_TOKEN=changeme
# 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
+13
View File
@@ -112,6 +112,19 @@ def link_user(db: Session, user: User, frame: Frame) -> None:
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:
resp = client.post("/login", data={"username": username, "password": password})
assert resp.status_code == 303, resp.text
+13 -10
View File
@@ -26,6 +26,8 @@ from app.models import (
Widget,
)
from .conftest import claim_device
EXPECTED_BYTES = 800 * 480 // 2
_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):
frame = db_session.get(Frame, 1)
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 len(resp.content) == EXPECTED_BYTES
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
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 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()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
creds = claim_device(db_session, frame)
_mock_immich(monkeypatch)
resp = client.get("/frame/image")
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
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 len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
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
db_session.refresh(cfg)
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)
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.commit()
creds = claim_device(db_session, frame)
_mock_immich(monkeypatch)
plain = client.get("/frame/image").content
with_manage = client.get("/frame/image?manage=1").content
plain = client.get(f"/frame/image?{creds}").content
with_manage = client.get(f"/frame/image?{creds}&manage=1").content
assert len(with_manage) == EXPECTED_BYTES
assert with_manage != plain # the manage-QR overlay actually got composited in
+9 -8
View File
@@ -19,7 +19,7 @@ from app.models import (
WhiteboardWidgetConfig,
)
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
@@ -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):
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 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)
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"
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 len(resp.content) == EXPECTED_BYTES
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)
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"
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 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"})
frame = db_session.get(Frame, 1)
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 len(resp.content) == EXPECTED_BYTES
+125 -29
View File
@@ -28,6 +28,60 @@ from app.models import (
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():
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_username" in user_columns # migration 14
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 "static_widget_configs" in inspector.get_table_names() # migration 20
assert "text_widget_configs" in inspector.get_table_names() # migration 21
@@ -104,14 +156,18 @@ def test_expected_columns_exist_on_current_schema():
assert "render_style" in whiteboard_widget_columns # migration 37
calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")}
assert "render_style" in calendar_widget_columns # migration 38
assert "theme" in frame_columns # migration 39
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)
assert frame.mode == "photos"
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
assert len(widgets) == 1
@@ -121,7 +177,7 @@ def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_sessi
config = db_session.get(PhotoWidgetConfig, widget.id)
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()
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
@@ -138,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):
"""Reproduces the old fixed 50/50 inlay split as two independent,
non-overlapping widgets instead of silently dropping the photo half
on upgrade -- see models.py's CalendarWidgetConfig docstring."""
frame = Frame(
name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
mode="calendar", orientation="landscape", calendar_view="week",
calendar_photo_inlay=True, album_id="album-123",
current_asset_id="asset-1", queue=["asset-1", "asset-2"],
created_at=time.time(),
)
on upgrade -- see models.py's CalendarWidgetConfig docstring. Exercises
_migration_41's raw-SQL backfill safety net: a frame whose legacy
Frame columns (pre-widget-system) still carry real data but which
somehow has no Widget yet."""
frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
orientation="landscape", created_at=time.time())
db_session.add(frame)
db_session.flush()
frame_id = frame.id
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()
widgets = db_session.scalars(
@@ -178,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):
frame = Frame(
name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
mode="whiteboard", orientation="portrait",
whiteboard_url="https://example.com/board.whiteboard",
created_at=time.time(),
)
"""Exercises _migration_41's raw-SQL backfill safety net for a
whiteboard-mode legacy frame -- same shape as the calendar-inlay case
above, just the simpler single-widget mode dispatch branch."""
frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
orientation="portrait", created_at=time.time())
db_session.add(frame)
db_session.flush()
frame_id = frame.id
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()
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
@@ -247,19 +321,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
conn.execute(text(
"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"))
frame = Frame(
name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
mode="photos", album_id="legacy-album", current_asset_id="legacy-asset",
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
created_at=time.time())
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "legacy-owner")
db_session.commit()
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
# "calendar", so it has no calendar widget for _ensure_frame_
# calendars_rekeyed to attach it to) -- exercises that it's dropped
@@ -362,6 +440,24 @@ def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_ses
assert frame.last_displayed_at == 0.0
def test_migration_40_adds_font_scale_to_an_existing_database(db_session):
"""Exercises _migration_40's real guarded ALTER path (widgets isn't
dropped/recreated by the pre-widget-system replay tests, so its
columns must be added defensively, same reasoning as migration
26/27/29/30's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 39"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
widget = db_session.query(Widget).filter(Widget.frame_id == 1).first()
assert widget.font_scale == 1.0
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
@@ -513,12 +609,11 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
conn.execute(text(
"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"))
frame = Frame(
name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
mode="calendar", created_at=time.time(),
)
frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
created_at=time.time())
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "cal-owner")
@@ -526,6 +621,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id})
conn.execute(text(
"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)"
+9 -7
View File
@@ -14,7 +14,7 @@ from PIL import Image
from app.image_pipeline import logical_render_size
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):
@@ -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):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
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
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):
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")
assert first.status_code == 200
resp = client.post("/frame/advance")
resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200
after_advance = client.get("/api/frames/1/now-displaying")
assert after_advance.status_code == 200
resp = client.post("/frame/back")
resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200
after_back = client.get("/api/frames/1/now-displaying")
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")
frame = db_session.get(Frame, 1)
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()
login(client, "bob")
+2 -1
View File
@@ -37,7 +37,8 @@ def test_set_border_persists(client, db_session):
assert resp.status_code == 200, resp.text
assert resp.json() == {
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "locked": False,
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "font_scale": 1.0,
"locked": False,
}
widget = db_session.get(Widget, widget_id)
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
+184
View File
@@ -0,0 +1,184 @@
"""routers/api_widgets.py's POST .../font-scale endpoint (models.Widget.
font_scale) -- a Widget-level property, not a per-type config field, same
reasoning/shape as test_widget_border.py's border coverage. The second
half confirms the actual render threading (widgets/calendar.py and
widgets/tasks.py pass widget.font_scale into both the classic and modern
builders), the same "spy on the resolve call" approach test_widgets_tasks.
py/test_widgets_calendar.py already use for frame.theme threading."""
from __future__ import annotations
import time
from app.models import CalendarWidgetConfig, Frame, TaskWidgetConfig, Widget
from .conftest import csrf_headers, link_user, login, make_user
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
def test_new_widget_defaults_to_normal_font_scale(db_session):
widget = db_session.query(Widget).filter_by(frame_id=1).one()
assert widget.font_scale == 1.0
def test_set_font_scale_persists(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json()["font_scale"] == 1.25
widget = db_session.get(Widget, widget_id)
assert widget.font_scale == 1.25
def test_set_font_scale_rejects_a_value_outside_the_fixed_choices(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 3.0}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "font_scale" in resp.json()["detail"]
assert db_session.get(Widget, widget_id).font_scale == 1.0
def test_set_font_scale_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_set_font_scale_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.get(Widget, widget_id).font_scale == 1.0
def test_set_font_scale_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
# --- actually threads through to the renderers -----------------------------
def test_calendar_classic_render_threads_font_scale_through(db_session, monkeypatch):
"""Confirms widgets/calendar.py's classic branch passes widget.
font_scale into calendar_render._build, by spying on panel_style.
scaled_size (every classic builder's one shared scale point -- see
its own docstring)."""
from app import panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time(), font_scale=1.5)
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda"))
db_session.commit()
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.calendar.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.5 for s in seen_scales)
def test_calendar_modern_render_threads_font_scale_through(db_session, monkeypatch):
from PIL import Image
from app import html_render, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time(), font_scale=1.25)
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda", render_style="modern"))
db_session.commit()
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
from app import panel_style
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.calendar.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.25 for s in seen_scales)
def test_tasks_classic_render_threads_font_scale_through(db_session, monkeypatch):
from app import panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time(), font_scale=1.5)
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id))
db_session.commit()
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.5 for s in seen_scales)
def test_tasks_modern_render_threads_font_scale_through(db_session, monkeypatch):
from PIL import Image
from app import html_render, panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time(), font_scale=1.25)
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id, render_style="modern"))
db_session.commit()
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.25 for s in seen_scales)
+2 -2
View File
@@ -76,9 +76,9 @@ def _capture_build_tasks_title(monkeypatch):
seen_titles = []
real_build_tasks = widgets.tasks._build_tasks
def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks"):
def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks", font_scale=1.0):
seen_titles.append(title)
return real_build_tasks(tasks, target_w, target_h, palette_rgb, title)
return real_build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
monkeypatch.setattr(widgets.tasks, "_build_tasks", spy)
return seen_titles