26 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
tfaour 466efdb873 Deploy: docker compose down before pull/up, not just up -d
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 3m29s
Build and push server image / deploy (push) Failing after 1m21s
up -d alone assumes the previous container releases port 8420 cleanly
before the new one binds -- it doesn't force that. The last three
deploys all failed with "port is already allocated" at exactly that
step; running compose down first (confirmed working when done manually
over SSH) guarantees the old container is fully stopped and removed
before the new one starts, closing the race.
2026-07-31 12:35:14 +00:00
tfaour e363db0e4e Clarify what a theme actually changes beyond the header accent
A theme's font_family applies to every text element in a modern-style
widget, not just the header title, and radius/shadow change the card's
whole look -- worth spelling out since the accent color alone
undersells how much a theme actually does, especially on text-heavy
widgets, and on the two widget kinds (battery, static/whiteboard) that
have no header at all.
2026-07-31 12:35:07 +00:00
tfaour 5f4f8f2ea7 Add a curated theme system for "modern" style widgets, inspired by Tesserae
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00
tfaour e331f5e5a1 Roll out "modern" HTML/CSS render style to every widget except photos
Build and push server image / test (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 3m51s
Build and push server image / deploy (push) Failing after 1m57s
Extends weather's experimental Chromium+Jinja2 render style to battery,
text, tasks, static image, whiteboard, and calendar (all four view
modes -- agenda/today_tomorrow/week/month), and gives the photos widget
its own genuinely independent palette + dithering strength.

Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the
existing palette_rgb/dither_strength), with a second "Photos
configuration" card in Advanced Configuration. widgets/photos.py's
render() quantizes itself against these before returning -- no
render_panel changes needed, since photos is the only widget that
genuinely needs a different reference palette and can carry that
itself, the same way modern-style widgets already self-dither via
ordered_dither.

Battery/text/tasks/static image/whiteboard: same render_style pattern
weather established (render_style column, html_render.py build
function, Jinja2 template, dialog toggle). Static image/whiteboard get
their first-ever visual chrome (a rounded-corner shadowed card,
shared framed_image.html.jinja) since classic draws them with zero
frame at all. Fixed the same "preview endpoint bypasses render_style"
bug weather originally shipped with, for tasks/static/whiteboard/
calendar's preview endpoints.

Calendar: own module (app/calendar_html_render.py, mirroring
calendar_render.py's separation from the simpler widgets) covering all
four view modes, not just agenda -- reuses calendar_render's own
private helpers so event colors/times/weather/month-grid math match
classic exactly. Found and fixed two real cross-day layout bugs along
the way: a per-day header height that varied based on whether that
specific day had a weather entry (misaligning where every other day's
event rows started across the week/month grid), and regular-weight
small text being fragile under Bayer ordered dithering (out-of-month
day numbers degraded into unrecognizable speckle) -- fixed by using
bold everywhere and de-emphasizing via size instead of weight/gray,
since gray text has the same dithering fragility this project's PIL
renderers already avoid for exactly this reason.

Migrations 32-38 (Frame's two new columns, then one render_style column
per widget config table). 452 tests passing, including new dispatch/
migration coverage per widget type and a dedicated photos test proving
photo_palette_rgb produces genuinely independent quantization from the
frame's main palette_rgb.
2026-07-31 03:52:19 +00:00
tfaour c6dad191fb Fetch headless Chromium at container startup instead of build time
Build and push server image / test (push) Successful in 45s
Build and push server image / build-and-push (push) Successful in 4m17s
Build and push server image / deploy (push) Failing after 1m42s
build-and-push failed on the last deploy: chromium-headless-shell's
single ~181MB binary can't be split across Docker layers the way this
project's pip/npm installs were (those are many independently-
installable smaller packages; this is one file), and confirmed-failed
to push past the registry's per-layer size limit.

Moves the `playwright install chromium-headless-shell` step from the
Dockerfile to start.sh, caching into PLAYWRIGHT_BROWSERS_PATH on the
/data volume -- only the very first boot on a fresh volume downloads
it, every boot after that is a no-op check. The image itself no longer
grows by ~262MB, so nothing new gets pushed to the registry at all.
2026-07-31 02:18:36 +00:00
tfaour 8ea1c53ec3 Add experimental HTML/CSS "modern" render style for weather widget
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Failing after 2m34s
Build and push server image / deploy (push) Has been skipped
The weather widget's icons/layout are hand-drawn PIL primitives -- clean
under quantization but flat, no gradients/shadows. Adds an opt-in
render_style="modern" (current/daily modes only) that instead renders a
Jinja2 template through a persistent headless-Chromium browser
(app/html_render.py), following the approach of Tesserae, an open-source
e-ink dashboard targeting this same panel family.

Key design points:
- The Chromium dependency (Playwright) is lazily imported only when a
  weather widget actually uses "modern" style, and the background browser
  itself only launches on first use -- every other widget type, and this
  one's own classic/hourly/multi_city paths, never pay for it.
- No Frame-level dithering setting needed: html_render dithers its own
  rendered widget to exact palette colors (Bayer/ordered, not
  Floyd-Steinberg) before compositing, so the shared whole-canvas
  Floyd-Steinberg pass sees zero quantization error there and leaves it
  untouched -- same trick draw_text/hand-drawn icons already use. Floyd-
  Steinberg keeps working unchanged for photos and every other widget.
- A "Load calibrated Spectra 6 preset" button in Advanced configuration
  offers a community-measured palette (data ported from
  paperlesspaper/epdoptimize, Apache 2.0) as an alternative starting
  point to the existing idealized DEFAULT_PALETTE_RGB -- fills the
  existing palette table, doesn't save by itself.

Known open risk, not resolved here: a headless Chromium binary is far
larger than the ~100MB single-layer limit that already forced this
project's pip/npm installs into split layers, and (unlike those) is a
single ~180MB file that can't be split across layers by ordinary
Dockerfile restructuring. Flagged prominently in server/Dockerfile and
docs/widgets.md -- treat this render style as experimental/local-only
until that's resolved.
2026-07-30 22:18:43 +00:00
tfaour d34eb1bf45 Modernize on-panel widget visuals: real typography, theme colors, gutter
Build and push server image / test (push) Successful in 38s
Build and push server image / build-and-push (push) Successful in 2m46s
Build and push server image / deploy (push) Successful in 58s
Introduces app/panel_style.py, a shared style module every render
module now draws through instead of independently duplicating margins/
colors/fonts: Inter Bold/Regular (already vendored, previously only
used by widgets/text.py) replace PIL's single-weight bundled default
font everywhere else; a per-widget-kind accent color (calendar=blue,
tasks=green, weather=black header) replaces plain black-on-white chrome
and is centralized in one THEME mapping so a future global theme only
needs to touch panel_style.py; a small per-widget gutter separates
adjacent widgets without touching grid.py's cell math; header bars,
color chips, and the battery icon get rounded corners.

Also drops the MUTED gray text color used throughout calendar_render.py
and weather_render.py -- a non-palette color that has no close match in
the panel's 6-ink palette and dithers into visible speckle once the
composited canvas is quantized. Secondary text now reads through size/
weight alone, always exact black.

widgets/battery.py and manage_overlay.py's previously-duplicated
battery-glyph-drawing code now share one implementation (panel_style.
draw_battery_icon). widgets/_shared.py's placeholder image is fixed to
use exact palette colors and route through image_pipeline.draw_text,
same as everything else -- it was quietly violating both rules already.

image_pipeline.draw_widget_border gains an opt-in radius param (default
0, unused by any call site) for a possible future rounded-border
setting -- doesn't touch the exact-corner-pixel behavior test_widget_
border.py already pins.

Deliberately out of scope: DEFAULT_PALETTE_RGB and the Floyd-Steinberg
quantization pipeline are untouched, per the prior reverted measured-
palette/OKLab attempt (05b417a/dfe9d701).
2026-07-30 03:12:17 +00:00
tfaour bcea090e73 Add LAYOUT_CONFIG_FIELDS step to the make-widget checklist
A new widget type shipping without an entry there fails silently --
no error, no test failure, it just saves/applies with an empty config
forever. Caught for real on the weather widget (37d57a1); adding the
step and a matching test-pattern bullet so the next widget type doesn't
repeat it.
2026-07-28 15:17:43 +00:00
tfaour 37d57a1f88 Fix saved layouts silently dropping weather widget settings
Build and push server image / test (push) Successful in 38s
Build and push server image / build-and-push (push) Successful in 2m37s
Build and push server image / deploy (push) Successful in 52s
LAYOUT_CONFIG_FIELDS never had a "weather" entry, so saving a layout
captured an empty config for any weather widget -- applying it back
(including via hold-to-cycle) reset mode/provider/city/units/etc to
defaults instead of restoring what was configured.
2026-07-28 14:44:15 +00:00
tfaour d974e872ba Split the pip install into multiple Dockerfile layers
Build and push server image / test (push) Successful in 36s
Build and push server image / build-and-push (push) Successful in 2m34s
Build and push server image / deploy (push) Successful in 49s
The combined pip install layer was already over Cloudflare's
single-blob/layer payload-size limit (~113MB unpacked) before any
recent change -- the last two build-and-push CI runs were failing on
it. Isolate the three largest packages (sqlalchemy, pillow, pypdfium2)
into their own layers, same fix already applied to render-service's
npm installs below for the same limit.
2026-07-28 04:31:18 +00:00
tfaour dfe9d71971 Revert "Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering"
This reverts commit 05b417a29b.
2026-07-28 04:29:59 +00:00
tfaour 05b417a29b Quantize with a measured Spectra 6 palette and OKLab-space ordered dithering
Build and push server image / test (push) Successful in 58s
Build and push server image / build-and-push (push) Successful in 2m44s
Build and push server image / deploy (push) Successful in 53s
DEFAULT_PALETTE_RGB was a guessed approximation of the panel's ink
colors (pure sRGB primaries); swap in epdoptimize's measured spectra6
palette instead, which is far more muted/darker, matching how these
inks actually look.

_quantize now matches against the palette in OKLab space (perceptual
distance) instead of PIL's raw-RGB quantize(), with lightness weighted
down relative to hue/chroma when selecting the nearest color -- this
palette's inks are lit so differently from their sRGB namesakes
(muted dark red, bright yellow) that unweighted distance let lightness
dominate and mismatch hue (pure red nearest "yellow").

Dithering switched from Floyd-Steinberg error diffusion to a Bayer
ordered dither: true error diffusion is an inherently serial per-pixel
loop, and doing that in pure Python for a full 800x480 panel took
~1s, blowing past the render-latency budget the "render widgets
concurrently" fix (previous commit) exists to protect. The ordered
dither finds each pixel's true nearest and second-nearest palette
color and mixes between them (via projection onto that segment, not
distance ratio) using a tiled Bayer threshold -- fully vectorized, no
Python-level pixel loop.
2026-07-28 04:20:25 +00:00
tfaour a48c84ed4a Render widgets concurrently instead of one at a time
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Failing after 1m57s
Build and push server image / deploy (push) Has been skipped
A layout with several network-backed widgets (photos, weather,
calendar) paid their fetch latency serially in one /frame/* request,
which could exceed the firmware's fixed HTTP timeout and show a false
"server failed" status screen even though the server was still
working -- most visibly on the hold-triggered "cycle layouts" action,
which swaps in a whole new, cold-started widget set. Each widget now
renders on its own DB session in a thread pool (a plain Session isn't
thread-safe to share, but the per-frame threading.Lock in
frame_locked/widget_locked already made this kind of concurrency safe
by design -- see app/db.py); regions are still collected in
sort_order so overlapping widgets paint in the same z-order as before.
2026-07-28 03:40:07 +00:00
tfaour d1f1968317 Log device-facing /frame/* requests in the server log
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Failing after 1m59s
Build and push server image / deploy (push) Has been skipped
The admin log viewer only ever showed exceptions from device.py, not
successful requests -- no way to see a request that was slow-but-200,
or a device probing with a stale/wrong token. Adds a middleware that
logs method, path, device id (never the token), status, and wall time
for every /frame/* request.
2026-07-28 03:24:13 +00:00
tfaour 83994aab7b Add an admin-only server log viewer to the web UI
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 2m35s
Build and push server image / deploy (push) Successful in 51s
The root logger previously had no handler at all, so every module's
logger.info() call (user creation, claims, password resets, ...) was
silently dropped, not just unviewable. Adds a RotatingFileHandler
writing into the existing /data volume so log content also survives
container restarts/redeploys, plus /admin/logs (tail + line-count
picker + full-file download) alongside the existing Users & Frames
admin page.
2026-07-28 03:13:10 +00:00
tfaour 5866c2f040 Flatten page-level cards when installed as a standalone PWA
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 2m38s
Build and push server image / deploy (push) Successful in 51s
The boxed-card look reads as "still a website" once the app is
running full-screen off the home screen. Scoped to
display-mode: standalone so the regular browser-tab view is
untouched; dialog-internal cards keep their box since they group
subsections of one form rather than acting as page furniture.
2026-07-28 02:40:58 +00:00
tfaour dd038f8e46 Make the server installable as a home-screen PWA
Build and push server image / test (push) Successful in 36s
Build and push server image / build-and-push (push) Successful in 2m37s
Build and push server image / deploy (push) Successful in 57s
Adds a web manifest, hand-drawn cup+frame icons, and a presence-only
service worker (no offline caching) so mobile browsers offer
"Add to Home Screen" for the server UI.
2026-07-28 02:26:54 +00:00
tfaour 3fdda096a9 Smooth battery percent readings before computing drop-rate steps
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m36s
Build and push server image / deploy (push) Successful in 53s
A 1M-ohm divider (way over the ~10k source impedance the ESP32 ADC's
sample-and-hold expects) doesn't always misfire in isolation -- short
bursts of a few consecutive bad readings, and multi-reading drifts,
both slip past the existing step-level MAD outlier rejection since the
steps between two bad readings in the same burst look ordinary. Add a
Hampel-filter smoothing pass (local-neighborhood MAD, same statistical
approach as the existing outlier rejection) ahead of it.
2026-07-28 02:09:12 +00:00
tfaour 575b3cfa61 Add expected time to device status bar
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Successful in 2m38s
Build and push server image / deploy (push) Successful in 58s
2026-07-28 01:37:33 +00:00
tfaour aa4a382c1b Add "now displaying" / "up next" preview pair to the frame header
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m40s
Build and push server image / deploy (push) Successful in 57s
The server now records exactly what was last sent to the device on
every device-facing render (/frame/image, /frame/advance, /frame/back,
and the global hold actions), persisted as Frame.last_displayed_image/
_at and served back via GET /api/frames/{id}/now-displaying. The
header thumbnail is split into that frozen "now displaying" snapshot
and the existing live "up next" re-render, with an arrow between them
-- so editing a layout shows the change immediately on the right while
the left stays exactly what's actually on the panel until the device's
next real wake.
2026-07-28 00:41:11 +00:00
tfaour 684225422c Distinguish "staged, not yet applied" from "up to date" in firmware check
Build and push server image / test (push) Successful in 37s
Build and push server image / build-and-push (push) Successful in 2m36s
Build and push server image / deploy (push) Successful in 57s
update_available only compared the latest Gitea release against what's
staged, not what the frame is actually running -- so once a release
was staged (manually or via auto-update) but the frame hadn't woken up
and applied it yet, "Check now" reported "Up to date" even though the
device was still on the old version. Report the frame's actual running
version and use it to show a distinct "staged, applies on next wake"
message instead.
2026-07-27 22:55:40 +00:00
114 changed files with 5837 additions and 1189 deletions
+18 -1
View File
@@ -1,6 +1,6 @@
---
name: make-widget
description: Scaffold a new widget type for the espresso_frame server (the ~13-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
description: Scaffold a new widget type for the espresso_frame server (the ~14-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, saved-layout config allowlist, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
---
Adding a widget type is a very consistent, repeated pattern in this
@@ -114,6 +114,16 @@ Pick your template accordingly:
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
widget without it staying accurate.
14. **`app/routers/api_layouts.py`** -- add a `"<type>": (...)` entry to
`LAYOUT_CONFIG_FIELDS` listing the config columns that are an
authored *setting* (as opposed to runtime/cache state like a fetch
cache or queue position, which a saved layout deliberately leaves
out -- see the dict's own comment). Skipping this doesn't error or
warn anywhere: the widget just silently saves/applies with an empty
`{}` config forever, resetting to defaults on every layout apply or
hold-to-cycle. This actually shipped missing for the weather widget
-- caught only because a user noticed layout-cycling kept resetting
its city/mode.
## Tests (`server/tests/`)
@@ -138,6 +148,13 @@ Pick your template accordingly:
- Any pure-logic helper module (decoding, parsing -- like
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
DB, just the function.
- `test_saved_layouts.py` -- a `test_save_and_apply_round_trip_<type>_settings`
test: set every field the new `LAYOUT_CONFIG_FIELDS` entry lists,
save a layout, assert the `SavedLayoutWidget.config` snapshot has them
all, delete the frame's widgets, apply the layout back, assert the
new widget's config matches -- and that any runtime/cache field
(`checked_at`, a fetch cache, a queue) was *not* carried over. See
`test_save_and_apply_round_trip_weather_settings` for the pattern.
Run the full suite before calling it done:
@@ -11,6 +11,7 @@ mkdir -p "$SCRATCH"
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
CONFIG_PATH="$SCRATCH/config.json" \
LOG_PATH="$SCRATCH/app.log" \
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
> "$SCRATCH/server.log" 2>&1 &
PID=$!
+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 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
+236 -17
View File
@@ -6,10 +6,10 @@ weather/battery), like arranging icons on an Android home screen. A frame
can hold several widgets of the same type (e.g. two photo widgets pointed
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`,
@@ -148,6 +162,210 @@ grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible.
### "Modern" render style (experimental)
Every widget type except photos has a `render_style` column (`"classic"`
default | `"modern"`) that swaps its hand-drawn PIL primitives for an
HTML/CSS render: a Jinja2 template (`app/templates/widget_html/`) drawn
through a persistent headless-Chromium browser (`app/html_render.py`,
Playwright) instead of `ImageDraw` -- gradients, shadows, and soft icon
shading PIL can't easily do. Calendar's own modern-style builders (all
four view modes) live in `app/calendar_html_render.py` rather than
`html_render.py` itself, mirroring `calendar_render.py`'s own separation
from the simpler widget types.
Every modern-style builder runs its own `ordered_dither` (Bayer/ordered,
not Floyd-Steinberg) before returning, committing the widget to exact
palette colors *before* compositing -- safe to mix with photo/other
classic-rendered widgets on the same frame without a Floyd-Steinberg
seam at the boundary, because ordered dithering has no cross-pixel error
term the way Floyd-Steinberg's diffusion does (see `html_render.py`'s
module docstring). No `Frame`-level dithering setting was needed to make
this work.
Not offered for the **photos** widget -- a real photograph isn't a
synthesized dashboard card, and photos has a different concern instead:
its own independent palette/dithering strength (`Frame.photo_palette_rgb`
/ `photo_dither_strength`, a second "Photos configuration" card in
Advanced Configuration, separate from the main `palette_rgb`/
`dither_strength` every other widget uses). `widgets/photos.py`'s
`render()` quantizes itself against these before returning, so a frame
can tune the rest of its widgets' look (e.g. a calibrated palette for
modern-style dashboard widgets) independently of what actually looks
best for real photographs, with no `render_panel` changes needed --
see that module's own docstring for the one small, accepted edge case
(a border on a photos widget whose palette genuinely diverges from the
frame's main one).
Playwright/Chromium is a real, heavyweight runtime dependency imported
lazily only when a widget actually uses modern style. Its browser binary
is fetched by `start.sh` at container startup rather than baked into the
image (see `server/Dockerfile`'s own comment) -- a single ~181MB
`chrome-headless-shell` binary can't be split across Docker layers the
way this project's pip/npm installs were, and confirmed-failed to push
to the registry as a build-time layer; cached on the `/data` volume
(`PLAYWRIGHT_BROWSERS_PATH`) so only the very first boot on a fresh
volume actually downloads it. Still real-panel-unverified -- treat every
"modern" style as experimental regardless of deploy status.
Per-widget-type notes:
- **weather**: `current`/`daily` modes only -- `hourly`/`multi_city`
always render classic regardless of this setting (see the Weather
widget section below).
- **calendar**: all four view modes (agenda/today_tomorrow/week/month)
have a modern builder -- the only widget type with full modern-style
coverage from the start, rather than a partial rollout like weather's.
Month view's "falls back to agenda below a size threshold" behavior
(`_month_view_fits`) is honored identically in both styles.
- **battery/text/tasks**: full coverage (both battery modes; text reuses
its own `_fit()` shrink-to-fit sizing logic, only the drawing differs).
- **static image/whiteboard**: modern style is the *first* visual chrome
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. 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
`Frame.theme` (String, default `"classic"`, one Advanced Configuration
`<select>`) picks a curated visual preset for every modern-style widget
on that frame -- font family, corner radius, drop shadow, and an accent
hue for widgets with a header/accent region. Presets live in
`app/theme_tokens.py`'s `THEMES` dict; `resolve_theme(theme_name,
widget_kind, palette_rgb)` turns one into concrete, ready-to-render
values (`accent_hex`/`accent_hex_dark`, resolved `font_regular`/
`font_bold` file paths, `radius`, `shadow`, `accent_amplitude`). Inspired
by [Tesserae](https://github.com/dmellok/tesserae)'s (AGPL-3.0) own
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 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
chips, and text's user-authored inline run colors are status/identity
signals, not style choices -- no theme may recolor them, and every
`build_*`/`resolve_theme` call site that touches those stays on its own
existing logic untouched. Text's own per-widget `font_family` setting
(a user's explicit content-level choice, same carve-out reasoning) is
similarly never overridden by a theme -- `build_text` accepts a
`theme_name` param for signature uniformity with every other modern-
style builder but deliberately ignores it.
**Rich accent hues, not just the 6 exact panel inks.** A theme's
`accent_hex` can be any arbitrary color (e.g. terracotta, moss, slate) --
`html_render.ordered_dither_regions(rendered, palette_rgb,
base_amplitude, accent_regions=[(rect, amplitude), ...])` dithers the
whole widget at the existing safe default (`ordered_dither`'s tuned 48,
unchanged, still icon/text-legible) and then *separately* re-dithers
just the accent rectangle (a header bar's already-computed pixel rect)
at a theme's higher `accent_amplitude` (~130) and pastes it back. Safe
to do per-region for the same reason `ordered_dither` itself is safe
per-widget: ordered (Bayer) dithering has no cross-pixel error term, so
a region's result depends only on its own pixels. A single higher
amplitude applied to the *whole* widget instead was tried and rejected --
it washes out pale content (a weather icon's white cloud body nearly
vanished in testing); confining the higher amplitude to just the accent
rect avoids that while still letting the rect approximate a rich hue via
denser stippling instead of flatly snapping to one nearest ink (what
happens to a rich hue at the base amplitude).
**"classic" is a deliberately no-visual-change default.** Its
`accent_hex` is `None`, meaning "keep this widget kind's own pre-theme
look exactly": weather's header was always a fixed blue gradient (now
`theme_tokens._CLASSIC_WEATHER_GRADIENT`, byte-identical to the old
module-level `ACCENT_START`/`ACCENT_END` constants this system
replaced); tasks/calendar's header was always a flat single ink resolved
through `panel_style.THEME` (still is, just via `resolve_theme` now).
Widget kinds with no ink of their own (battery/text/static/whiteboard)
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 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
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
@@ -269,6 +487,11 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
side -- the calendar widget's embedded strip, as a standalone
widget's whole content instead of a strip above an agenda day.
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
| `"modern"`, experimental) -- see "Modern render style" above; weather's
own modern coverage is `current`/`daily` only, `hourly`/`multi_city`
always render classic regardless of this setting.
`current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
@@ -340,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
+52
View File
@@ -36,9 +36,61 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# EXPERIMENTAL. System libs a headless Chromium needs (app/html_render.py, the weather widget's
# opt-in "modern" render style), trimmed from Playwright's own full
# `install-deps chromium` list to just what a headless (no Xvfb),
# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a
# *headed* browser) and the CJK/Cyrillic/Thai locale font packages
# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf,
# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) --
# fonts-noto-color-emoji is the one that actually matters here (real
# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome
# fallback glyphs in this feature's original spike).
RUN apt-get update && apt-get install -y --no-install-recommends \
libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \
libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \
libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \
libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \
fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Split across several layers rather than one `pip install -r
# requirements.txt` -- same Cloudflare single-blob/layer payload-size
# limit as render-service's npm installs below. The single combined
# layer was measured at ~113MB unpacked, over the limit on its own.
# Isolating the largest packages gets every layer's unpacked size well
# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the
# remaining `-r requirements.txt` layer ~71MB). Each package version here
# still comes from requirements.txt (`pip install -r` for everything that
# doesn't need its own layer skips these, since pip sees them already
# satisfied); the explicit versions below just control *when* each
# installs -- same "single source of truth, just splitting *when* it
# installs" tradeoff as the npm section's --no-save comment below.
RUN pip install --no-cache-dir sqlalchemy==2.0.51
RUN pip install --no-cache-dir pillow==12.3.0
RUN pip install --no-cache-dir pypdfium2==5.12.1
RUN pip install --no-cache-dir playwright==1.61.0
RUN pip install --no-cache-dir -r requirements.txt
# The headless Chromium binary itself is deliberately NOT installed
# here at build time. `playwright install chromium-headless-shell`
# unpacks to ~262MB, and its single `chrome-headless-shell` binary alone
# (measured: 181MB) is one file -- unlike the pip/npm splits above
# (independently-installable smaller packages moved into their own
# layers), a single 181MB file can't be divided across multiple <100MB
# Docker layers by any ordinary COPY/RUN restructuring; the whole file
# lands in whichever layer's diff contains it, which confirmed-failed
# to push to this project's registry (the same Cloudflare single-blob/
# layer limit that forced the pip/npm splits elsewhere in this file --
# see their comments). Fix: start.sh downloads it at container startup
# instead, cached on the /data volume (PLAYWRIGHT_BROWSERS_PATH below)
# so it survives restarts/redeploys and only ever downloads once per
# volume, not once per image layer. Trade-off: first boot on a fresh
# volume needs network access to Playwright's CDN -- true of Immich/
# weather API access too, so not a new requirement for this server.
ENV PLAYWRIGHT_BROWSERS_PATH=/data/.playwright-browsers
# render-service/'s dependencies installed as several separate layers
# rather than one `npm install` covering all of them -- a from-scratch
# push of this image once hit Cloudflare's payload-size limit on a
+17 -12
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
@@ -91,11 +88,19 @@ algorithm itself -- it just streams the response straight to the panel.
again until a recharge is detected and it crosses again. No SMTP
configured, or no email on the relevant account, and both features
silently no-op rather than erroring.
- **Server logs.** `/admin/logs` shows the tail of the process's own
log file (`LOG_PATH` env var, default `/data/server.log` -- the same
`/data` volume as the database and legacy config, so it survives
container restarts/redeploys; `LOG_LEVEL` env var, default `INFO`).
Rotates at ~2MB x 3 backups; the page only reads the current file,
"Download full log" streams it raw. There's no log shipping/
aggregation beyond this -- it's a single-container deployment, so
the file *is* the log.
## Endpoints
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
`/admin`, `/admin/logs`, `/frames/{id}` (Photos), `/frames/{id}/config`,
`/frames/{id}/stats`, `/m/{manage_token}`.
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
@@ -206,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,
@@ -226,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()
+347
View File
@@ -0,0 +1,347 @@
"""Calendar widget's "modern" render style -- all four view modes
(agenda/today_tomorrow/week/month), mirroring calendar_render.py's own
_build dispatch shape exactly so app/widgets/calendar.py and the
calendar preview endpoint can call either module identically. Kept in
its own module rather than joining app/html_render.py's other build_*
functions, mirroring calendar_render.py's own separation from the
simpler widgets (calendar is the one case where html_render.py growing
a 5th unrelated builder starts to hurt readability).
Reuses calendar_render's own private helpers (_events_on_day/_event_
colors/_event_start/_fmt_time/_weather_for_day/_month_view_fits/
_add_months) so a modern-style view's event list/colors/times/weather/
month-grid math match the classic renderer's data exactly -- only the
drawing differs, same relationship weather's build_current/build_daily
have with weather_render.py."""
from __future__ import annotations
import calendar as calendar_module
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from PIL import Image
from . import html_render, panel_style, theme_tokens
from .calendar_render import (
MARGIN,
WEEKDAY_NAMES,
_add_months,
_event_colors,
_event_start,
_events_on_day,
_fmt_time,
_month_view_fits,
_weather_for_day,
)
def _weather_row(weather_cities, day, units) -> list[dict]:
entries = _weather_for_day(weather_cities, day)
return [
{"emoji": html_render.CATEGORY_EMOJI.get(e["category"], ""), "high": round(e["high"]), "low": round(e["low"])}
for e in entries
]
def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb, weather_cities,
weather_units: str, owners_seen: list[str], rows_avail_h: int, row_h: int) -> dict:
"""One day's {header, weather_entries, rows, more_count} -- shared by
build_agenda/build_today_tomorrow/build_week's vertical layout, same
reuse relationship calendar_render._draw_agenda_day has with
_build_agenda/_build_today_tomorrow. `rows_avail_h` is the *rows*
area's own pixel budget only -- the caller has already reserved a
separate, uniform header_h (which is where weather actually renders,
see the day-header macro) for every section, so this function
doesn't need to account for weather space itself."""
header = day.strftime("%A, %B ") + str(day.day)
weather_entries = _weather_row(weather_cities, day, weather_units)
max_rows = max(0, rows_avail_h // row_h)
day_events = _events_on_day(events, day, tz)
rows = []
for event in day_events[:max_rows]:
colors = _event_colors(event, owners_seen, palette_rgb)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
rows.append({"colors": [html_render._rgb_to_hex(c) for c in colors], "time": time_str,
"summary": event["summary"]})
return {"header": header, "weather_entries": weather_entries, "rows": rows,
"more_count": max(0, len(day_events) - max_rows)}
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,
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 = 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 = 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,
target_h - header_h - MARGIN, row_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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
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 + accent_h)
return html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
)
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,
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). 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 = 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 = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
owners_seen: list[str] = []
days = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h)
for d in day_dates
]
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,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
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 + accent_h),
theme["accent_amplitude"])
for i in range(len(days))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
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, 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
columns) layouts. Each header (per-section or per-column) dithers
richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER
today = datetime.now(tz).date()
if days == 7:
days_since_start = (today.weekday() - week_start) % 7
week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
else:
week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
owners_seen: list[str] = []
if layout == "vertical":
section_h = target_h // 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 = 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)
for d in day_dates
]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render(
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_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 + 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 = 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 + 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):
day = week_first_day + timedelta(days=i)
label = day.strftime("%a %-d") if day != today else f"{day.strftime('%a %-d')}"
weather_entries = _weather_row(weather_cities, day, weather_units)
day_events = _events_on_day(events, day, tz)
rows = []
for event in day_events[:max_rows]:
color = html_render._rgb_to_hex(_event_colors(event, owners_seen, palette_rgb)[0])
summary = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
rows.append({"color": color, "summary": summary})
cols.append({
"label": label, "weather": weather_entries[0] if weather_entries else None,
"rows": rows, "more_count": max(0, len(day_events) - max_rows),
})
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
html = template.render(
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, 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 + 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,
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.
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()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks_dates = list(
calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month)
)
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
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 = []
for week in weeks_dates:
row = []
for day in week:
day_events = _events_on_day(events, day, tz)
dots = [html_render._rgb_to_hex(_event_colors(e, owners_seen, palette_rgb)[0]) for e in day_events[:4]]
row.append({
"day_num": day.day, "in_month": day.month == target_month.month,
"is_today": day == today, "dots": dots, "more_count": max(0, len(day_events) - 4),
})
weeks.append(row)
template = html_render._jinja_env.get_template("calendar_month.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
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)
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(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, 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
modern view instead of erroring or silently reverting to classic."""
effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h):
effective_view = "agenda"
if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
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, 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, font_scale)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)
+168 -135
View File
@@ -11,7 +11,7 @@ Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time)
events into one, see _event_colors/_draw_color_bar below.
events into one, see _event_colors/panel_style.draw_color_chip below.
"""
from __future__ import annotations
@@ -26,6 +26,7 @@ from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
_apply_manage_overlay,
@@ -42,12 +43,20 @@ CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (t
"week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
MARGIN = 20
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned). BG/FG are this module's own plain black/white -- checkbox
# outlines, month-view grid hairlines -- not a text-emphasis concern (no
# MUTED gray here anymore -- see panel_style's module docstring for why:
# a mid-gray fill has no close palette match and dithers into speckle
# once the whole canvas is quantized. Secondary text now reads through
# size/weight alone, always exact black).
MARGIN = panel_style.CONTENT_MARGIN
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
# Was a light gray, but that dithers away to near-invisible once quantized
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
# Structural dividers/grid lines (between stacked day sections, week
# columns, month cells) stay a plain black rule -- gray dithers away to
# near-invisible once quantized to the 6-color e-ink palette. Headers
# no longer use this: see panel_style.draw_header_bar/theme_color.
RULE = (0, 0, 0)
# Fallback for any event whose calendar has no manually pinned color
@@ -67,7 +76,7 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
one person's calendar). Usually just one color; more than one is
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
_draw_color_bar. Each source's own manually pinned color
panel_style.draw_color_chip. Each source's own manually pinned color
(FrameCalendar.color_index -- see routers/api_widgets.py's
api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
@@ -91,24 +100,6 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
return colors
def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
colors: list[tuple[int, int, int]], radius: int) -> None:
"""One rounded bar for a single-source event, or that same overall
footprint split into equal-width side-by-side segments -- one per
contributing calendar -- for a deduplicated shared event (see
_event_colors/calendar_feed.merge_events). Splitting rather than
e.g. concentric rings keeps every color equally "thick and bold" at
a glance, the same design goal a single pinned color already has."""
if len(colors) == 1:
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
return
seg_w = (x1 - x0) / len(colors)
for i, color in enumerate(colors):
seg_x0 = round(x0 + i * seg_w)
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried
@@ -158,11 +149,12 @@ def _fmt_task_due(due: str | None) -> str:
return d.strftime("%b %-d")
# ImageFont.load_default() (used for everything else in this module --
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
# not supported". So event titles get drawn with two fonts: the normal
# Neither Inter (panel_style.font_bold/font_regular, this module's own
# body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
# default font has emoji glyphs, and PIL/FreeType don't skip an
# unsupported codepoint, they substitute a ".notdef" tofu box (a visible
# filled rectangle) -- reads as a rendering glitch, not "emoji not
# supported". So event titles get drawn with two fonts: the normal
# text font for everything else, and one of these for actual emoji runs
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
@@ -401,15 +393,17 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
these vertically without duplicating the row-layout/truncation
logic. Weather is drawn above the event list -- eating into the same
row budget the event count is truncated against, exactly like the
header/rule above it already does."""
header bar above it already does."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
header_h = title_font.size + 20
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
panel_style.theme_color("calendar", palette_rgb))
text_x0 = x0 + MARGIN
text_w = w - MARGIN * 2
header = day.strftime("%A, %B ") + str(day.day)
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
_truncate_to_width(draw, header, title_font, text_w), title_font, BG)
y = y0 + header_h + 12
weather_entries = _weather_for_day(weather_cities, day)
if weather_entries:
@@ -422,13 +416,13 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not day_events:
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
draw_text(img, (text_x0, y), "Nothing scheduled", body_font)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font)
break
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
prefix = f"{time_str} "
draw_text(img, (text_x0 + 18, y), prefix, body_font)
@@ -445,13 +439,13 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
(`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
default; the only widget type with its own on-panel title, since
it's the only one where "which list is this" isn't obvious from its
content the way a calendar/photo/whiteboard's is), then a color bar
(reusing _event_colors/_draw_color_bar as-is: a task dict's
top-level owner_display_name/color_index is exactly _event_colors'
single-source fallback shape, since caldav_client.merge_tasks
doesn't cross-list-dedup tasks into a "sources" list the way
merge_events dedups events) + checkbox glyph + due date (if any) +
summary per task, same header/rule/row-cap/truncation shape as
content the way a calendar/photo/whiteboard's is), then a color chip
(reusing _event_colors/panel_style.draw_color_chip as-is: a task
dict's top-level owner_display_name/color_index is exactly
_event_colors' single-source fallback shape, since caldav_client.
merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
way merge_events dedups events) + checkbox glyph + due date (if any)
+ summary per task, same header/row-cap/truncation shape as
_draw_agenda_day's event list so the standalone tasks widget (see
_build_tasks) reads as the same consistent design as everything
else on-panel, not a bolted-together look. Reuses _draw_mixed_line
@@ -460,45 +454,52 @@ def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int,
Outstanding tasks get an empty checkbox; completed ones (only ever
present when TaskWidgetConfig.show_completed is on -- see
caldav_client.fetch_tasks' completed_since) get a filled one and
muted text, no due-date prefix (irrelevant once done)."""
caldav_client.fetch_tasks' completed_since) get a filled checkbox in
this widget's own Green accent (see panel_style.THEME) -- that fill
is the "done" signal, no due-date prefix (irrelevant once done) and
no separate muted text treatment (see module-level MUTED removal
note above _event_colors)."""
x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
header_h = title_font.size + 20
panel_style.draw_header_bar(draw, (x0, y0, w, header_h), header_h,
panel_style.theme_color("tasks", palette_rgb))
text_x0 = x0 + MARGIN
text_w = w - MARGIN * 2
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
_truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
y = y0 + header_h + 12
row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks:
draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
draw_text(img, (text_x0, y), "Nothing outstanding", body_font)
return
owners_seen: list[str] = []
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
for i, task in enumerate(tasks):
if i >= max_rows:
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font)
break
done = task.get("completed_at") is not None
colors = _event_colors(task, owners_seen, palette_rgb)
_draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
panel_style.draw_color_chip(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors)
box = body_font.size - 6
box_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5
box_r = min(panel_style.CHIP_RADIUS, box // 2)
if done:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], fill=FG)
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, fill=checkbox_fill)
else:
draw.rectangle([box_x, box_y, box_x + box, box_y + box], outline=FG, width=2)
draw.rounded_rectangle([box_x, box_y, box_x + box, box_y + box], radius=box_r, outline=FG, width=2)
text_x = box_x + box + 10
due_str = None if done else _fmt_task_due(task.get("due"))
prefix = f"{due_str} " if due_str else ""
if prefix:
draw_text(img, (text_x, y), prefix, body_font, MUTED)
draw_text(img, (text_x, y), prefix, body_font)
prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
_draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
body_font, text_w - (text_x - text_x0) - prefix_w, fill=MUTED if done else FG)
body_font, text_w - (text_x - text_x0) - prefix_w)
y += row_h
@@ -511,18 +512,19 @@ _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:
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
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_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
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)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = []
_draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
_draw_agenda_day(img, draw, day, events, tz, region, title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
@@ -533,30 +535,31 @@ _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
shifts the whole two-day window together, same "days" unit
_build_agenda already uses, so NEXT/BACK behaves identically across
both views."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
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_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
weather_font = ImageFont.load_default(size=weather_size)
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)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
section_h = ch // 2
owners_seen: list[str] = []
for i in range(2):
section_y0 = i * section_h
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
(0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
palette_rgb, weather_cities, weather_font, weather_units)
return img
@@ -573,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
@@ -585,8 +588,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_widgets.py's api_widget_config_save)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date()
@@ -599,36 +601,38 @@ 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 = ImageFont.load_default(size=max(14, title_base - days))
body_font = ImageFont.load_default(size=max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days))
section_h = target_h // 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 = i * section_h
section_y0 = cy0 + i * section_h
if i > 0:
draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
day = week_first_day + timedelta(days=i)
_draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
_draw_agenda_day(img, draw, day, events, tz, (cx0, section_y0, cw, section_h),
title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units)
return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_font = ImageFont.load_default(size=header_size)
chip_font = ImageFont.load_default(size=chip_size)
weather_font = ImageFont.load_default(size=weather_size)
col_w = (target_w - MARGIN * 2) // days
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)
col_w = (cw - MARGIN * 2) // days
header_h = 44
for col in range(days):
day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w
x0 = cx0 + MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = MARGIN + header_h
y = cy0 + MARGIN + header_h
# Columns are narrow, so only what actually fits gets drawn (see
# weather_render.draw_weather_row) -- typically one city, no label
# (the column itself makes which day it's for obvious; a city name
@@ -640,14 +644,14 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb)
row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h)
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font)
break
colors = _event_colors(event, owners_seen, palette_rgb)
_draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
panel_style.draw_color_chip(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
if event["all_day"]:
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
else:
@@ -669,42 +673,65 @@ _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."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
"Not in this month" day numbers used to be a muted gray -- now
de-emphasized by weight instead (Regular vs. Bold), same reasoning
as everywhere else this module dropped MUTED -- see module-level
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_font = ImageFont.load_default(size=header_size)
day_font = ImageFont.load_default(size=day_size)
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)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
col_w = (target_w - MARGIN * 2) // 7
col_w = (cw - MARGIN * 2) // 7
header_h = 28
grid_top = MARGIN + header_h
row_h = (target_h - MARGIN - grid_top) // len(weeks)
grid_top = cy0 + MARGIN + header_h
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
today_accent = panel_style.theme_color("calendar", palette_rgb)
today_badge_r = min(panel_style.CHIP_RADIUS, 9)
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names):
draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font)
owners_seen: list[str] = []
dot_r = 6
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = MARGIN + col * col_w
x0 = cx0 + MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
text_color = FG if in_month else MUTED
if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
# A filled accent badge (this widget's own theme color,
# see panel_style.THEME) instead of the old bare outline
# -- an actual "today" indicator, not just an outline
# easy to miss at ~24px. Sized around the actual digit
# bbox (not a fixed pixel box) so a bold 2-digit day
# number ("30") fits as comfortably as a single digit
# ("3") at every size tier.
day_str = str(day.day)
text_x, text_y = x0 + 6, y0 + 4
dbbox = draw.textbbox((text_x, text_y), day_str, font=day_font_in_month)
pad = 3
badge_rect = [dbbox[0] - pad, dbbox[1] - pad, dbbox[2] + pad, dbbox[3] + pad]
badge_r = min(today_badge_r, (badge_rect[3] - badge_rect[1]) // 2)
draw.rounded_rectangle(badge_rect, radius=badge_r, fill=today_accent)
draw_text(img, (text_x, text_y), day_str, day_font_in_month, BG)
else:
day_font = day_font_in_month if in_month else day_font_out_of_month
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
@@ -718,7 +745,7 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
dot_x += dot_r * 2 + 5
if len(day_events) > 4:
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font)
return img
@@ -731,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):
@@ -739,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
@@ -753,14 +780,19 @@ 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:
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)
# Drawn as a final overlay onto the already-composited img (not
# inside any one _build_* branch above), so it offsets by
# panel_style.GUTTER itself to land inside the same visible
# margin every builder's own content already respects.
font = panel_style.font_regular(14 if _size_tier(target_w, target_h) != "small" else 11)
draw_text(img, (panel_style.GUTTER + MARGIN, target_h - panel_style.GUTTER - MARGIN - font.size),
fetch_summary, font)
return img
@@ -790,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()
@@ -811,16 +843,17 @@ _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 = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size)
body_font = ImageFont.load_default(size=body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title)
img, draw, region = panel_style.card_canvas(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)
return img
@@ -837,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()
+666
View File
@@ -0,0 +1,666 @@
"""Experimental "modern" render style, offered as an opt-in alternative
to several widget types' hand-drawn PIL primitives: Jinja2 + a
persistent headless Chromium browser (Playwright) -- see docs/widgets.md
for the design rationale (gradients/shadows/soft shading that PIL can't
easily do, at the cost of a real browser-process dependency). Everything
in this module is shared infrastructure (the persistent browser, ordered
dithering) plus one `build_*` function per widget type that has a
modern-style builder -- battery/text/tasks/static image/whiteboard live
here directly (mirroring how those widget types are themselves "inlined"
in their own widget.py rather than getting a dedicated render module);
calendar's (all four view modes, see calendar_html_render.py) is the one
exception, kept separate the same reason calendar_render.py itself is
its own 800+ line file rather than joining battery/text/tasks inline.
Not offered for the photos widget -- a real photograph isn't a
synthesized dashboard card, and photos has its own separate palette/
dithering concern instead (see widgets/photos.py).
Two things this module owns that nothing else in the codebase needed
before:
1. A **persistent** background browser process. Widget rendering already
happens concurrently across a fresh `ThreadPoolExecutor` per frame
request (routers/device.py's _render_widgets) -- Playwright's sync
API is thread-affine (an object must be used from the thread that
created it), so a single browser object can't be handed across those
ad-hoc worker threads, and relaunching a full Chromium process on
every widget render would be real, avoidable latency. Fix: one
background thread runs its own persistent asyncio event loop hosting
one long-lived `Browser`, lazily started on first use (see start()) --
not eagerly at server startup, so a deployment that never enables the
weather widget's "modern" style never launches Chromium at all and
never needs Playwright's browser binaries installed. main.py's
lifespan only wires up the *shutdown* half (stop()), so a clean
server restart doesn't leave an orphaned Chromium process behind if
this was ever actually used. render_html_to_image() is a plain sync
function any worker thread can call, bridging in via
`asyncio.run_coroutine_threadsafe` (the standard safe cross-thread
entry point into a *running* loop on another thread).
2. **Per-region ordered (Bayer) dithering against the palette**, done
here rather than in the shared image_pipeline.py pipeline.
render_panel's whole-canvas single Floyd-Steinberg pass exists
because Floyd-Steinberg's error diffusion can't be split across
independently-quantized regions without a visible seam at the
boundary -- but that reasoning doesn't apply to ordered dithering,
which has no cross-pixel error term (each pixel's dither decision
only depends on its own position + color). So this module dithers its
own rendered widget to *already-exact* palette colors before
returning it; the later shared Floyd-Steinberg pass sees zero
quantization error there and leaves it untouched -- the same
"pre-commit to exact palette colors" trick image_pipeline.draw_text
and the hand-drawn weather icons already rely on, just reached a
different way. Floyd-Steinberg keeps working exactly as before for
photos and every other (classic-rendered) widget region.
"""
from __future__ import annotations
import asyncio
import io
import threading
from datetime import date
from pathlib import Path
import numpy as np
from jinja2 import Environment, FileSystemLoader, select_autoescape
from PIL import Image
from . import panel_style, theme_tokens
from .image_pipeline import DEFAULT_PALETTE_RGB, hex_to_rgb
_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html"
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
_jinja_env = Environment(
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
autoescape=select_autoescape(["html", "jinja"]),
)
CATEGORY_EMOJI = {
"clear": "☀️",
"partly_cloudy": "",
"cloudy": "☁️",
"fog": "\U0001f32b",
"rain": "\U0001f327",
"snow": "❄️",
"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)
def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
"""A darker shade of `rgb` for a CSS gradient's second stop -- purely
decorative (ordered_dither commits everything to exact palette colors
regardless of which literal hex a gradient starts from)."""
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
_loop_thread: threading.Thread | None = None
_browser = None
_playwright_cm = None
_start_lock = threading.Lock()
async def _launch_browser() -> None:
global _browser, _playwright_cm
from playwright.async_api import async_playwright
_playwright_cm = async_playwright()
playwright = await _playwright_cm.__aenter__()
_browser = await playwright.chromium.launch()
async def _close_browser() -> None:
global _browser, _playwright_cm
if _browser is not None:
await _browser.close()
_browser = None
if _playwright_cm is not None:
await _playwright_cm.__aexit__(None, None, None)
_playwright_cm = None
def start() -> None:
"""Launches the background event loop + persistent Chromium browser,
if not already running. Called lazily by render_html_to_image on
first use (not from main.py's lifespan -- see module docstring for
why this must stay opt-in) -- exposed directly too, for tests that
want to control startup explicitly. Idempotent -- a second call
while already started is a no-op."""
global _loop, _loop_thread
if _loop is not None:
return
ready = threading.Event()
def _run() -> None:
global _loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_loop = loop
ready.set()
loop.run_forever()
_loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop")
_loop_thread.start()
ready.wait()
asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result()
def stop() -> None:
"""Closes the browser and stops the background loop -- called from
main.py's lifespan shutdown so a server restart never leaves an
orphaned Chromium process behind. No-op if start() was never called
(the common case: most deployments never enable "modern" style)."""
global _loop, _loop_thread
if _loop is None:
return
asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result()
_loop.call_soon_threadsafe(_loop.stop)
_loop_thread.join(timeout=5)
_loop = None
_loop_thread = None
async def _screenshot(html: str, target_w: int, target_h: int) -> bytes:
page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1)
try:
await page.set_content(html, wait_until="networkidle")
return await page.screenshot()
finally:
await page.close()
def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image:
"""Renders `html` (already sized to target_w x target_h via its own
<style>) through the persistent headless Chromium browser and
returns an RGB image of exactly that size. Safe to call from any
thread -- bridges into the dedicated background asyncio loop via
run_coroutine_threadsafe. Lazily calls start() on first use (see its
docstring) -- the first "modern" style render on a freshly-started
server pays Chromium's launch latency; every render after that reuses
the same persistent browser."""
if _loop is None:
with _start_lock:
if _loop is None:
start()
future = asyncio.run_coroutine_threadsafe(_screenshot(html, target_w, target_h), _loop)
png_bytes = future.result()
return Image.open(io.BytesIO(png_bytes)).convert("RGB")
# --- Ordered (Bayer 8x8) dithering against an arbitrary palette ---------
_BAYER8 = (
np.array(
[
[0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
],
dtype=np.float32,
)
/ 64.0
- 0.5
)
def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float = 48.0) -> Image.Image:
"""Bayer-ordered dither of `img` against `palette_rgb` (falls back to
DEFAULT_PALETTE_RGB) -- every output pixel is one of the palette's
exact colors, spatially patterned rather than error-diffused, so it's
safe to run per-region before compositing (see module docstring for
why that's not true of Floyd-Steinberg). `amplitude` is the Bayer
bias's full swing in 0-255 RGB units before nearest-palette-color
matching -- 48 was the value this render style was tuned against in
the exploratory spike behind this feature; not exposed as a per-frame
setting (unlike dither_strength) since there's only one consumer of
it today."""
palette = np.array(palette_rgb or DEFAULT_PALETTE_RGB, dtype=np.float32)
arr = np.asarray(img.convert("RGB"), dtype=np.float32)
h, w, _ = arr.shape
tile = np.tile(_BAYER8, (h // 8 + 1, w // 8 + 1))[:h, :w]
biased = np.clip(arr + tile[:, :, None] * amplitude, 0, 255)
diffs = biased[:, :, None, :] - palette[None, None, :, :]
dists = np.einsum("hwkc,hwkc->hwk", diffs, diffs)
idx = np.argmin(dists, axis=2)
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
def ordered_dither_regions(rendered: Image.Image, palette_rgb: list | None, base_amplitude: float = 48.0,
accent_regions: list[tuple[tuple[int, int, int, int], float]] = ()) -> Image.Image:
"""Like `ordered_dither`, but lets specific rectangles (e.g. a themed
header bar) dither at a higher amplitude than the rest of the widget.
A single higher amplitude applied to a whole widget washes out pale
content (a weather icon's white cloud body nearly disappeared in
testing); dithering the base image at the safe default and only
re-dithering an accent rect on top -- pasted back over the base --
lets a header carry a rich, arbitrary accent hue (via denser
stippling) without touching icon/text legibility elsewhere. Safe to
do per-region for the same reason `ordered_dither` is safe per-widget
(see its docstring): no cross-pixel error-diffusion term, so each
region's result depends only on its own pixels."""
base = ordered_dither(rendered, palette_rgb, amplitude=base_amplitude)
for (x0, y0, x1, y1), amplitude in accent_regions:
crop = rendered.crop((x0, y0, x1, y1))
base.paste(ordered_dither(crop, palette_rgb, amplitude=amplitude), (x0, y0))
return base
# --- Weather "modern" style ----------------------------------------------
def _day_label(day_date: date) -> str:
delta = (day_date - date.today()).days
if delta == 0:
return "Today"
if delta == 1:
return "Tomorrow"
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).
"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"
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, pad=round(pad),
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
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)
def build_daily(daily: dict[str, dict], 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_daily -- same
call signature. Returns an already-palette-exact RGB image (see
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:
return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
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)),
"emoji": CATEGORY_EMOJI.get(d["category"], ""),
"high": round(d["high"]),
"low": round(d["low"]),
}
for day_str, d in days
]
template = _jinja_env.get_template("weather_daily.html.jinja")
html = template.render(
w=target_w, h=target_h, pad=pad, col_gap=col_gap,
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
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 not city_label:
return ordered_dither(rendered, palette_rgb)
accent_rect = (pad, pad, target_w - pad, pad + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
SUPPORTED_MODES = ("current", "daily")
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""Dispatches to build_current/build_daily -- mirrors weather_render.
build()'s signature (minus interval_hours, which no modern-style mode
uses) so app/widgets/weather.py and the weather preview endpoint can
call either module identically. Only call this for mode in
SUPPORTED_MODES -- callers are expected to have already fallen back to
weather_render.build() for hourly/multi_city (see weather.py)."""
if mode == "current":
return build_current(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
return build_daily(data, target_w, target_h, palette_rgb, units, city_label, theme_name)
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "",
theme_name: str | None = None) -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see
ordered_dither), so the final _quantize pass here is a no-op on it,
same reasoning as the module docstring's compositing story."""
from .image_pipeline import _quantize, _png_bytes, logical_render_size
target_w, target_h = logical_render_size(orientation)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
# --- Battery "modern" style ------------------------------------------------
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
base = min(target_w, target_h)
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, "gap": gap, "total": total}
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of widgets/battery.py's classic PIL
drawing -- same icon+percent+caption-lines shape, `lines` already
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 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."""
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)
while sizes["total"] > avail_h and scale > 0.3:
scale -= 0.05
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
# Extreme case (a 1x1-grid-cell-sized widget in "detailed" mode):
# scale bottomed out and it still doesn't fit -- drop the least
# important line rather than render overlapping text, same
# graceful-degradation idiom the classic PIL path's own
# `if y + small_font_size > ...: break` truncation already uses.
while sizes["total"] > avail_h and lines:
lines = lines[:-1]
num_lines = len(lines)
sizes = _battery_sizes(target_w, target_h, num_lines, scale)
theme = theme_tokens.resolve_theme(theme_name, "battery", palette_rgb)
fill_color = panel_style.battery_fill_color(percent, palette_rgb)
icon_h = sizes["icon_h"]
icon_w = int(icon_h * 1.8)
stroke = max(2, icon_h // 12)
nub_w = max(3, icon_w // 10)
template = _jinja_env.get_template("battery.html.jinja")
html = template.render(
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"], line_gap=sizes["gap"],
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
# --- Text "modern" style ---------------------------------------------------
def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = None,
theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of widgets/text.py's classic PIL
drawing. Reuses widgets/text.py's own `_fit()` for the one piece of
logic CSS has no native equivalent for (shrink-to-fit sizing) --
`_fit` measures against the exact same vendored font files via PIL,
so the resolved size is a real fit decision, not a guess -- but lets
the browser do its own text wrapping/line-breaking at that size
(paragraphs/runs passed through directly as HTML) rather than
replicating `_fit`'s own word-wrapped line list; the two wrapping
algorithms can disagree on exact break points, an acceptable
approximation since this style only needs to look good and fit
reasonably, not be pixel-identical to classic. Returns an already-
palette-exact RGB image (see ordered_dither).
theme_name is accepted (every modern-style build_* function takes
one, threaded uniformly from frame.theme) but deliberately unused --
the text widget's own font_family is a per-widget, user-authored
choice (see widgets/text.py's module docstring), same carve-out
reasoning as run-level colors; a frame theme overriding it would
silently undo an explicit user choice. text.html.jinja also has no
card chrome (no radius/shadow) for a theme to touch."""
from PIL import ImageDraw
from . import widgets # local import: heavy-ish, and only "modern" text needs it
text_widget = widgets.text
bg_rgb = (hex_to_rgb(cfg.background_color) if cfg.background_color else None) or (255, 255, 255)
family = cfg.font_family if cfg.font_family in text_widget.FONT_FAMILIES else text_widget.DEFAULT_FONT_FAMILY
paragraphs = cfg.content or []
margin = text_widget.MARGIN
max_width = max(10, target_w - 2 * margin)
max_height = max(10, target_h - 2 * margin)
measure_img = Image.new("RGB", (1, 1))
draw = ImageDraw.Draw(measure_img)
size, _lines = text_widget._fit(draw, paragraphs, family, cfg.font_size, max_width, max_height)
files = text_widget._FONT_FILES.get(family) or text_widget._FONT_FILES[text_widget.DEFAULT_FONT_FAMILY]
align = cfg.align if cfg.align in ("left", "center", "right") else "left"
template = _jinja_env.get_template("text.html.jinja")
html = template.render(
w=target_w, h=target_h, margin=margin, bg_color=_rgb_to_hex(bg_rgb),
size=size, line_height=text_widget.LINE_HEIGHT_FACTOR, align=align,
font_regular=str(_FONT_DIR / files[(False, False)]), font_bold=str(_FONT_DIR / files[(True, False)]),
font_italic=str(_FONT_DIR / files[(False, True)]), font_bold_italic=str(_FONT_DIR / files[(True, True)]),
paragraphs=paragraphs,
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
# --- 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, 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) 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)
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)
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] = []
rows = []
for task in tasks[:max_rows]:
colors = _event_colors(task, owners_seen, palette_rgb)
done = task.get("completed_at") is not None
due = None if done else (_fmt_task_due(task.get("due")) or None)
rows.append({
"colors": [_rgb_to_hex(c) for c in colors],
"done": done,
"due": due,
"summary": task["summary"],
})
more_count = max(0, len(tasks) - max_rows)
template = _jinja_env.get_template("tasks.html.jinja")
html = template.render(
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, 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 + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
# --- Static image / whiteboard "modern" style (shared) ---------------------
def build_framed_image(composed: Image.Image, target_w: int, target_h: int,
palette_rgb: list | None = None, theme_name: str | None = None,
widget_kind: str = "static") -> Image.Image:
"""Wraps an already-composed image (static_image.py/whiteboard.py's
own compose_into() output, exactly target_w x target_h, already
cropped/fit per that widget's own display_mode) in a rounded-corner,
shadowed card -- the first visual chrome either widget type has ever
had (both currently draw with zero chrome of their own). Theme-aware
for radius/shadow only -- no text/header content to accent or font.
Returns an already-palette-exact RGB image (see ordered_dither)."""
import base64
theme = theme_tokens.resolve_theme(theme_name, widget_kind, palette_rgb)
buf = io.BytesIO()
composed.convert("RGB").save(buf, format="PNG")
image_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
template = _jinja_env.get_template("framed_image.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
image_b64=image_b64,
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
+78 -20
View File
@@ -61,7 +61,8 @@ def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1:
pos += spacing
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None:
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int],
radius: int = 0) -> None:
"""Draws a border inset within img's own bounds, mutating it in
place -- called once per widget's own region (routers/device.py's
_render_widgets, and each widget type's own dialog preview) before
@@ -74,24 +75,44 @@ def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tupl
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
just inside the image's edge; "fancy" is two thinner concentric
strokes with a gap between them, picture-frame-mat style. "none" (or
a non-positive thickness) draws nothing."""
a non-positive thickness) draws nothing. `radius` is opt-in and only
honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) --
"dashed"/"dotted" trace each of the 4 edges as independent straight
segments (see _dashed_edge/_dotted_edge) and ignore it, a documented
limitation rather than a bug. Defaults to 0 (unchanged sharp-corner
behavior) and no call site passes non-zero today -- this ships the
capability for a future per-widget "rounded border" setting without
changing default behavior anywhere (see tests/test_widget_border.py's
exact-corner-pixel assertions)."""
if style == "none" or thickness <= 0:
return
w, h = img.size
t = max(1, min(int(thickness), min(w, h) // 2))
draw = ImageDraw.Draw(img)
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
if style == "fancy":
line_t = max(1, t // 3)
gap = max(2, t - 2 * line_t)
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
if r:
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t)
else:
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
inset = line_t + gap
if w - 2 * inset > 1 and h - 2 * inset > 1:
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0
if inner_r:
draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r,
outline=color, width=line_t)
else:
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
return
if style == "solid":
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
if r:
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t)
else:
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
return
# dashed/dotted trace the same centered-on-the-edge path solid/
@@ -166,6 +187,27 @@ DEFAULT_PALETTE_RGB = [
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
# A community-measured alternative starting point for the same 6 slots,
# ported (data only, not code) from paperlesspaper/epdoptimize's
# src/dither/data/default-palettes.json "spectra6" entry (Apache
# License 2.0, https://github.com/paperlesspaper/epdoptimize) -- offered
# as a one-click "Load calibrated preset" in the Advanced configuration
# UI, not a new default: unlike DEFAULT_PALETTE_RGB above, these are an
# actual panel's measured appearance rather than idealized primaries
# (real Spectra 6 white/black are notably duller than pure #fff/#000),
# but measured from a different unit than any given frame's actual
# panel -- panel_style.py's own docstring already notes units vary
# enough to be worth calibrating per frame, and this hasn't been
# verified against this project's own hardware.
CALIBRATED_SPECTRA6_RGB = [
(0x1F, 0x22, 0x26), # BLACK
(0xB9, 0xC7, 0xC9), # WHITE
(0xC1, 0xBB, 0x1E), # YELLOW
(0x62, 0x20, 0x1E), # RED
(0x23, 0x3F, 0x8E), # BLUE
(0x35, 0x56, 0x3A), # GREEN
]
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
# hardware protocol, never user-configurable. 0x4 is intentionally unused
@@ -496,9 +538,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
return _transpose_and_pack(quantized, orientation)
def _png_bytes(img: Image.Image) -> bytes:
buf = io.BytesIO()
img.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes:
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run
@@ -530,7 +579,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
as_png=True returns a normal browser-viewable PNG in logical (upright)
orientation instead of packed native-panel bytes, same convention as
render_preview_png -- used for the web UI's live "how it's displaying"
thumbnail."""
thumbnail.
capture_snapshot=True (only meaningful alongside as_png=False) returns
(packed_bytes, png_bytes) instead of just packed_bytes -- both derived
from the same already-quantized canvas, so a device-facing render can
also persist a browser-viewable copy (see routers/device.py's
_record_last_displayed) without re-running composition/quantization a
second time."""
logical_w, logical_h = logical_render_size(orientation)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions:
@@ -540,10 +596,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png:
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
return _transpose_and_pack(quantized, orientation)
return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation)
if capture_snapshot:
return packed, _png_bytes(quantized)
return packed
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
@@ -559,14 +616,13 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
return _png_bytes(quantized)
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
manage: dict | None = None, as_png: bool = False) -> bytes:
manage: dict | None = None, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
@@ -574,7 +630,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
yet."""
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
instead of just packed."""
margin = 24
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
@@ -638,7 +695,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png:
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
return _transpose_and_pack(quantized, orientation)
return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation)
if capture_snapshot:
return packed, _png_bytes(quantized)
return packed
+34
View File
@@ -0,0 +1,34 @@
"""Root-logger configuration: a rotating file handler under the same
/data volume as the sqlite DB and legacy config.json, so the admin log
viewer has something to read and log content survives container
restarts -- a redeploy happens on every push to main touching
server/**, which would make an in-memory-only log buffer nearly
useless in practice. Before this, the root logger had no handler at
all, so every module's logger.info() call (user creation, claims,
password resets, ...) was silently dropped rather than merely
un-viewable -- this fixes that too, not just adds a viewer."""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_PATH = Path(os.environ.get("LOG_PATH", "/data/server.log"))
def configure_logging() -> None:
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(LOG_PATH, maxBytes=2_000_000, backupCount=3)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root = logging.getLogger()
root.addHandler(handler)
root.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
def read_log_tail(lines: int) -> str:
if not LOG_PATH.exists():
return ""
text = LOG_PATH.read_text(errors="replace")
return "\n".join(text.splitlines()[-lines:])
+57 -17
View File
@@ -16,14 +16,16 @@ pre-database config.json deployment on first boot."""
from __future__ import annotations
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from . import migration
from . import html_render, logging_setup, migration
from .auth import (
browser_token_valid,
current_user,
@@ -38,12 +40,53 @@ from .routers.common import shell_context
logger = logging.getLogger(__name__)
# Before anything else logs: a handler exists to catch it, and it lands in
# the same persistent volume the admin log viewer reads from.
logging_setup.configure_logging()
# Schema + legacy-config import, before the first request is served.
migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
@asynccontextmanager
async def _lifespan(app: FastAPI):
"""Startup does nothing browser-related -- html_render.start() is
lazy (only the weather widget's opt-in "modern" render style ever
triggers it, see that module's docstring), so a deployment that
never uses it never launches Chromium or needs Playwright's browser
binaries installed. Shutdown calls html_render.stop() unconditionally
(a no-op if it was never started) so a server restart never leaves
an orphaned Chromium process running."""
yield
html_render.stop()
app = FastAPI(title="ESPresso Frame Server", lifespan=_lifespan)
templates = Jinja2Templates(directory="app/templates")
@app.middleware("http")
async def log_device_requests(request: Request, call_next):
"""Access log for the firmware-facing /frame/* protocol -- the admin
log viewer otherwise only ever shows exceptions (device.py logs
those, not successful requests), so a slow-but-200 request or a
device hammering a stale/wrong token leaves no trace at all. Logs
the device id (query param, not the token -- never log credentials)
and wall time, which is exactly what's needed to spot a request that
blew past the firmware's fixed HTTP timeout without technically
failing server-side."""
if not request.url.path.startswith("/frame/"):
return await call_next(request)
start = time.monotonic()
device_id = request.query_params.get("id", "") or "-"
response = await call_next(request)
elapsed_ms = (time.monotonic() - start) * 1000
logger.info(
"%s %s id=%s -> %d (%.0fms)",
request.method, request.url.path, device_id, response.status_code, elapsed_ms,
)
return response
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
@@ -60,26 +103,23 @@ def health() -> dict:
return {"status": "ok"}
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
@app.get("/sw.js")
def service_worker() -> FileResponse:
# Served from / rather than /static/sw.js so its default scope is the
# whole app -- a SW can only ever control paths at or below its own URL.
return FileResponse("app/static/sw.js", media_type="application/javascript")
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
@@ -90,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)
+33 -54
View File
@@ -14,7 +14,8 @@ from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, draw_text
from . import panel_style
from .image_pipeline import draw_text
PADDING = 16
QR_TEXT_GAP = 8
@@ -27,9 +28,12 @@ BODY_FONT_SIZE = 20
BATTERY_ICON_W = 40
BATTERY_ICON_H = 22
BATTERY_ICON_STROKE = 2
# Stroke/nub width/height are no longer fixed constants here -- panel_
# style.draw_battery_icon derives them from icon_w/icon_h itself (same
# formula widgets/battery.py's own icon already used). BATTERY_NUB_W
# below is kept only as this box's own outer-width estimate, not fed
# into the icon drawing itself.
BATTERY_NUB_W = 5
BATTERY_NUB_H = 10
BATTERY_ICON_TEXT_GAP = 8
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
@@ -37,10 +41,6 @@ FACE_LABEL_PADDING = 8
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
def _font(size: int) -> ImageFont.ImageFont:
return ImageFont.load_default(size=size)
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
import qrcode
@@ -52,7 +52,7 @@ def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont) -> tuple[int, int]:
"""(width, height) of `lines` stacked with LINE_GAP between them, at
`font` -- the box _draw_text_box below will need."""
w = 0
@@ -64,7 +64,7 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
return w, h
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont,
center_x: int, top: int) -> None:
y = top
for line in lines:
@@ -82,7 +82,8 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
(the battery, below the manage QR) use it instead of recomputing the
same geometry a second time."""
qr_img = _qr_image(url)
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
caption_font = panel_style.font_bold(TITLE_FONT_SIZE)
text_w, text_h = _text_box(draw, caption, caption_font) if caption else (0, 0)
content_w = max(qr_img.width, text_w)
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
@@ -90,24 +91,26 @@ def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption:
h = content_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption:
_draw_centered_lines(img, draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
_draw_centered_lines(img, draw, caption, caption_font, center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
return x0, y0, w, h
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
"""White-padded box with centered text lines, placed in one of the
panel's four corners."""
font = _font(BODY_FONT_SIZE)
font = panel_style.font_regular(BODY_FONT_SIZE)
text_w, text_h = _text_box(draw, lines, font)
w = text_w + PADDING * 2
h = text_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
_draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING)
@@ -123,34 +126,17 @@ def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner:
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
# DEFAULT_PALETTE_RGB order is [BLACK, WHITE, YELLOW, RED, BLUE, GREEN]
# (see image_pipeline.PANEL_CODES) -- picked by level so the fill itself
# carries the "how worried should I be" signal, not just the number next
# to it. Thresholds match the low-battery-alert spirit elsewhere in this
# project (not tied to a frame's own configured alert threshold, since
# this glyph has to make sense with no configuration at all).
_BATTERY_LOW = DEFAULT_PALETTE_RGB[3] # red
_BATTERY_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
_BATTERY_HIGH = DEFAULT_PALETTE_RGB[5] # green
def _battery_fill_color(percent: int) -> tuple[int, int, int]:
if percent <= 15:
return _BATTERY_LOW
if percent <= 40:
return _BATTERY_MEDIUM
return _BATTERY_HIGH
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None:
"""Battery glyph (now actually filled to `percent`, not just a static
outline -- easy now that this renders server-side instead of being a
fixed bitmap firmware drew) + "NN%" text, right-aligned under the
given anchor box (the manage QR box) -- a sensible default position,
not a constraint anything else has to route around; move this call
site's arguments to place it anywhere else instead."""
font = _font(BODY_FONT_SIZE)
"""Battery glyph + "NN%" text, right-aligned under the given anchor
box (the manage QR box) -- a sensible default position, not a
constraint anything else has to route around; move this call site's
arguments to place it anywhere else instead. The glyph itself is
panel_style.draw_battery_icon -- the one shared implementation
replacing what used to be a second, independent copy of widgets/
battery.py's own icon-drawing code (same shape, same red/yellow/
green thresholds, previously kept in sync by convention only)."""
font = panel_style.font_regular(BODY_FONT_SIZE)
text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
text_w = draw.textlength(text, font=font)
@@ -162,22 +148,14 @@ def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anc
x0 = anchor_x0 + anchor_w - w
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
icon_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE
inner_x1, inner_y1 = icon_x + BATTERY_ICON_W - BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_H - BATTERY_ICON_STROKE
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (percent / 100))
if fill_x1 > inner_x0:
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_battery_fill_color(percent))
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
width=BATTERY_ICON_STROKE)
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
fill=(0, 0, 0))
panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent)
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
text, font)
text, font, panel_style.battery_fill_color(percent))
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
@@ -185,7 +163,7 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
anchor_y) point, flipped above if there's no room below, clamped to
stay fully on-panel -- unlike the four corner boxes (always in-bounds
by construction), a face can be anywhere, including near an edge."""
font = _font(BODY_FONT_SIZE)
font = panel_style.font_regular(BODY_FONT_SIZE)
text_w = draw.textlength(name, font=font)
bbox = draw.textbbox((0, 0), name, font=font)
text_h = bbox[3] - bbox[1]
@@ -201,7 +179,8 @@ def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anc
x0 = max(0, min(x0, img_w - w))
y0 = max(0, min(y0, img_h - h))
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS,
fill=(255, 255, 255), outline=(0, 0, 0))
draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
+486 -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
@@ -774,6 +770,393 @@ def _migration_29(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
def _migration_30(conn) -> None:
""""Now displaying" (models.Frame.last_displayed_image/
last_displayed_at) -- the web UI's header preview pair needs a frozen
record of exactly what the last device-facing render actually sent,
separate from the always-live "up next" re-render (see
routers/device.py's _record_last_displayed, api_frames.py's
/now-displaying endpoint). NULL/0.0 for every existing frame until
its next real device fetch -- no behavior change to what's served,
only a new thing recorded alongside it.
Guarded per-column, same reasoning as migration 26/27/29's own
comments: frames is a table test_migrations.py's pre-widget-system
replay tests leave un-dropped, so it keeps the fresh-install
create_all() copy -- which already has these columns -- when those
tests replay migrations 17+ from schema_version 16. Without the
guard, replaying this migration there re-adds a column that's already
there and SQLite raises "duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "last_displayed_image" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
if "last_displayed_at" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
def _migration_31(conn) -> None:
"""Weather widget render style (models.WeatherWidgetConfig.
render_style): "classic" (existing hand-drawn PIL renderer,
unchanged) or "modern" (app/html_render.py's headless-Chromium/CSS
renderer). Every existing weather widget defaults to "classic" --
no behavior change until a widget's dialog switches it.
Guarded per-column, same reasoning as migration 30's own comment:
weather_widget_configs is a table some replay tests may re-create
fresh via create_all() (which already has this column) rather than
replaying migration 24's raw CREATE TABLE."""
existing = {c["name"] for c in inspect(conn).get_columns("weather_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE weather_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_32(conn) -> None:
"""Photos widget's own independent palette/dithering (models.Frame.
photo_palette_rgb/photo_dither_strength -- see widgets/photos.py's
render()). NULL/1.0 defaults reproduce the exact previous rendering
(same reference palette/strength as the main fields) until a frame's
Configuration tab sets them differently.
Guarded per-column, same reasoning as migration 30/31's own comments."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "photo_palette_rgb" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_palette_rgb TEXT"))
if "photo_dither_strength" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN photo_dither_strength REAL NOT NULL DEFAULT 1.0"))
def _migration_33(conn) -> None:
"""Battery widget render style (models.BatteryWidgetConfig.
render_style) -- same shape as migration 31's weather one. Every
existing battery widget defaults to "classic", no behavior change."""
existing = {c["name"] for c in inspect(conn).get_columns("battery_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE battery_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_34(conn) -> None:
"""Text widget render style (models.TextWidgetConfig.render_style) --
same shape as migration 31/33. Every existing text widget defaults to
"classic", no behavior change."""
existing = {c["name"] for c in inspect(conn).get_columns("text_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE text_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_35(conn) -> None:
"""Tasks widget render style (models.TaskWidgetConfig.render_style)
-- same shape as migration 31/33/34. Every existing tasks widget
defaults to "classic", no behavior change."""
existing = {c["name"] for c in inspect(conn).get_columns("task_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_36(conn) -> None:
"""Static image widget render style (models.StaticWidgetConfig.
render_style) -- same shape as migration 31/33/34/35."""
existing = {c["name"] for c in inspect(conn).get_columns("static_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE static_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_37(conn) -> None:
"""Whiteboard widget render style (models.WhiteboardWidgetConfig.
render_style) -- same shape as migration 36."""
existing = {c["name"] for c in inspect(conn).get_columns("whiteboard_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE whiteboard_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_38(conn) -> None:
"""Calendar widget render style (models.CalendarWidgetConfig.
render_style) -- same shape as migration 31/33/34/35/36/37."""
existing = {c["name"] for c in inspect(conn).get_columns("calendar_widget_configs")}
if "render_style" not in existing:
conn.execute(text("ALTER TABLE calendar_widget_configs ADD COLUMN render_style TEXT NOT NULL DEFAULT 'classic'"))
def _migration_39(conn) -> None:
"""Frame-level curated theme for "modern" style widgets (models.
Frame.theme, see theme_tokens.THEMES) -- same guarded-per-column
shape as every prior migration."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "theme" not in existing:
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),
@@ -804,6 +1187,18 @@ MIGRATIONS = [
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
(30, _migration_30),
(31, _migration_31),
(32, _migration_32),
(33, _migration_33),
(34, _migration_34),
(35, _migration_35),
(36, _migration_36),
(37, _migration_37),
(38, _migration_38),
(39, _migration_39),
(40, _migration_40),
(41, _migration_41),
]
@@ -820,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()
@@ -846,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
@@ -863,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],
@@ -905,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.
@@ -939,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
@@ -1113,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:
+60 -127
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
@@ -178,113 +163,19 @@ class Frame(Base):
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
# original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# -- 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)
# Same shape as palette_rgb/dither_strength above, but scoped to only
# the photos widget (widgets/photos.py quantizes against these itself,
# before returning -- see its own docstring) -- lets a frame tune the
# rest of its widgets' palette/dithering (e.g. a "modern" HTML-
# rendered dashboard look) independently of what actually looks best
# for real photographs. NULL/1.0 = same defaults as the main fields.
photo_palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
photo_dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# Curated visual theme for "modern" (HTML/CSS) style widgets -- see
# theme_tokens.THEMES. Frame-level (like palette_rgb/dither_strength
# above), not per-widget, since a theme is "how this frame looks."
# Widgets rendered in classic (PIL) style ignore this entirely.
theme: Mapped[str] = mapped_column(String, default="classic")
# -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
@@ -328,6 +219,16 @@ class Frame(Base):
# starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- "now displaying" (see routers/device.py's _record_last_displayed,
# api_frames.py's /now-displaying endpoint) -- exactly what the last
# device-facing render (/frame/image, /frame/advance, /frame/back, or
# a global hold action) actually sent, as an upright PNG, so the web
# UI's header preview can show it frozen alongside a live "up next"
# re-render instead of conflating the two. NULL until a real device
# has fetched at least once.
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
# -- stats (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
@@ -476,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"),)
@@ -532,6 +439,10 @@ class CalendarWidgetConfig(Base):
week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# classic (calendar_render.py) vs modern (app/calendar_html_render.py,
# agenda mode only so far -- see that module's docstring) -- see
# widgets/calendar.py's render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class TaskWidgetConfig(Base):
@@ -570,6 +481,9 @@ class TaskWidgetConfig(Base):
# outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False)
# classic (hand-drawn PIL, calendar_render._build_tasks) vs modern
# (app/html_render.py) -- see widgets/tasks.py's render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class WhiteboardWidgetConfig(Base):
@@ -583,6 +497,10 @@ class WhiteboardWidgetConfig(Base):
url: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# classic (no chrome, unchanged) vs modern (app/html_render.py's
# rounded-corner shadowed card) -- see widgets/whiteboard.py's
# render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class WeatherWidgetConfig(Base):
@@ -597,13 +515,19 @@ class WeatherWidgetConfig(Base):
`cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of
{"label","high","low","category"} for multi_city."""
{"label","high","low","category"} for multi_city.
`render_style` picks which renderer draws the widget: "classic" (the
hand-drawn PIL primitives in app/weather_render.py, unchanged
default) or "modern" (app/html_render.py's Jinja2/headless-Chromium
path, "current"/"daily" modes only for now -- see weather.py's
render())."""
__tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as
@@ -652,6 +576,9 @@ class TextWidgetConfig(Base):
font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff")
# classic (hand-drawn PIL) vs modern (app/html_render.py) -- see
# widgets/text.py's render()/render_preview_png().
render_style: Mapped[str] = mapped_column(String, default="classic")
class StaticWidgetConfig(Base):
@@ -673,6 +600,10 @@ class StaticWidgetConfig(Base):
# minus crop_faces -- no face detection for an uploaded image (see
# image_pipeline.STATIC_DISPLAY_MODES).
display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
# classic (no chrome, unchanged) vs modern (app/html_render.py's
# rounded-corner shadowed card) -- see widgets/static_image.py's
# render().
render_style: Mapped[str] = mapped_column(String, default="classic")
class BatteryWidgetConfig(Base):
@@ -683,12 +614,14 @@ class BatteryWidgetConfig(Base):
of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and the
last report's age."""
last report's age. `render_style` picks classic (hand-drawn PIL) vs
modern (app/html_render.py) -- see widgets/battery.py's render()."""
__tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
# widget_type -> its per-type extension table, keyed by widget_id. Used
+206
View File
@@ -0,0 +1,206 @@
"""Shared visual language for everything drawn onto the e-ink panel
(excluding widgets/text.py, which already has its own richer multi-
family font picker and is left alone) -- spacing, ink-color resolution,
Inter font loading, and the small set of drawing primitives
(header bar, color chip, battery icon) more than one render module needs.
Centralizes what used to be independently redefined per render file
(calendar_render.py/weather_render.py each had their own MARGIN/BG/FG/
RULE, widgets/battery.py and manage_overlay.py each had their own
battery-glyph-drawing code) so the panel reads as one consistent system
instead of N separately-styled widgets. Still bound by the same hard
constraints as everything else that draws before the single whole-canvas
quantize/dither pass (see image_pipeline.py's module docstring/draw_text):
every fill here is one of DEFAULT_PALETTE_RGB's 6 exact colors, and text
always routes through image_pipeline.draw_text.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB
# Spacing scale. CONTENT_MARGIN carries over calendar_render.py/
# weather_render.py's own long-tuned MARGIN=20 value unchanged (not
# re-tuned -- every wrap/truncation-width calc in those modules was
# measured against it). GUTTER is new: the inset every widget applies
# within its own target_w x target_h box (see card_canvas) to get a
# visible seam between adjacent widgets without touching grid.py's
# zero-gap cell math.
GUTTER = 6
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)
# Which accent ink each widget kind's chrome (header bar, task checkbox,
# etc.) uses -- one dict, so "what color is a calendar header" has a
# single answer instead of being hardcoded separately everywhere a
# render module wants it. This is what makes a future global color
# theme *possible* without another pass through every render module: a
# per-frame override just needs to pick a different THEME mapping (or
# remap individual entries) here and resolve through theme_color/ink
# below, which already goes through a frame's own tuned Frame.
# palette_rgb -- swapping a slot's actual RGB (e.g. a custom "blue")
# already re-themes every widget that uses THEME_CALENDAR for its
# header, with no other code to touch. Weather deliberately maps to
# BLACK, not a color -- see weather_render's header call site -- so its
# own hand-drawn, already-colorful icons stay the star.
THEME_CALENDAR = BLUE
THEME_TASKS = GREEN
THEME_WEATHER = BLACK
THEME = {"calendar": THEME_CALENDAR, "tasks": THEME_TASKS, "weather": THEME_WEATHER}
def theme_color(widget_kind: str, palette_rgb: list | None = None) -> tuple[int, int, int]:
"""THEME[widget_kind] resolved against this frame's actual palette --
the one call every render module's header/accent chrome should go
through instead of hardcoding a palette index inline."""
return ink(palette_rgb, THEME[widget_kind])
def ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index -- generalizes the same resolution idiom weather_render._ink/
calendar_render._event_colors already used locally, so a custom
palette override (Frame.palette_rgb) still gets its own actual
yellow/red/blue/green, and every fill stays an exact, ditherless
palette match either way."""
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
@lru_cache(maxsize=256)
def font_bold(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_FONT_DIR / "Inter-Bold.ttf"), size)
@lru_cache(maxsize=256)
def font_regular(size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(_FONT_DIR / "Inter-Regular.ttf"), size)
def card_canvas(target_w: int, target_h: int,
bg: tuple[int, int, int] = (255, 255, 255)) -> tuple:
"""A full target_w x target_h canvas filled with `bg`, plus the
GUTTER-inset rect (x0, y0, w, h) every widget should draw its actual
chrome/content within -- this is the whole mechanism behind the
gutter between widgets (see module docstring): the widget's render()
contract (exact target_w x target_h in, same size out, unchanged) is
what routers/device.py pastes and what draw_widget_border frames, so
a border still frames the widget's true full box; only the widget's
own drawing backs off from that box's true edge."""
img = Image.new("RGB", (target_w, target_h), bg)
draw = ImageDraw.Draw(img)
x0, y0 = GUTTER, GUTTER
w, h = max(1, target_w - 2 * GUTTER), max(1, target_h - 2 * GUTTER)
return img, draw, (x0, y0, w, h)
def _clamped_radius(radius: int, w: int, h: int) -> int:
return max(0, min(radius, w // 2, h // 2))
def draw_header_bar(draw: ImageDraw.ImageDraw, rect: tuple[int, int, int, int], height: int,
fill: tuple[int, int, int], radius: int = CARD_RADIUS) -> None:
"""A widget's title bar: rounded top corners only (corners=(tl, tr,
bl, br), the bottom pair left square) so it reads as a card's header
fused to the content below it, not a standalone pill floating with a
gap above its own body."""
x0, y0, w, h = rect
r = _clamped_radius(radius, w, height * 2)
draw.rounded_rectangle([x0, y0, x0 + w, y0 + height], radius=r, fill=fill,
corners=(True, True, False, False))
def draw_color_chip(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
colors: list[tuple[int, int, int]], radius: int = CHIP_RADIUS) -> None:
"""One rounded chip for a single-source event/task, or that same
footprint split into equal-width side-by-side segments -- one per
contributing calendar -- for a deduplicated shared event (see
calendar_render._event_colors/calendar_feed.merge_events). Splitting
rather than e.g. concentric rings keeps every color equally "thick
and bold" at a glance, the same design goal a single pinned color
already has. Generalizes calendar_render.py's old private
_draw_color_bar so the radius comes from one shared constant."""
if len(colors) == 1:
r = _clamped_radius(radius, x1 - x0, y1 - y0)
draw.rounded_rectangle([x0, y0, x1, y1], radius=r, fill=colors[0])
return
seg_w = (x1 - x0) / len(colors)
for i, color in enumerate(colors):
seg_x0 = round(x0 + i * seg_w)
seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)
def battery_fill_color(percent: int, palette_rgb: list | None = None) -> tuple[int, int, int]:
"""Red/yellow/green by charge level -- the fill itself carries the
"how worried should I be" signal, not just the number next to it.
Shared threshold logic for widgets/battery.py and manage_overlay.py,
which previously each defined the same three-tier thresholds twice."""
if percent <= 15:
return ink(palette_rgb, RED)
if percent <= 40:
return ink(palette_rgb, YELLOW)
return ink(palette_rgb, GREEN)
def draw_battery_icon(draw: ImageDraw.ImageDraw, x0: int, y0: int, icon_w: int, icon_h: int,
percent: int, palette_rgb: list | None = None) -> None:
"""A rounded battery glyph -- outline + charge-level fill + terminal
nub -- anchored at (x0, y0), the body's own top-left corner (the nub
extends past icon_w on the right). The one shared implementation
behind what used to be two separate ImageDraw glyphs: widgets/
battery.py's own icon+percent widget, and manage_overlay.py's compact
battery readout on the "scan to manage" overlay -- same shape, same
red/yellow/green thresholds, previously kept in sync by convention
rather than by sharing code."""
stroke = max(2, icon_h // 12)
nub_w = max(3, icon_w // 10)
nub_h = icon_h // 2
radius = _clamped_radius(icon_h // 6, icon_w, icon_h)
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
if fill_x1 > inner_x0:
fill_radius = _clamped_radius(radius, fill_x1 - inner_x0, inner_y1 - inner_y0)
draw.rounded_rectangle([inner_x0, inner_y0, fill_x1, inner_y1], radius=fill_radius,
fill=battery_fill_color(percent, palette_rgb))
draw.rounded_rectangle([x0, y0, x0 + icon_w, y0 + icon_h], radius=radius, outline=(0, 0, 0), width=stroke)
nub_y = y0 + (icon_h - nub_h) // 2
nub_radius = _clamped_radius(max(1, nub_w // 3), nub_w, nub_h)
draw.rounded_rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], radius=nub_radius,
fill=(0, 0, 0))
+46 -1
View File
@@ -25,7 +25,7 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours
from .. import gitea_releases, grid, quiet_hours, theme_tokens
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS
@@ -105,6 +105,10 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
photo_palette: list[str] | None = Form(None),
photo_palette_reset: bool | None = Form(None),
photo_dither_strength: float | None = Form(None),
theme: str | None = Form(None),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
@@ -182,6 +186,19 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
if photo_palette_reset:
cfg.photo_palette_rgb = None
elif photo_palette is not None:
if len(photo_palette) != len(PALETTE_LABELS):
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(photo_palette)}")
parsed = [hex_to_rgb(h) for h in photo_palette]
if any(rgb is None for rgb in parsed):
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
if photo_dither_strength is not None:
cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
if theme is not None:
cfg.theme = theme if theme in theme_tokens.THEMES else "classic"
if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None:
@@ -244,6 +261,14 @@ def api_status(
"device": {
"last_seen": frame.last_seen or None,
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
# When the device is next expected to check in, per the same
# sleep duration frame_config() actually hands it (see
# device.py's /frame/config) -- not the raw overdue_gap above,
# which is deliberately generous (OVERDUE_FACTOR) to avoid
# false alarms during quiet hours rather than a best guess.
"expected_next_checkin": (
frame.last_seen + quiet_hours.effective_refresh_interval_s(frame) if frame.last_seen else None
),
"firmware_version": frame.device_firmware_version or None,
"firmware_available": frame.firmware_available_version or None,
"battery": (
@@ -271,6 +296,25 @@ def api_frame_preview(
return Response(content=png, media_type="image/png")
@router.get("/api/frames/{frame_id}/now-displaying")
def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
"""Exactly what was last actually sent to this frame's device (see
routers/device.py's _record_last_displayed) -- the frozen "now
displaying" half of the header preview pair, as opposed to /preview's
always-live "up next" re-render. 404 (not a placeholder image) until
the device has fetched at least once, so the web UI can show its own
empty state instead of a broken image. X-Displayed-At carries the
capture time (unix seconds) for a "N ago" label -- a header, not the
body, since the body is the raw PNG bytes."""
if frame.last_displayed_image is None:
raise HTTPException(404, "This frame hasn't displayed anything yet")
return Response(
content=frame.last_displayed_image,
media_type="image/png",
headers={"X-Displayed-At": str(frame.last_displayed_at)},
)
@router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute(
@@ -384,6 +428,7 @@ def api_firmware_check(
"board": frame.device_board_variant or None,
"latest_version": frame.firmware_gitea_latest_version or None,
"staged_version": frame.firmware_available_version or None,
"running_version": frame.device_firmware_version or None,
"update_available": update_available,
}
+10 -6
View File
@@ -56,13 +56,17 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"photos": ("album_id", "order", "display_mode", "queue_target_len"),
"calendar": (
"view", "week_start", "weather_enabled", "weather_units", "weather_cities",
"week_days", "week_layout", "week_start_offset",
"week_days", "week_layout", "week_start_offset", "render_style",
),
"tasks": ("name", "show_completed", "render_style"),
"static": ("display_mode", "original_filename", "render_style"),
"text": ("content", "font_size", "font_family", "align", "background_color", "render_style"),
"whiteboard": ("user_id", "url", "render_style"),
"battery": ("mode", "render_style"),
"weather": (
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
"hourly_interval_hours", "daily_days", "cities", "render_style",
),
"tasks": ("name", "show_completed"),
"static": ("display_mode", "original_filename"),
"text": ("content", "font_size", "font_family", "align", "background_color"),
"whiteboard": ("user_id", "url"),
"battery": ("mode",),
}
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
+142 -26
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 (
@@ -35,11 +35,16 @@ from ..image_pipeline import (
DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES,
hex_to_rgb,
logical_render_size,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
render_preview_png,
compose_into,
_enhance,
_png_bytes,
_quantize,
)
from ..image_upload import decode_upload
from ..models import (
@@ -91,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(
@@ -275,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)
@@ -317,9 +347,12 @@ def api_widget_config_save(
album_id: str | None = Form(None),
order: str | None = Form(None),
display_mode: str | None = Form(None),
static_render_style: str | None = Form(None),
whiteboard_render_style: str | None = Form(None),
queue_target_len: int | None = Form(None),
# calendar
calendar_view: str | None = Form(None),
calendar_render_style: str | None = Form(None),
calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None),
@@ -329,10 +362,12 @@ def api_widget_config_save(
# tasks
tasks_name: str | None = Form(None),
tasks_show_completed: bool | None = Form(None),
tasks_render_style: str | None = Form(None),
# text
text_html: str | None = Form(None),
text_font_size: int | None = Form(None),
text_font_family: str | None = Form(None),
text_render_style: str | None = Form(None),
text_align: str | None = Form(None),
text_background_color: str | None = Form(None),
# weather
@@ -341,8 +376,10 @@ def api_widget_config_save(
weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None),
weather_render_style: str | None = Form(None),
# battery
battery_mode: str | None = Form(None),
battery_render_style: str | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
@@ -405,6 +442,8 @@ def api_widget_config_save(
# new unit label.
ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units
if calendar_render_style is not None and calendar_render_style in ("classic", "modern"):
ccfg.render_style = calendar_render_style
elif widget.widget_type == "tasks":
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
if tasks_name is not None:
@@ -415,10 +454,22 @@ def api_widget_config_save(
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
tcfg.show_completed = tasks_show_completed
tcfg.checked_at = 0.0 # pick up the change promptly
if tasks_render_style is not None and tasks_render_style in ("classic", "modern"):
tcfg.render_style = tasks_render_style
elif widget.widget_type == "static":
with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
if display_mode is not None:
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE
if static_render_style is not None and static_render_style in ("classic", "modern"):
scfg.render_style = static_render_style
elif widget.widget_type == "whiteboard":
# user_id/url go through the dedicated /whiteboard-source endpoint
# (owner-only, JSON body) -- render_style is the one setting this
# widget type takes through the shared /config form, same as
# every other widget type's own render_style.
with widget_locked(db, frame.id, widget.id) as (_, _, wbcfg):
if whiteboard_render_style is not None and whiteboard_render_style in ("classic", "modern"):
wbcfg.render_style = whiteboard_render_style
elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None:
@@ -439,6 +490,8 @@ def api_widget_config_save(
xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
)
if text_render_style is not None and text_render_style in ("classic", "modern"):
xcfg.render_style = text_render_style
elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
@@ -469,10 +522,14 @@ def api_widget_config_save(
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days))
if weather_render_style is not None and weather_render_style in ("classic", "modern"):
wcfg.render_style = weather_render_style
elif widget.widget_type == "battery":
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
if battery_mode is not None:
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
if battery_render_style is not None and battery_render_style in ("classic", "modern"):
bcfg.render_style = battery_render_style
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -833,14 +890,30 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
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,
)
target_w, target_h = logical_render_size(frame.orientation)
if ccfg.render_style == "modern":
from zoneinfo import ZoneInfo
from .. import calendar_html_render
tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
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, widget.font_scale,
)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
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, font_scale=widget.font_scale,
)
return Response(content=png, media_type="image/png")
@@ -859,8 +932,19 @@ def api_widget_preview_tasks(
raise HTTPException(400, "No task lists included on this widget yet")
tcfg = db.get(TaskWidgetConfig, widget.id)
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, title=tcfg.name or "Tasks")
title = tcfg.name or "Tasks"
if tcfg.render_style == "modern":
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,
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,
font_scale=widget.font_scale)
return Response(content=png, media_type="image/png")
@@ -1108,10 +1192,19 @@ def api_widget_preview_weather(
if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet")
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
)
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
# Same local-import reasoning as widgets/weather.py's render().
from .. import html_render
png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", theme_name=frame.theme,
)
else:
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
)
return Response(content=png, media_type="image/png")
@@ -1158,11 +1251,22 @@ def api_widget_preview_static(
if not scfg.image:
raise HTTPException(400, "No image uploaded to this widget yet")
source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
if scfg.render_style == "modern":
from .. import html_render
target_w, target_h = logical_render_size(frame.orientation)
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode=scfg.display_mode)
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
return Response(content=png, media_type="image/png")
@@ -1181,7 +1285,8 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet")
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb)
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
theme_name=frame.theme)
return Response(content=png, media_type="image/png")
@@ -1297,8 +1402,19 @@ def api_widget_preview_whiteboard(
raise HTTPException(400, "No whiteboard configured on this widget yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox",
)
if wcfg.render_style == "modern":
from .. import html_render
target_w, target_h = logical_render_size(frame.orientation)
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode="letterbox")
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
"whiteboard")
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox",
)
return Response(content=png, media_type="image/png")
+66 -12
View File
@@ -59,6 +59,12 @@ MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
# single noisy reading needs rejecting at the per-wake-drop level, not
# just at the recharge-detection level.
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5
# Readings considered on each side of a given reading when
# _smooth_percents looks for local outliers. Needs to be at least half
# the length of the longest bad-reading burst a noisy divider produces
# (observed up to ~4 consecutive corrupted reports on one frame) so the
# good neighbors still outnumber the bad ones in the window.
BATTERY_SMOOTHING_WINDOW = 4
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
@@ -179,6 +185,50 @@ def _reject_outlier_drops(steps: list[tuple[int, float]]) -> list[tuple[int, flo
return kept or steps # never filter down to nothing
def _smooth_percents(percents: list[int]) -> list[float]:
"""Replaces any reading that's a wild outlier against its own local
neighborhood with that neighborhood's median, before per-wake drop
steps are ever built from the series.
_reject_outlier_drops (above) only catches a bad reading by how much
it distorts the *steps* immediately on either side of it -- which is
exactly what one isolated glitch does, but a 1M-ohm divider (see
firmware/main/battery.c) doesn't always misfire in isolation: several
consecutive reports can drift or glitch together (a multi-minute
crawl from 68 up into the high 70s with nothing charging, or a run of
several ~40 reports spliced into an otherwise flat ~53 run). A step
computed *between* two bad readings in the same burst looks like an
ordinary small change, not an outlier, so it sails through
_reject_outlier_drops untouched.
A Hampel identifier catches that instead: each reading is compared to
the median of its own local window (not the whole series), using the
same MAD-based modified z-score as _reject_outlier_drops so this
adapts to how noisy a given frame's sensor actually is rather than a
fixed percent-point cutoff. A window of BATTERY_SMOOTHING_WINDOW
reports on each side tolerates a bad burst up to that long while
still being outvoted by the surrounding good readings."""
n = len(percents)
smoothed = list(percents)
for i in range(n):
lo = max(0, i - BATTERY_SMOOTHING_WINDOW)
hi = min(n, i + BATTERY_SMOOTHING_WINDOW + 1)
neighborhood = percents[lo:hi]
median = statistics.median(neighborhood)
abs_devs = [abs(v - median) for v in neighborhood]
# Unlike _reject_outlier_drops, no mean-of-abs-devs fallback here:
# a burst can be a big enough share of this small a window that
# the mean itself gets dragged up by the very values being
# tested, hiding them. A flat 1-percentage-point floor -- this
# project's smallest real unit of noise -- keeps the test from
# dividing by zero without being skewed by the burst it's
# checking.
mad = statistics.median(abs_devs) or 1
if abs(0.6745 * (percents[i] - median) / mad) > OUTLIER_MODIFIED_Z_THRESHOLD:
smoothed[i] = median
return smoothed
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
"""Remaining-time estimate from a recency-weighted average of the
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
@@ -189,18 +239,21 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
Consecutive reports are assumed to be consecutive wakes (firmware
reports battery on every wake while on battery), so each step's
(prev_percent - next_percent) is that wake's cost. A step where
percent went *up* is a recharge, not negative drain, and is skipped
entirely rather than folded in as a weird outlier; a flat step
(0% change) still counts as a real, cheap wake -- excluding those
would systematically overstate the per-wake cost by only counting
the wakes that happened to tick the percentage down. The remaining
steps then get one more pass, _reject_outlier_drops, to catch the
single-noisy-reading case that "percent went up" alone can't (see
that function's docstring). Steps are weighted linearly by recency
(step i of n gets weight i, 1-indexed) so a recent change in usage
pattern shows up quickly instead of being washed out by a long flat
history.
(prev_percent - next_percent) is that wake's cost. Raw percents go
through _smooth_percents first, which corrects readings (including
short bursts of them) that are wild outliers against their own local
neighborhood -- see that function's docstring for why that catches
noise shapes _reject_outlier_drops can't. A step where percent went
*up* is a recharge, not negative drain, and is skipped entirely
rather than folded in as a weird outlier; a flat step (0% change)
still counts as a real, cheap wake -- excluding those would
systematically overstate the per-wake cost by only counting the
wakes that happened to tick the percentage down. The remaining steps
then get one more pass, _reject_outlier_drops, to catch whatever
single-noisy-reading shape survives smoothing (see that function's
docstring). Steps are weighted linearly by recency (step i of n gets
weight i, 1-indexed) so a recent change in usage pattern shows up
quickly instead of being washed out by a long flat history.
The resulting %/wake rate is then converted to wall-clock time using
the frame's *current* refresh_interval_s and quiet-hours settings
@@ -220,6 +273,7 @@ def battery_estimate_s(frame: Frame, db: Session) -> int | None:
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
return None
percents = list(reversed(rows)) # chronological order
percents = _smooth_percents(percents)
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
for i in range(1, len(percents)):
+109 -32
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, Response
@@ -23,7 +24,7 @@ from sqlalchemy.orm import Session
from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..db import SessionLocal, frame_locked, get_db
from ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
@@ -43,7 +44,7 @@ router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
as_png: bool = False) -> bytes:
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""What an unclaimed or widget-less frame displays instead of real
content -- instructions with a QR, rendered at 200 so the device
treats it as a perfectly normal image and never error-loops. The
@@ -60,6 +61,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
)
if frame.owner_user_id is None:
return render_placeholder(
@@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
)
return render_placeholder(
["Almost there!", "Add a widget for this frame at", base],
@@ -76,11 +79,46 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
)
def _render_one_widget(frame_id: int, widget_id: int, orientation: str, panel_w: int, panel_h: int,
cell: tuple[int, int, int, int], is_normal_wake: bool,
) -> tuple[tuple[int, int, int, int], object] | None:
"""Renders exactly one widget on its own DB session, so several of
these can run concurrently in a thread pool -- see app/db.py's
module docstring: handlers already run multi-threaded (sync
handlers in FastAPI's threadpool, one process), and frame_locked/
widget_locked's per-frame threading.Lock is what makes that safe,
not anything about which Session object is in play. A SQLAlchemy
Session itself is never safe to share across threads, so each
concurrent render gets a fresh one rather than reusing the
request's. Most of a widget's render time is spent waiting on an
external call (Immich, a weather provider, CalDAV) with the DB
untouched, which is exactly the time this buys back."""
db = SessionLocal()
try:
frame = db.get(Frame, frame_id)
widget = db.get(Widget, widget_id)
if widget is None:
return None # deleted between the listing query and this fetch -- skip it, not a 500
module = WIDGET_TYPES.get(widget.widget_type)
if module is None:
return None # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
px, py, pw, ph = grid.cell_to_pixels(orientation, panel_w, panel_h, cell)
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
draw_widget_border(
img, widget.border_style, widget.border_thickness,
resolve_border_color(widget.border_color_index, frame.palette_rgb),
)
return (px, py, pw, ph), img
finally:
db.close()
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
as_png: bool = False) -> bytes:
as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math),
draws that widget's own optional border directly onto its region
@@ -89,34 +127,46 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
image_pipeline.render_panel for the single shared paste/enhance/
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
dict -- a frame can now show several widgets at once instead of
exactly one mode owning the whole panel."""
exactly one mode owning the whole panel.
Widgets render concurrently (_render_one_widget, each on its own DB
session) rather than one at a time -- a layout with several
network-backed widgets (photos, weather, calendar) previously paid
their fetch latency serially, which could push a single /frame/*
response past the firmware's fixed HTTP timeout and show a
misleading "server failed" status screen even though the server
was simply still working. Futures are submitted in sort_order and
collected in that same order (not completion order) -- overlapping
widgets must still paint in the original z-order."""
all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
panel_w, panel_h = logical_render_size(frame.orientation)
regions = []
for widget in all_widgets:
module = WIDGET_TYPES.get(widget.widget_type)
if module is None:
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500
px, py, pw, ph = grid.cell_to_pixels(
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h)
)
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake)
draw_widget_border(
img, widget.border_style, widget.border_thickness,
resolve_border_color(widget.border_color_index, frame.palette_rgb),
)
regions.append(((px, py, pw, ph), img))
if all_widgets:
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
futures = [
pool.submit(
_render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
(widget.x, widget.y, widget.w, widget.h), is_normal_wake,
)
for widget in all_widgets
]
for future in futures:
result = future.result()
if result is not None:
regions.append(result)
return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
capture_snapshot=capture_snapshot,
)
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
is_normal_wake: bool, as_png: bool = False) -> bytes:
is_normal_wake: bool, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The top-level "what does this frame show right now" entry point.
An unclaimed frame or one with no widgets yet gets the setup
placeholder (needs `request` for its QR URLs -- only available on the
@@ -134,11 +184,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
if request is None:
return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
manage=manage, as_png=as_png,
manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
)
return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
@@ -232,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
@@ -245,6 +294,17 @@ def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1"
def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
"""Persists exactly what a device-facing render just sent (upright
PNG, manage overlay included if present -- whatever's actually on the
panel) as this frame's "now displaying" snapshot, the frozen half of
the web UI's header preview pair (see api_frames.py's /now-displaying
endpoint and its always-live "up next" counterpart, /preview)."""
with frame_locked(db, frame.id) as locked:
locked.last_displayed_image = png_snapshot
locked.last_displayed_at = time.time()
@router.get("/frame/image")
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
@@ -262,9 +322,14 @@ def frame_image(
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
This is also the "normal wake" that resets any calendar widget's
browse position back to today (see app/widgets/calendar.py)."""
browse position back to today (see app/widgets/calendar.py).
Also records what's returned as this frame's "now displaying"
snapshot (see _record_last_displayed) -- every other device-facing
render endpoint below does the same."""
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -277,7 +342,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
device's next-photo button."""
_run_button_actions(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -288,7 +356,10 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
with nothing to go back to. Used by the device's back-photo button."""
_run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -303,7 +374,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -312,7 +386,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
"""The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
content, snapshot = _render_frame_content(
db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
)
_record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
+17 -3
View File
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import 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
@@ -25,6 +25,7 @@ from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import (
BORDER_STYLES,
BORDER_STYLE_LABELS,
CALIBRATED_SPECTRA6_RGB,
DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS,
MAX_BORDER_THICKNESS,
@@ -89,9 +90,11 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS,
themes=theme_tokens.THEMES,
)
@@ -255,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", {
@@ -270,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":
@@ -279,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":
@@ -304,6 +317,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
)
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_cfg": whiteboard_cfg,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
})
+32 -21
View File
@@ -13,7 +13,7 @@ import logging
import time
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -35,6 +35,7 @@ from ..auth import (
verify_password,
)
from ..db import get_db
from ..logging_setup import LOG_PATH, read_log_tail
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
from .common import valid_http_url
@@ -569,6 +570,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
"smtp": get_server_settings(db),
"notice": notice,
"error": error,
"active_admin_tab": "main",
})
return templates.TemplateResponse("admin.html", ctx)
@@ -583,6 +585,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
return _render_admin(request, db, user)
@router.get("/admin/logs", response_class=HTMLResponse)
def admin_logs_page(request: Request, lines: int = 500, db: Session = Depends(get_db)):
user = current_user(request, db)
if user is None:
return RedirectResponse("/login", status_code=303)
if not user.is_admin:
raise HTTPException(403, "Admin only")
from .common import shell_context
lines = max(50, min(lines, 5000))
ctx = shell_context(request, db, user, active_nav="admin")
ctx.update({
"active_admin_tab": "logs",
"log_exists": LOG_PATH.exists(),
"log_path": str(LOG_PATH),
"log_lines": lines,
"log_text": read_log_tail(lines),
})
return templates.TemplateResponse("admin_logs.html", ctx)
@router.get("/admin/logs/download")
def admin_logs_download(request: Request, db: Session = Depends(get_db)):
_require_admin_page(request, db)
if not LOG_PATH.exists():
raise HTTPException(404, "No log file yet")
return FileResponse(LOG_PATH, filename="server.log", media_type="text/plain")
@router.post("/admin/users", response_class=HTMLResponse)
def admin_create_user(
request: Request,
@@ -676,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,
+9
View File
@@ -58,6 +58,15 @@
}
})();
// Registering this is what makes Chrome/Android offer the "Add to Home
// screen" install prompt -- a manifest link alone isn't enough. Served
// from /sw.js (not /static/sw.js) so its scope is the whole app.
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js");
});
}
// Shared display names for widget_type, everywhere one shows up in the
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = {
+8
View File
@@ -18,6 +18,14 @@ function renderDeviceStatusBar(device) {
}
const now = Date.now() / 1000;
const rows = [];
if (device.expected_next_checkin) {
const remaining = device.expected_next_checkin - now;
rows.push([
'Expected in',
remaining > 0 ? `~${formatDuration(remaining)}` : 'Any moment',
device.overdue,
]);
}
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push(['Last seen', `${ago} ago`, device.overdue]);
if (device.firmware_version) {
+93 -23
View File
@@ -114,7 +114,7 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Advanced configuration: color palette ----
// ---- Advanced configuration: color palette(s) ----
//
// Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview
@@ -122,9 +122,15 @@ document.getElementById('take-control').addEventListener('click', takeControl);
// the server already validates as #rrggbb); the R/G/B fields are purely
// an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch.
//
// Parameterized by classPrefix ("palette" for the main one, "photo-
// palette" for the photos-only one added alongside it) rather than
// duplicated wholesale -- there are exactly two real instances of this,
// not a speculative future one, and the two would otherwise be ~90
// near-identical lines apart.
function paletteHexInputs() {
return Array.from(document.querySelectorAll('.palette-hex'))
function paletteHexInputs(classPrefix) {
return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
}
@@ -140,45 +146,53 @@ function rgbFromHex(hex) {
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function paletteFieldsFor(index) {
function paletteFieldsFor(classPrefix, index) {
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
return {
hex: at(`${classPrefix}-hex`), r: at(`${classPrefix}-r`), g: at(`${classPrefix}-g`), b: at(`${classPrefix}-b`),
swatch: at(`${classPrefix}-swatch-preview`),
};
}
function syncPaletteFromHex(index) {
const f = paletteFieldsFor(index);
function syncPaletteFromHex(classPrefix, index) {
const f = paletteFieldsFor(classPrefix, index);
const rgb = rgbFromHex(f.hex.value);
if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value;
}
function syncPaletteFromRgb(index) {
const f = paletteFieldsFor(index);
function syncPaletteFromRgb(classPrefix, index) {
const f = paletteFieldsFor(classPrefix, index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex;
f.swatch.style.background = hex;
}
const palettePickerCount = paletteHexInputs().length;
for (let i = 0; i < palettePickerCount; i++) {
const f = paletteFieldsFor(i);
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
function wirePaletteInputs(classPrefix) {
const count = paletteHexInputs(classPrefix).length;
for (let i = 0; i < count; i++) {
const f = paletteFieldsFor(classPrefix, i);
f.hex.addEventListener('input', () => syncPaletteFromHex(classPrefix, i));
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(classPrefix, i)));
}
}
wirePaletteInputs('palette');
wirePaletteInputs('photo-palette');
// Sliders: live numeric readout next to each, no save until the button
// below is clicked.
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
['color_boost', 'contrast_boost', 'dither_strength', 'photo_dither_strength'].forEach((id) => {
const input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
});
async function savePalette(extra) {
async function savePalette(classPrefix, paletteFormKey, extra) {
const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs()) {
body.append('palette', input.value);
for (const input of paletteHexInputs(classPrefix)) {
body.append(paletteFormKey, input.value);
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -195,7 +209,7 @@ async function savePalette(extra) {
}
document.getElementById('palette-save').addEventListener('click', () => {
savePalette({
savePalette('palette', 'palette', {
color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value,
@@ -203,16 +217,65 @@ document.getElementById('palette-save').addEventListener('click', () => {
});
document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs();
const inputs = paletteHexInputs('palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex(i);
syncPaletteFromHex('palette', i);
});
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00';
});
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
savePalette('palette', 'palette', { palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
});
// Fills the table with a community-measured starting point (see the
// card's own explanatory text) -- doesn't save by itself, same as
// editing the hex/RGB fields by hand; the user still clicks Save (or
// Reset) to commit or discard it.
document.getElementById('palette-load-calibrated').addEventListener('click', () => {
const inputs = paletteHexInputs('palette');
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex('palette', i);
});
});
// ---- Photos configuration: its own separate palette/dithering ----
document.getElementById('photo-palette-save').addEventListener('click', () => {
savePalette('photo-palette', 'photo_palette', {
photo_dither_strength: document.getElementById('photo_dither_strength').value,
});
});
document.getElementById('photo-palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs('photo-palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex('photo-palette', i);
});
document.getElementById('photo_dither_strength').value = '1';
document.getElementById('photo_dither_strength_value').textContent = '1.00';
savePalette('photo-palette', 'photo_palette', { photo_palette_reset: 'true', photo_dither_strength: '1' });
});
// ---- Theme ----
document.getElementById('theme-save').addEventListener('click', async () => {
const body = new URLSearchParams({ theme: document.getElementById('theme-select').value });
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// ---- Preview: current photo vs. how it renders with saved settings ----
@@ -339,8 +402,15 @@ async function loadFirmwareCheck(force) {
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
statusEl.textContent = `Update available: v${data.latest_version}` +
(data.running_version ? ` (currently running v${data.running_version}).` : '.');
btn.style.display = 'inline-block';
} else if (data.latest_version && data.running_version && data.running_version !== data.latest_version) {
// Already staged (or auto-applied) but the frame hasn't woken up
// and picked it up yet -- not "up to date" until it actually has.
statusEl.textContent = `v${data.latest_version} staged -- applies next time the frame wakes ` +
`(currently running v${data.running_version}).`;
btn.style.display = 'none';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
+99 -40
View File
@@ -58,48 +58,31 @@
});
})();
// Live "how it's displaying" thumbnail. A real composite render (same
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow
// poll rather than something tighter like the 10s device-status poll --
// no need to hit Immich/calendar/whiteboard sources that often just for
// a header thumbnail. Click enlarges it in a dialog (which also fetches
// a fresh render); clicking the enlarged image refreshes it again.
// Now-displaying / up-next header preview pair. "Up next" is a real
// composite render (same pipeline /frame/image uses), not a cached
// snapshot, so it's on a slow poll rather than something tighter like
// the 10s device-status poll -- no need to hit Immich/calendar/
// whiteboard sources that often just for a header thumbnail, and it
// shows layout edits live as they're made. "Now displaying" is the
// opposite: exactly the bytes last actually sent to the device (see
// routers/device.py's _record_last_displayed), frozen until the
// device's next real wake even while the layout is being edited live --
// that contrast is the point of showing both side by side.
(function () {
var thumb = document.getElementById('frame-preview-thumb');
var dialog = document.getElementById('frame-preview-dialog');
var bigImg = document.getElementById('frame-preview-dialog-img');
var closeBtn = document.getElementById('frame-preview-dialog-close');
if (!thumb || !window.FRAME_BASE_API) return;
var nextThumb = document.getElementById('frame-preview-thumb');
var nextDialog = document.getElementById('frame-preview-dialog');
var nextBigImg = document.getElementById('frame-preview-dialog-img');
var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
var nowThumb = document.getElementById('frame-preview-now-thumb');
var nowDialog = document.getElementById('frame-preview-now-dialog');
var nowBigImg = document.getElementById('frame-preview-now-dialog-img');
var nowCloseBtn = document.getElementById('frame-preview-now-dialog-close');
if (!nextThumb || !window.FRAME_BASE_API) return;
function previewUrl() {
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
}
function refreshThumb() {
thumb.src = previewUrl();
}
// Opening the dialog (or clicking the big image inside it) fetches a
// fresh render and keeps the header thumb in sync, so this single path
// covers both "enlarge" and the old click-to-refresh behavior.
function refreshBig() {
var url = previewUrl();
bigImg.src = url;
thumb.src = url;
}
thumb.addEventListener('click', function () {
if (!dialog) { refreshThumb(); return; }
refreshBig();
dialog.showModal();
});
refreshThumb();
setInterval(refreshThumb, 60000);
if (dialog && bigImg && closeBtn) {
bigImg.addEventListener('click', refreshBig);
closeBtn.addEventListener('click', function () { dialog.close(); });
// Same backdrop-click-to-close trick as #widget-dialog: a click that
// lands on the dialog element itself (not its content box) means the
// backdrop was hit.
// Same backdrop-click-to-close trick as #widget-dialog: a click that
// lands on the dialog element itself (not its content box) means the
// backdrop was hit.
function closeOnBackdropClick(dialog) {
dialog.addEventListener('click', function (e) {
if (e.target !== dialog) return;
var rect = dialog.getBoundingClientRect();
@@ -107,4 +90,80 @@
if (!inside) dialog.close();
});
}
function nextUrl() {
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
}
function refreshNext() {
nextThumb.src = nextUrl();
}
// Opening the dialog (or clicking the big image inside it) fetches a
// fresh render and keeps the header thumb in sync, so this single path
// covers both "enlarge" and the old click-to-refresh behavior.
function refreshNextBig() {
var url = nextUrl();
nextBigImg.src = url;
nextThumb.src = url;
}
nextThumb.addEventListener('click', function () {
if (!nextDialog) { refreshNext(); return; }
refreshNextBig();
nextDialog.showModal();
});
refreshNext();
setInterval(refreshNext, 60000);
if (nextDialog && nextBigImg && nextCloseBtn) {
nextBigImg.addEventListener('click', refreshNextBig);
nextCloseBtn.addEventListener('click', function () { nextDialog.close(); });
closeOnBackdropClick(nextDialog);
}
// "Now displaying" fetches rather than sets .src directly: it needs to
// tell a 404 (device hasn't fetched yet) apart from a real image to
// show its own empty state instead of a broken-image icon, and reads
// the capture time off X-Displayed-At for the "N ago" tooltip.
if (nowThumb) {
var nowObjectUrl = null;
function refreshNow() {
fetch(`${window.FRAME_BASE_API}/now-displaying?t=${Date.now()}`)
.then(function (resp) {
if (!resp.ok) {
nowThumb.classList.add('frame-preview-thumb-empty');
nowThumb.removeAttribute('src');
nowThumb.title = "Now displaying -- hasn't shown anything yet";
return null;
}
var displayedAt = resp.headers.get('X-Displayed-At');
nowThumb.title = displayedAt
? `Now displaying -- ${formatDuration(Math.max(0, Date.now() / 1000 - parseFloat(displayedAt)))} ago -- click to enlarge`
: 'Now displaying -- click to enlarge';
return resp.blob();
})
.then(function (blob) {
if (!blob) return;
nowThumb.classList.remove('frame-preview-thumb-empty');
var url = URL.createObjectURL(blob);
var old = nowObjectUrl;
nowObjectUrl = url;
nowThumb.src = url;
if (old) URL.revokeObjectURL(old);
})
.catch(function () { /* transient failure -- leave the last-known thumb showing */ });
}
nowThumb.addEventListener('click', function () {
if (nowThumb.classList.contains('frame-preview-thumb-empty') || !nowDialog) return;
nowBigImg.src = nowThumb.src;
nowDialog.showModal();
});
refreshNow();
setInterval(refreshNow, 60000);
if (nowDialog && nowCloseBtn) {
nowCloseBtn.addEventListener('click', function () { nowDialog.close(); });
closeOnBackdropClick(nowDialog);
}
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "ESPresso Frame",
"short_name": "ESPresso",
"description": "Manage your e-ink photo frames.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f5f6f8",
"theme_color": "#2563eb",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+8
View File
@@ -0,0 +1,8 @@
// Presence-only service worker: satisfies the "installable" requirement
// (Chrome/Android in particular checks for a controlling SW with a fetch
// handler) without adding an offline cache -- every request just goes to
// the network as normal. Served from / (see app/main.py's /sw.js route)
// so its scope covers the whole app, not just /static/.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
self.addEventListener("fetch", (event) => event.respondWith(fetch(event.request)));
+67 -1
View File
@@ -156,6 +156,24 @@ button.linklike:hover { color: var(--text); background: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
.log-view-controls { display: flex; gap: 10px; align-items: center; margin: 10px 0; font-size: 13px; }
.log-view-controls a:not(.btn-inline) { color: var(--text-muted); }
.log-view-controls a.active { color: var(--accent); font-weight: 600; }
.log-view {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
max-height: 65vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: 12.5px;
line-height: 1.5;
color: var(--text);
}
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
@@ -243,6 +261,36 @@ input[type="range"] {
}
.card + .card { margin-top: 20px; }
/* Installed as a standalone app, the boxed-card look reads as "still a
website" -- flatten page-level cards into the page background so it
feels native. Cards inside the widget dialog keep their box: they're
grouping subsections of one form, not top-level page furniture. */
@media (display-mode: standalone) {
.card {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
padding: 20px 0;
}
.card + .card {
margin-top: 4px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
#widget-dialog-body .card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 22px;
}
#widget-dialog-body .card + .card {
margin-top: 20px;
padding-top: 22px;
border-top: none;
}
}
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; }
@@ -719,13 +767,24 @@ code {
}
.frame-name-edit button { margin-top: 0; }
.frame-preview-pair {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 12px;
vertical-align: middle;
}
.frame-preview-arrow {
color: var(--text-muted);
font-size: 16px;
line-height: 1;
}
.frame-preview-thumb {
height: 44px;
width: auto;
max-width: 130px;
object-fit: contain;
vertical-align: middle;
margin-left: 12px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface-alt);
@@ -733,6 +792,13 @@ code {
transition: opacity .12s ease;
}
.frame-preview-thumb:hover { opacity: 0.8; }
.frame-preview-thumb-empty {
opacity: 0.3;
cursor: default;
width: 60px;
font-size: 0; /* no src yet -- suppresses the browser's fallback alt-text render */
}
.frame-preview-thumb-empty:hover { opacity: 0.3; }
.frame-preview-dialog {
position: fixed;
@@ -13,6 +13,7 @@ function initBatteryDialog() {
e.preventDefault();
const body = new URLSearchParams({
battery_mode: document.getElementById('battery_mode').value,
battery_render_style: document.getElementById('battery_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -81,6 +81,7 @@ function initCalendarDialog() {
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
calendar_render_style: document.getElementById('calendar_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -198,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);
}
});
}
@@ -38,6 +38,7 @@ function initStaticDialog() {
e.preventDefault();
const body = new URLSearchParams({
display_mode: document.getElementById('display_mode').value,
static_render_style: document.getElementById('static_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
+2
View File
@@ -71,6 +71,7 @@ function initTasksDialog() {
const body = new URLSearchParams({
tasks_name: document.getElementById('tasks_name').value,
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
tasks_render_style: document.getElementById('tasks_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -89,6 +90,7 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
initBorderFields();
initFontScaleFields();
initButtonActionFields();
}
+1
View File
@@ -113,6 +113,7 @@ function initTextDialog() {
text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').value,
text_render_style: document.getElementById('text_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -14,6 +14,12 @@ function updateWeatherFieldVisibility() {
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
// Modern style is only built for current/daily (see app/html_render.py) --
// hourly/multi_city always render classic server-side regardless of this
// setting, so hide the row entirely rather than offer a choice that's a
// silent no-op.
document.getElementById('weather-render-style-row').style.display =
(mode === 'current' || mode === 'daily') ? '' : 'none';
}
function addWeatherWidgetCityRow(label) {
@@ -74,6 +80,7 @@ function initWeatherDialog() {
weather_units: document.getElementById('weather_units').value,
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
weather_daily_days: document.getElementById('weather_daily_days').value,
weather_render_style: document.getElementById('weather_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -90,6 +90,25 @@ function initWhiteboardDialog() {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
}
document.getElementById('whiteboard-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
whiteboard_render_style: document.getElementById('whiteboard_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadWhiteboardPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
// Shows whatever's already cached (cheap, no refetch) on open; the
// button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_widget_preview_
+4
View File
@@ -0,0 +1,4 @@
<nav class="tabs">
<a href="/admin" class="{% if active_admin_tab == 'main' %}active{% endif %}">Users &amp; Frames</a>
<a href="/admin/logs" class="{% if active_admin_tab == 'logs' %}active{% endif %}">Server Logs</a>
</nav>
+11 -2
View File
@@ -7,10 +7,19 @@
<button type="button" class="btn-inline" id="frame-name-save">Save</button>
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
</span>
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to enlarge">
<span class="frame-preview-pair">
<img id="frame-preview-now-thumb" class="frame-preview-thumb frame-preview-thumb-empty" alt="What the frame is currently displaying" title="Now displaying">
<span class="frame-preview-arrow" aria-hidden="true">&rarr;</span>
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame will show next" title="Up next -- live preview, updates as you edit the layout -- click to enlarge">
</span>
<dialog id="frame-preview-now-dialog" class="frame-preview-dialog">
<button type="button" id="frame-preview-now-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button>
<img id="frame-preview-now-dialog-img" alt="What the frame is currently displaying">
</dialog>
<dialog id="frame-preview-dialog" class="frame-preview-dialog">
<button type="button" id="frame-preview-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button>
<img id="frame-preview-dialog-img" alt="Live preview of what the frame is displaying" title="Click to refresh">
<img id="frame-preview-dialog-img" alt="Live preview of what the frame will show next" title="Click to refresh">
</dialog>
@@ -11,6 +11,12 @@
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
</select>
</label>
<label>Render style
<select id="battery_render_style">
<option value="classic" {% if not battery_cfg or battery_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn icon)</option>
<option value="modern" {% if battery_cfg and battery_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
@@ -40,6 +40,12 @@
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div>
<label>Render style
<select id="calendar_render_style">
<option value="classic" {% if calendar_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn)</option>
<option value="modern" {% if calendar_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental, all views)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
@@ -129,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;">
@@ -32,6 +32,12 @@
exactly without cropping (an image with a different aspect ratio
looks stretched); <strong>Shrink to fit</strong> shows the whole
image, letterboxed if needed.</p>
<label>Render style
<select id="static_render_style">
<option value="classic" {% if not static_cfg or static_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
<option value="modern" {% if static_cfg and static_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
@@ -55,12 +55,20 @@
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}>
<label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
</div>
<label>Render style
<select id="tasks_render_style">
<option value="classic" {% if task_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn checklist)</option>
<option value="modern" {% if task_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
@@ -43,6 +43,12 @@
<label>Background color
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
</label>
<label>Render style
<select id="text_render_style">
<option value="classic" {% if not text_cfg or text_cfg.render_style == 'classic' %}selected{% endif %}>Classic (hand-drawn text)</option>
<option value="modern" {% if text_cfg and text_cfg.render_style == 'modern' %}selected{% endif %}>Modern (experimental)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
@@ -11,6 +11,14 @@
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
</select>
</label>
<div id="weather-render-style-row">
<label>Render style
<select id="weather_render_style">
<option value="classic" {% if weather_cfg.render_style == "classic" %}selected{% endif %}>Classic (hand-drawn icons)</option>
<option value="modern" {% if weather_cfg.render_style == "modern" %}selected{% endif %}>Modern (experimental, current/daily only)</option>
</select>
</label>
</div>
<label>Weather source
<select id="weather_provider">
{% for value, label in weather_provider_labels.items() %}
@@ -50,6 +50,19 @@
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Settings</h2>
<form id="whiteboard-config-form">
<label>Render style
<select id="whiteboard_render_style">
<option value="classic" {% if not whiteboard_cfg or whiteboard_cfg.render_style == 'classic' %}selected{% endif %}>Classic (no frame)</option>
<option value="modern" {% if whiteboard_cfg and whiteboard_cfg.render_style == 'modern' %}selected{% endif %}>Modern (rounded-corner card, experimental)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
@@ -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>
+2 -8
View File
@@ -4,6 +4,8 @@
{% block page_title %}Administration{% endblock %}
{% block content %}
{% include "_admin_tabs.html" %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
@@ -107,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 }}">
+30
View File
@@ -0,0 +1,30 @@
{% extends "app_base.html" %}
{% block title %}Server Logs{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% include "_admin_tabs.html" %}
<section class="card">
<div class="card-title-row">
<h2 class="card-title">Server Logs</h2>
<a href="/admin/logs/download" class="secondary btn-inline">Download full log</a>
</div>
{% if not log_exists %}
<p class="sub">No log file yet -- nothing has been logged since this server last started.</p>
{% else %}
<p class="sub">Last {{ log_lines }} lines of <code>{{ log_path }}</code>. Rotates at ~2MB
(older entries roll into <code>{{ log_path }}.1</code>, etc. -- not shown here; use
"Download full log" for just the current file).</p>
<div class="log-view-controls">
{% for n in [200, 500, 2000, 5000] %}
<a href="/admin/logs?lines={{ n }}" class="{% if log_lines == n %}active{% endif %}">{{ n }}</a>
{% endfor %}
<a href="/admin/logs?lines={{ log_lines }}" class="secondary btn-inline">Refresh</a>
</div>
<pre class="log-view">{{ log_text }}</pre>
{% endif %}
</section>
{% endblock %}
+8
View File
@@ -17,6 +17,14 @@
})();
</script>
<link rel="stylesheet" href="/static/theme.css">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" href="/static/icons/favicon.png">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="ESPresso">
{% block extra_head %}{% endblock %}
</head>
<body>
+8
View File
@@ -17,6 +17,14 @@
})();
</script>
<link rel="stylesheet" href="/static/theme.css">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" href="/static/icons/favicon.png">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="ESPresso">
{% block extra_head %}{% endblock %}
</head>
<body>
+69 -5
View File
@@ -139,11 +139,12 @@
<details class="card">
<summary class="card-title">Advanced configuration</summary>
<p class="sub">Color-quantization values used when dithering photos
for this panel -- approximations by default, since exact primaries
aren't published. Tune them by comparing a rendered photo against
the physical panel; different panel units can vary enough to be
worth calibrating per frame.</p>
<p class="sub">Color-quantization values used for every widget
<strong>except photos</strong> (which has its own separate
settings below) -- approximations by default, since exact
primaries aren't published. Tune them by comparing a rendered
widget against the physical panel; different panel units can
vary enough to be worth calibrating per frame.</p>
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
@@ -187,6 +188,68 @@
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
<button type="button" class="secondary" id="palette-load-calibrated">Load calibrated Spectra 6 preset</button>
<p class="sub" style="margin-top: 8px;">Experimental: a community-measured
starting point (not this specific panel) -- fills the table above,
doesn't save by itself. Real Spectra 6 ink is duller than the
idealized defaults; this may or may not match your actual unit.
Compare against the physical panel before keeping it.</p>
</details>
<details class="card">
<summary class="card-title">Theme</summary>
<p class="sub">A curated visual style for widgets using the
"modern" (experimental) render style -- font, corner radius,
shadow, and header accent. Widgets rendered in the classic
style are unaffected. Photos are unaffected too (see Photos
configuration below).</p>
<label>Theme
<select id="theme-select">
{% for key, t in themes.items() %}
<option value="{{ key }}" {% if frame.theme == key %}selected{% endif %}>{{ t.label }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="theme-save" style="margin-top: 16px;">Save</button>
</details>
<details class="card">
<summary class="card-title">Photos configuration</summary>
<p class="sub">Palette and dithering used <strong>only</strong> by
the photos widget, independent of Advanced configuration above --
lets you tune the rest of this frame's widgets (e.g. a "modern"
HTML-rendered look) without changing what looks best for actual
photographs, or vice versa.</p>
<div class="palette-table-wrap">
<table class="palette-table">
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
<tbody>
{% set current_photo_palette = frame.photo_palette_rgb or default_palette_rgb %}
{% set current_photo_hex = palette_to_hex(current_photo_palette) %}
{% for label in palette_labels %}
<tr>
<td><span class="photo-palette-swatch-preview" data-index="{{ loop.index0 }}"
style="background: {{ current_photo_hex[loop.index0] }};"></span></td>
<td>{{ label }}</td>
<td><input type="text" class="photo-palette-hex" id="photo_palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
value="{{ current_photo_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
spellcheck="false" autocomplete="off"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-r" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][0] }}"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-g" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][1] }}"></td>
<td><input type="number" class="photo-palette-rgb photo-palette-b" data-index="{{ loop.index0 }}"
min="0" max="255" value="{{ current_photo_palette[loop.index0][2] }}"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<label>Dithering strength <span class="slider-value" id="photo_dither_strength_value">{{ "%.2f" | format(frame.photo_dither_strength) }}</span>
<input type="range" id="photo_dither_strength" min="0" max="1" step="0.05" value="{{ frame.photo_dither_strength }}">
</label>
<button type="button" class="secondary" id="photo-palette-save" style="margin-top: 16px;">Save</button>
<button type="button" class="secondary" id="photo-palette-reset">Reset to defaults</button>
</details>
<section class="card">
@@ -221,6 +284,7 @@
<script>
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
window.CALIBRATED_SPECTRA6_HEX = {{ calibrated_spectra6_hex | tojson }};
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
</script>
<script src="/static/device_status_bar.js"></script>
+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>
@@ -0,0 +1,39 @@
{% 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). 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 %}
<div class="day-empty" style="font-size: {{ body_size }}px;">Nothing scheduled</div>
{% endif %}
{% for row in rows %}
<div class="day-row" style="height: {{ row_h }}px;">
<div class="day-chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
<div class="day-time" style="font-size: {{ body_size }}px;">{{ row.time }}</div>
<div class="day-summary" style="font-size: {{ body_size }}px;">{{ row.summary }}</div>
</div>
{% endfor %}
{% if more_count %}<div class="day-more" style="font-size: {{ body_size }}px;">+{{ more_count }} more</div>{% endif %}
</div>
</div>
{% endmacro %}
@@ -0,0 +1,46 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.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 {
width: {{ icon_w }}px;
height: {{ icon_h }}px;
border: {{ stroke }}px solid #000000;
border-radius: {{ icon_radius }}px;
padding: {{ stroke }}px;
}
.icon-fill {
width: {{ fill_pct }}%;
height: 100%;
border-radius: {{ fill_radius }}px;
background: linear-gradient(180deg, {{ fill_color }}, {{ fill_color_dark }});
}
.icon-nub {
width: {{ nub_w }}px;
height: {{ nub_h }}px;
background: #000000;
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
}
.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="wrap">
<div class="icon-wrap">
<div class="icon-body"><div class="icon-fill"></div></div>
<div class="icon-nub"></div>
</div>
<div>
<div class="pct">{{ percent }}%</div>
<div class="lines">{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}</div>
</div>
</div>
</body></html>
@@ -0,0 +1,35 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
display: flex;
flex-direction: column;
}
.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 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; 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="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>
@@ -0,0 +1,71 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
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; }
{#- 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
dithering at small sizes (thin/low-contrast anti-aliased edges
have little "mass" to survive the bias+threshold step), and this
cell combined both, which degraded out-of-month day numbers into
unrecognizable speckle -- classic PIL's own de-emphasis trick
(weight instead of gray, see calendar_render._build_month's
docstring) doesn't transfer safely to this render path. #}
.day-num { font-size: {{ day_size }}px; font-weight: 700; color: #17233b; }
.day-num.out-of-month { font-size: {{ day_size * 0.8 }}px; }
.day-num.today {
display: inline-block; background: {{ accent_start }}; color: #ffffff;
border-radius: 4px; padding: 0 4px;
}
.dots { display: flex; gap: 3px; margin-top: 3px; align-items: center; flex-wrap: wrap; }
.dot { width: {{ dot_size }}px; height: {{ dot_size }}px; border-radius: 50%; flex: 0 0 auto; }
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
</style></head>
<body>
<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>
<div class="weeks">
{% for week in weeks %}
<div class="week-row">
{% for day in week %}
<div class="day-cell">
<span class="day-num {% if day.is_today %}today{% elif not day.in_month %}out-of-month{% endif %}">{{ day.day_num }}</span>
{% if day.dots %}
<div class="dots">
{% for c in day.dots %}<div class="dot" style="background:{{ c }};"></div>{% endfor %}
{% if day.more_count %}<span class="dot-more">+{{ day.more_count }}</span>{% endif %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</body></html>
@@ -0,0 +1,42 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
{#- 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: 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; 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="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, header_h, accent_h) }}
{% endfor %}
</div>
</body></html>
@@ -0,0 +1,67 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
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; }
{#- 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. #}
height: {{ header_h }}px;
flex: 0 0 auto;
padding: 2px 6px 4px;
overflow: hidden;
}
.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; }
.col-summary {
flex: 1 1 0; min-width: 0;
font-size: {{ chip_size }}px; color: #17233b; line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.col-more { font-size: {{ chip_size }}px; color: #5b6674; }
</style></head>
<body>
<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>
{% endfor %}
</div>
</div>
</body></html>
@@ -0,0 +1,27 @@
<!doctype html>
<html><head><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
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;
}
.card img {
width: 100%; height: 100%; display: block;
/* "contain", not "cover" -- the source image already went through
compose_into's own crop/fit (e.g. whiteboard's deliberate
letterbox-never-crop mode), so this card must not re-crop it;
the gutter inset is small relative to typical widget sizes, so
"contain" leaves at most a sliver of background visible, not a
real letterbox. */
object-fit: contain;
}
</style></head>
<body>
<div class="card"><img src="data:image/png;base64,{{ image_b64 }}"></div>
</body></html>
@@ -0,0 +1,59 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.wrap {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
display: flex;
flex-direction: column;
}
.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;
}
.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; }
.box {
width: {{ box_size }}px; height: {{ box_size }}px; border-radius: 3px; flex: 0 0 auto;
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; 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;
}
.empty { font-size: {{ body_size }}px; color: #5b6674; padding-top: 4px; }
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
</style></head>
<body>
<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>
{% endif %}
{% for row in rows %}
<div class="row">
<div class="chip">{% for c in row.colors %}<span style="background:{{ c }};"></span>{% endfor %}</div>
<div class="box {% if row.done %}done{% endif %}"></div>
{% if row.due %}<div class="due">{{ row.due }}</div>{% endif %}
<div class="summary">{{ row.summary }}</div>
</div>
{% endfor %}
{% if more_count %}<div class="more">+{{ more_count }} more</div>{% endif %}
</div>
</div>
</body></html>
@@ -0,0 +1,35 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "TextFont"; src: url("file://{{ font_regular }}"); font-weight: 400; font-style: normal; }
@font-face { font-family: "TextFont"; src: url("file://{{ font_bold }}"); font-weight: 700; font-style: normal; }
@font-face { font-family: "TextFont"; src: url("file://{{ font_italic }}"); font-weight: 400; font-style: italic; }
@font-face { font-family: "TextFont"; src: url("file://{{ font_bold_italic }}"); font-weight: 700; font-style: italic; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: {{ w }}px; height: {{ h }}px; background: {{ bg_color }}; }
.wrap {
width: {{ w - margin * 2 }}px;
min-height: {{ h - margin * 2 }}px;
margin: {{ margin }}px;
font-family: "TextFont", sans-serif;
font-size: {{ size }}px;
line-height: {{ line_height }};
text-align: {{ align }};
color: #000000;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
word-wrap: break-word;
}
p { min-height: 1em; }
</style></head>
<body>
<div class="wrap">
{% for paragraph in paragraphs %}
<p>
{% if not paragraph %}&nbsp;{% endif %}
{% for run in paragraph %}<span style="{% if run.bold %}font-weight:700;{% endif %}{% if run.italic %}font-style:italic;{% endif %}{% if run.underline %}text-decoration:underline;{% endif %}{% if run.color %}color:{{ run.color }};{% endif %}{% if run.bg %}background:{{ run.bg }};{% endif %}">{{ run.text }}</span>{% endfor %}
</p>
{% endfor %}
</div>
</body></html>
@@ -0,0 +1,37 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.wrap {
width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
display: flex; flex-direction: column; justify-content: space-between;
}
.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="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>
@@ -0,0 +1,45 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_regular }}"); font-weight: 400; }
@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; }
.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%);
}
.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%;
}
.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;
}
.icon { font-size: {{ icon_size }}px; line-height: 1; }
.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="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-label">{{ d.label }}</div>
<div class="icon">{{ d.emoji }}</div>
<div class="temps"><div class="high">{{ d.high }}&deg;</div><div class="low">{{ d.low }}&deg;{{ unit_suffix }}</div></div>
</div>
{% endfor %}
</div>
</div>
</body></html>
+202
View File
@@ -0,0 +1,202 @@
"""Curated theme presets for "modern" (HTML/CSS) style widgets, plus the
font-family table every modern-style widget -- and widgets/text.py's own
classic PIL path -- resolves fonts through. The font table used to be
widgets/text.py's private property; it's hoisted here (text.py now
imports it back) since html_render.py's build_text/build_daily/etc. all
need to resolve a theme's font_family to real font files too, not just
the text widget.
Inspired by Tesserae's (github.com/dmellok/tesserae, AGPL-3.0) three-
layer CSS custom-property theme system -- primitives, semantic per-
theme tokens, component tokens -- reimplemented here as original Python/
CSS rather than copied (see docs/widgets.md and this repo's CLAUDE.md on
copyleft dependencies).
A theme is purely **stylistic**: font family, corner radius, drop
shadow, header gradient on/off, and an accent hue for the widgets that
have an actual header/accent region to dither richer (see
html_render.ordered_dither_regions). It never touches **functional**
color-coding -- battery's charge-level red/yellow/green, calendar/tasks'
per-owner event color chips, and text's user-authored inline run colors
are status/identity signals, not style choices, and no theme may
recolor them.
Rich accent_hex values are not restricted to the 6 exact panel inks --
`ordered_dither_regions` approximates them via denser Bayer stippling in
just the accent region (verified directly: terracotta/ochre/moss/teal/
slate-blue/plum swatches all resolve to a believable multi-ink
approximation at amplitude ~130, the same mechanism -- spatial
dithering, not flat quantization -- Tesserae's own calibrated-palette
Floyd-Steinberg uses, just ordered instead of diffused)."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from PIL import ImageFont
from . import panel_style
_FONT_DIR = Path(__file__).resolve().parent / "fonts"
# --- Font family table (hoisted from widgets/text.py) -----------------
DEFAULT_FONT_FAMILY = "sans"
FONT_FAMILIES: dict[str, str] = {
"sans": "Sans-serif (Noto Sans)",
"inter": "Inter",
"source_sans": "Source Sans",
"serif": "Serif (Noto Serif)",
"elegant": "Elegant serif (Crimson Text)",
"slab": "Slab serif (Arvo)",
"mono": "Monospace (IBM Plex Mono)",
}
_FONT_FILES = {
"sans": {
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
},
"inter": {
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
},
"source_sans": {
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
},
"serif": {
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
},
"elegant": {
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
},
"slab": {
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
},
"mono": {
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
},
}
def font_path(family: str, bold: bool, italic: bool) -> Path:
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
return _FONT_DIR / files[(bold, italic)]
@lru_cache(maxsize=256)
def font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(font_path(family, bold, italic)), size)
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb)
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))
# --- Theme presets -------------------------------------------------------
DEFAULT_THEME = "classic"
# accent_hex=None means "keep each widget's own classic THEME_* ink" --
# resolve_theme() below is what actually looks that up -- so "classic" is
# deliberately a no-visual-change default, byte-identical to how modern
# style already rendered before this theme system existed.
THEMES: dict[str, dict] = {
"classic": {
"label": "Classic", "accent_hex": None,
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 48.0,
},
"terracotta": {
"label": "Terracotta", "accent_hex": "#a84b2a",
"font_family": "inter", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 130.0,
},
"ochre": {
"label": "Ochre", "accent_hex": "#b8892b",
"font_family": "slab", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 130.0,
},
"moss": {
"label": "Moss", "accent_hex": "#4f6f36",
"font_family": "serif", "radius": 4, "shadow": False,
"gradient": False, "accent_amplitude": 130.0,
},
"teal": {
"label": "Teal", "accent_hex": "#2b7a78",
"font_family": "source_sans", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": True, "accent_amplitude": 130.0,
},
"slate": {
"label": "Slate", "accent_hex": "#3f5a88",
"font_family": "inter", "radius": 0, "shadow": False,
"gradient": False, "accent_amplitude": 130.0,
},
"plum": {
"label": "Plum", "accent_hex": "#6a3b5e",
"font_family": "elegant", "radius": panel_style.CARD_RADIUS, "shadow": True,
"gradient": False, "accent_amplitude": 130.0,
},
}
# "classic" theme's accent_hex is None, meaning "keep this specific
# widget kind's own pre-theme-system look exactly" -- for tasks/calendar
# that's their classic THEME_TASKS/THEME_CALENDAR ink (a flat color, both
# gradient stops equal, since neither ever had a gradient header before
# this system existed); weather's modern style never went through
# panel_style.THEME at all -- its header was always this fixed blue
# gradient (see html_render.py's now-removed ACCENT_START/ACCENT_END
# constants) -- preserved here byte-for-byte so "classic" stays a
# genuinely no-visual-change default for every widget kind that shipped
# before themes existed.
_CLASSIC_WEATHER_GRADIENT = ("#1c4fd6", "#6fa8ff")
def resolve_theme(theme_name: str | None, widget_kind: str, palette_rgb: list | None) -> dict:
"""Concrete, ready-to-render values for one widget's modern-style
build_* function: accent_hex/accent_hex_dark (a gradient's two CSS
stops -- both equal when the theme has no gradient), font_family
(validated against FONT_FAMILIES) plus its resolved font_regular/
font_bold file paths, radius, shadow, gradient, and accent_amplitude
(for ordered_dither_regions' header rect -- unused by widgets that
dither their header at the base amplitude only, see module
docstring). widget_kind is one of panel_style.THEME's keys
("calendar"/"tasks"/"weather") plus "battery"/"text"/"static"/
"whiteboard" for the widgets that have no classic THEME_* ink of
their own -- those fall back to BLACK when theme_name is "classic"
or unrecognized."""
theme = THEMES.get(theme_name or DEFAULT_THEME, THEMES[DEFAULT_THEME])
accent_hex = theme["accent_hex"]
if accent_hex is None:
if widget_kind == "weather":
accent_hex, accent_hex_dark = _CLASSIC_WEATHER_GRADIENT
else:
ink_index = panel_style.THEME.get(widget_kind, panel_style.BLACK)
accent_hex = accent_hex_dark = _rgb_to_hex(panel_style.ink(palette_rgb, ink_index))
else:
accent_hex_dark = accent_hex if not theme["gradient"] else _darken_hex(
tuple(int(accent_hex[i:i + 2], 16) for i in (1, 3, 5))
)
family = theme["font_family"] if theme["font_family"] in FONT_FAMILIES else DEFAULT_FONT_FAMILY
return {
"theme_name": theme_name if theme_name in THEMES else DEFAULT_THEME,
"accent_hex": accent_hex,
"accent_hex_dark": accent_hex_dark,
"font_family": family,
"font_regular": str(font_path(family, False, False)),
"font_bold": str(font_path(family, True, False)),
"radius": theme["radius"],
"shadow": theme["shadow"],
"gradient": theme["gradient"],
"accent_amplitude": theme["accent_amplitude"],
}
+75 -63
View File
@@ -33,23 +33,27 @@ from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
from . import panel_style
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
MARGIN = 20
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned -- every column-width/icon-size calc below was measured
# against 20px). BG/FG are this module's own cloud-icon fill/outline and
# fog-line color (see draw_cloud/draw_weather_icon), not a text-emphasis
# concern -- those live in panel_style (font_bold/font_regular, no MUTED
# gray -- see its module docstring for why).
MARGIN = panel_style.CONTENT_MARGIN
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (0, 0, 0)
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
already this module's BG/FG) -- same resolution idiom as
calendar_render.py's _event_colors, so a custom palette override
(Frame.palette_rgb) still gets its own actual yellow/blue, and every
fill stays an exact, ditherless palette match either way."""
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
already this module's BG/FG) -- thin wrapper over panel_style.ink
(which generalized this same resolution idiom), kept so every
draw_weather_icon call site below doesn't need touching."""
return panel_style.ink(palette_rgb, index)
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
@@ -203,14 +207,17 @@ def _format_hour_label(iso_time: str) -> str:
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
min_size: int = 9) -> int:
min_size: int = 9, font_loader=panel_style.font_bold) -> int:
"""Largest size <= max_size at which every string in `texts` fits
within max_width -- used to size a per-column label/temp font against
the actual column width instead of an icon-radius-derived guess,
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
next column at a large icon size on a narrow column."""
next column at a large icon size on a narrow column. Measured against
`font_loader` (default Inter Bold -- the wider of the two weights a
column actually mixes, a label in Regular and a temp in Bold, so
fitting against Bold keeps both safely inside max_width)."""
for size in range(max_size, min_size - 1, -1):
font = ImageFont.load_default(size=size)
font = font_loader(size)
if all(draw.textlength(t, font=font) <= max_width for t in texts):
return size
return min_size
@@ -232,29 +239,28 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
(callers normally catch that earlier and show a placeholder instead,
but this degrades to a blank canvas rather than erroring either
way)."""
img = Image.new("RGB", (target_w, target_h), BG)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
if not entry:
return img
draw = ImageDraw.Draw(img)
icon_r = max(20, min(target_w, target_h) // 4)
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
icon_r = max(20, min(cw, ch) // 4)
cx, cy = cx0 + cw // 2, cy0 + ch // 2 - icon_r // 2
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(target_w, target_h) // 3)
temp_font = ImageFont.load_default(size=temp_size)
temp_size = max(24, min(cw, ch) // 3)
temp_font = panel_style.font_bold(temp_size)
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
temp_y = cy + icon_r + 12
draw_text(img, (target_w // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
draw_text(img, (cx0 + cw // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
if city_label:
label_size = max(12, temp_size // 3)
label_font = ImageFont.load_default(size=label_size)
label_font = panel_style.font_regular(label_size)
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
draw_text(img, (target_w // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font, MUTED)
draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font)
return img
@@ -265,19 +271,23 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
fetch_hourly), each showing an hour label, icon, and temp. Same
"draw however many fit" graceful degradation as draw_weather_row if
the box is too narrow for every tick."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
text_x0 = MARGIN
text_w = target_w - MARGIN * 2
y = MARGIN
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
text_x0 = cx0 + MARGIN
text_w = cw - MARGIN * 2
y = cy0 + MARGIN
title_size = max(14, min(target_w, target_h) // 16)
title_size = max(14, min(cw, ch) // 16)
if city_label:
title_font = ImageFont.load_default(size=title_size)
draw_text(img, (text_x0, y), city_label, title_font)
y += title_size + 10
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
# A filled header bar (this widget's chosen accent is black, not
# a color, so the hand-drawn icons below stay the star -- see
# panel_style module docstring) replaces the old plain title +
# thin rule line.
header_h = title_size + 20
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
panel_style.theme_color("weather", palette_rgb))
title_font = panel_style.font_bold(title_size)
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
y = cy0 + header_h + 12
# Capped to however many columns actually fit at a legible width
# (narrower widgets/smaller intervals just show fewer ticks) rather
@@ -290,23 +300,24 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
if not ticks:
return img
col_w = max(1, text_w // len(ticks))
icon_r = max(10, min(col_w // 3, (target_h - y - MARGIN) // 4))
icon_r = max(10, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
time_labels = [_format_hour_label(e["time"]) for e in ticks]
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
label_font = ImageFont.load_default(size=label_size)
time_font = panel_style.font_regular(label_size)
temp_font = panel_style.font_bold(label_size)
for i, entry in enumerate(ticks):
cx = text_x0 + i * col_w + col_w // 2
time_label = time_labels[i]
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
tbbox = draw.textbbox((0, 0), time_label, font=time_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font)
cy = y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
tempbbox = draw.textbbox((0, 0), temp_label, font=temp_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font)
return img
@@ -317,40 +328,41 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
configured day count by app/weather's provider fetch_daily -- this
just draws whatever it's handed, same "stop once it doesn't fit"
graceful degradation as draw_weather_row)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
text_x0 = MARGIN
text_w = target_w - MARGIN * 2
y = MARGIN
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
text_x0 = cx0 + MARGIN
text_w = cw - MARGIN * 2
y = cy0 + MARGIN
title_size = max(14, min(target_w, target_h) // 16)
title_size = max(14, min(cw, ch) // 16)
if city_label:
title_font = ImageFont.load_default(size=title_size)
draw_text(img, (text_x0, y), city_label, title_font)
y += title_size + 10
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
header_h = title_size + 20
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
panel_style.theme_color("weather", palette_rgb))
title_font = panel_style.font_bold(title_size)
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
y = cy0 + header_h + 12
days = list(daily.items())
if not days:
return img
col_w = max(1, text_w // len(days))
icon_r = max(12, min(col_w // 3, (target_h - y - MARGIN) // 4))
icon_r = max(12, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
label_font = ImageFont.load_default(size=label_size)
label_font = panel_style.font_regular(label_size)
temp_font = panel_style.font_bold(label_size)
for i, (_, d) in enumerate(days):
x0 = text_x0 + i * col_w
label, temps = labels[i], temps_strs[i]
lbbox = draw.textbbox((0, 0), label, font=label_font)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font)
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
tbbox = draw.textbbox((0, 0), temps, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
tbbox = draw.textbbox((0, 0), temps, font=temp_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font)
return img
@@ -360,10 +372,9 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
reuses draw_weather_row (the same layout calendar_render.py's
embedded strip uses), just as the whole widget's own content instead
of a strip above an agenda day."""
img = Image.new("RGB", (target_w, target_h), BG)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
if not cities:
return img
draw = ImageDraw.Draw(img)
# Just the city name on-panel ("Portland", not the full disambiguated
# "Portland, Oregon, United States") -- that fuller form matters for
# telling apart geocoder candidates when adding a city (see
@@ -372,7 +383,7 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
# calendar_render.py's _weather_for_day already does for its own
# embedded strip.
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
text_w = target_w - MARGIN * 2
text_w = cw - MARGIN * 2
# Sized against how many entries actually need to fit side by side,
# not just the box's height -- an icon/font picked from target_h
# alone (as this used to do) drew each entry so wide that only the
@@ -380,13 +391,14 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
# doesn't fit" degradation silently dropped every city after it,
# even in an ordinary-sized widget with plenty of cities configured.
col_w = max(1, text_w // len(cities))
icon_r = max(10, min(col_w // 6, target_h // 6, 40))
icon_r = max(10, min(col_w // 6, ch // 6, 40))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
font = ImageFont.load_default(size=font_size)
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r,
font_loader=panel_style.font_regular)
font = panel_style.font_regular(font_size)
y = max(cy0 + MARGIN, cy0 + (ch - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, cx0 + MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
show_labels=True, palette_rgb=palette_rgb)
return img
+7 -5
View File
@@ -7,17 +7,19 @@ fraction of the panel, so its placeholder needs to scale down with it.
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw
_BG = (245, 245, 245)
_FG = (90, 90, 90)
from .. import panel_style
from ..image_pipeline import draw_text
_BG = (255, 255, 255)
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
img = Image.new("RGB", (target_w, target_h), _BG)
draw = ImageDraw.Draw(img)
font_size = max(10, min(20, target_h // 8))
font = ImageFont.load_default(size=font_size)
font = panel_style.font_regular(font_size)
line_h = font_size + 4
total_h = line_h * len(lines)
y = max(4, (target_h - total_h) // 2)
@@ -25,6 +27,6 @@ def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.I
bbox = draw.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
x = max(4, (target_w - line_w) // 2)
draw.text((x, y), line, fill=_FG, font=font)
draw_text(img, (x, y), line, font)
y += line_h
return img
+57 -64
View File
@@ -17,10 +17,11 @@ from __future__ import annotations
import io
import time
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from ..image_pipeline import DEFAULT_PALETTE_RGB, _quantize, draw_text, logical_render_size
from .. import panel_style
from ..image_pipeline import _quantize, draw_text, logical_render_size
from ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s
from ._shared import placeholder_image
@@ -28,45 +29,16 @@ from ._shared import placeholder_image
ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {}
BG = (255, 255, 255)
MUTED = (110, 110, 110)
# Same thresholds/colors as manage_overlay.py's own battery glyph (not
# shared code -- that one draws onto the manage-QR overlay in a fixed
# small size, this one fills an arbitrary widget region -- but the
# "how worried should I be" color story should read the same wherever a
# battery glyph shows up on a panel). Exact panel ink RGB values, not
# arbitrary reds/yellows/greens -- a flat fill already at a palette
# color quantizes with zero dithering error once the whole composited
# canvas gets quantized, where an off-palette color would dither into a
# visible speckle at these small on-panel sizes.
_LOW = DEFAULT_PALETTE_RGB[3] # red
_MEDIUM = DEFAULT_PALETTE_RGB[2] # yellow
_HIGH = DEFAULT_PALETTE_RGB[5] # green
def _fill_color(percent: int) -> tuple[int, int, int]:
if percent <= 15:
return _LOW
if percent <= 40:
return _MEDIUM
return _HIGH
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int) -> None:
stroke = max(2, icon_h // 12)
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
palette_rgb: list | None = None) -> None:
"""Centers panel_style.draw_battery_icon (top-left-anchored) under
`cx` -- this widget's own layout picks a center point, that helper's
shared implementation (also used by manage_overlay.py's battery
readout) just needs a top-left corner."""
nub_w = max(3, icon_w // 10)
nub_h = icon_h // 2
x0 = cx - (icon_w + nub_w) // 2
y0 = top
inner_x0, inner_y0 = x0 + stroke, y0 + stroke
inner_x1, inner_y1 = x0 + icon_w - stroke, y0 + icon_h - stroke
fill_x1 = inner_x0 + round((inner_x1 - inner_x0) * (max(0, min(100, percent)) / 100))
if fill_x1 > inner_x0:
draw.rectangle([inner_x0, inner_y0, fill_x1, inner_y1], fill=_fill_color(percent))
draw.rectangle([x0, y0, x0 + icon_w, y0 + icon_h], outline=(0, 0, 0), width=stroke)
nub_y = y0 + (icon_h - nub_h) // 2
draw.rectangle([x0 + icon_w, nub_y, x0 + icon_w + nub_w, nub_y + nub_h], fill=(0, 0, 0))
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
def _format_estimate(seconds: float) -> str:
@@ -88,6 +60,21 @@ def _format_age(as_of: float) -> str:
return f"{round(delta / 86400)}d ago"
def _lines_for(mode: str, frame: Frame, db: Session) -> list[str]:
"""The 0-2 caption lines "detailed" mode shows below the percent --
shared by both render styles so the estimate/age formatting only
lives in one place."""
if mode != "detailed":
return []
lines = []
estimate_s = battery_estimate_s(frame, db)
if estimate_s is not None:
lines.append(_format_estimate(estimate_s))
if frame.battery_as_of:
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
return lines
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
@@ -99,40 +86,46 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
cfg = db.get(BatteryWidgetConfig, widget.id)
mode = cfg.mode if cfg else "detailed"
palette_rgb = frame.palette_rgb
lines = _lines_for(mode, frame, db)
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
cx = target_w // 2
if cfg and cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it (same reasoning as
# image_pipeline.render_placeholder's local `import qrcode`).
from .. import html_render
icon_h = max(20, min(target_w, target_h) // 3)
return html_render.build_battery(percent, lines, target_w, target_h, palette_rgb, frame.theme)
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
cx = cx0 + cw // 2
icon_h = max(20, min(cw, ch) // 3)
icon_w = int(icon_h * 1.8)
icon_top = max(4, target_h // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent)
icon_top = max(4, cy0 + ch // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
pct_font_size = max(18, min(target_w, target_h) // 3)
pct_font = ImageFont.load_default(size=pct_font_size)
# The percent number picks up the icon's own charge-level color
# (red/yellow/green) instead of plain black -- ties the two into one
# visual statement rather than "colored icon, black number".
pct_font_size = max(18, min(cw, ch) // 3)
pct_font = panel_style.font_bold(pct_font_size)
pct_text = f"{percent}%"
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
pct_y = icon_top + icon_h + 10
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font)
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
panel_style.battery_fill_color(percent, palette_rgb))
if mode == "detailed":
lines = []
estimate_s = battery_estimate_s(frame, db)
if estimate_s is not None:
lines.append(_format_estimate(estimate_s))
if frame.battery_as_of:
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
small_font_size = max(11, pct_font_size // 3)
small_font = ImageFont.load_default(size=small_font_size)
y = pct_y + pct_font_size + 12
for line in lines:
if y + small_font_size > target_h - 4:
break
lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font, MUTED)
y += small_font_size + 6
small_font_size = max(11, pct_font_size // 3)
small_font = panel_style.font_regular(small_font_size)
y = pct_y + pct_font_size + 12
for line in lines:
if y + small_font_size > cy0 + ch - 4:
break
lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
y += small_font_size + 6
return img
+16 -1
View File
@@ -19,6 +19,8 @@ from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from zoneinfo import ZoneInfo
from ..calendar_render import _build
from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget
@@ -47,11 +49,24 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
events, fetch_summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if cfg.weather_enabled else None
if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it.
from .. import calendar_html_render
tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
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, 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,
)
+20 -3
View File
@@ -8,7 +8,23 @@ take down the whole panel's render just because one region out of
several couldn't be composed this cycle; it falls back to a small
placeholder instead, the same resilience calendar mode's old photo-inlay
already had (see routers/device.py's `except HTTPException: pass` around
its own inlay fetch)."""
its own inlay fetch).
Unlike every other widget type, render() quantizes its own output
(against Frame.photo_palette_rgb/photo_dither_strength, not the main
palette_rgb/dither_strength the rest of the frame uses) before
returning, so a frame can tune its other widgets' look (e.g. the
"modern" HTML-rendered widgets' Bayer dithering) independently of
whatever looks best for actual photographs -- see image_pipeline.
render_panel's docstring for why this is safe to do per-widget without
a shared-canvas seam risk. One small, accepted edge case: widget
borders are always drawn afterward (routers/device.py's
_render_one_widget) against the *main* palette_rgb, so a border on a
photos widget whose photo_palette_rgb genuinely diverges from
palette_rgb can sit against already-quantized-to-a-different-reference
photo pixels -- cosmetically arguable, not a bug, and not worth
special-casing border resolution for what's a deliberate, uncommon
customization."""
from __future__ import annotations
@@ -18,7 +34,7 @@ from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours
from ..db import widget_locked
from ..image_pipeline import compose_into
from ..image_pipeline import _quantize, compose_into
from ..models import Frame, PhotoWidgetConfig, Widget
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
from ._shared import placeholder_image
@@ -50,7 +66,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
except HTTPException as e:
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]])
return compose_into(source, faces, target_w, target_h, cfg.display_mode)
composed = compose_into(source, faces, target_w, target_h, cfg.display_mode)
return _quantize(composed, frame.photo_palette_rgb, frame.photo_dither_strength).convert("RGB")
def _advance(db: Session, frame: Frame, widget: Widget) -> None:
+9 -1
View File
@@ -32,4 +32,12 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
if not cfg.image:
return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"])
source = Image.open(io.BytesIO(cfg.image)).convert("RGB")
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode)
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode)
if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme, "static")
return composed
+10 -1
View File
@@ -36,7 +36,16 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
signature regardless of which ones actually care."""
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
cfg = db.get(TaskWidgetConfig, widget.id)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, cfg.name or "Tasks")
title = cfg.name or "Tasks"
if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# 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,
widget.font_scale)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, font_scale=widget.font_scale)
ACTIONS: dict = {}
+40 -63
View File
@@ -20,12 +20,11 @@ block of authored text."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text
@@ -40,66 +39,20 @@ LINE_HEIGHT_FACTOR = 1.35
DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255)
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"
# A small curated set, not an open-ended picker -- each entry needs a
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
# only ship as a variable font (Playfair Display, Lora, Merriweather,
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
# of static builds from their own upstream repos where one exists (see
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
# they're all OFL, same as the Noto fonts already vendored here, but
# each has a different copyright holder so gets its own license file
# rather than sharing app/fonts/OFL.txt).
DEFAULT_FONT_FAMILY = "sans"
FONT_FAMILIES: dict[str, str] = {
"sans": "Sans-serif (Noto Sans)",
"inter": "Inter",
"source_sans": "Source Sans",
"serif": "Serif (Noto Serif)",
"elegant": "Elegant serif (Crimson Text)",
"slab": "Slab serif (Arvo)",
"mono": "Monospace (IBM Plex Mono)",
}
_FONT_FILES = {
"sans": {
(False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
(False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
},
"inter": {
(False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
(False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
},
"source_sans": {
(False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
(False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
},
"serif": {
(False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
(False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
},
"elegant": {
(False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
(False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
},
"slab": {
(False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
(False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
},
"mono": {
(False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
(False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
},
}
# The font-family table (a small curated set, not an open-ended picker --
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
# lives in app/theme_tokens.py now, shared with every modern-style
# widget's own font resolution -- re-exported here under their original
# names since this was the text widget's own table before the theme
# system needed it too (see app/fonts/OFL-*.txt for each non-Noto
# family's own license/copyright).
DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
FONT_FAMILIES = theme_tokens.FONT_FAMILIES
_FONT_FILES = theme_tokens._FONT_FILES
_font = theme_tokens.font
_WORD_OR_SPACE = re.compile(r"\S+|\s+")
@lru_cache(maxsize=256)
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)
def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
"""One paragraph's styled runs -> word groups: each group is a list
of same-word sub-tokens that must stay glued together on one line
@@ -224,6 +177,29 @@ def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.I
return img
def _render_dispatch(cfg: TextWidgetConfig, target_w: int, target_h: int,
palette_rgb: list | None, theme_name: str | None = None) -> Image.Image:
"""classic vs modern (app/html_render.py) -- shared by render() and
render_preview_png() so both honor render_style identically (weather
once shipped with its preview endpoint bypassing render_style
entirely by calling the classic renderer directly -- this shared
dispatch point exists specifically so that bug can't happen here).
palette_rgb is unused by the classic path (it never quantizes itself
-- see module docstring), only threaded through for modern's own
ordered_dither. theme_name is threaded through uniformly (every
modern-style widget's dispatch takes one) but build_text ignores it
-- see its own docstring for why (the text widget's font is a
per-widget, user-authored choice, not theme-driven)."""
if cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_text(cfg, target_w, target_h, palette_rgb, theme_name)
return _render_text(cfg, target_w, target_h)
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
@@ -232,10 +208,11 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
cfg = db.get(TextWidgetConfig, widget.id)
if cfg is None or not has_text(cfg.content):
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
return _render_text(cfg, target_w, target_h)
return _render_dispatch(cfg, target_w, target_h, frame.palette_rgb, frame.theme)
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
theme_name: str | None = None) -> bytes:
"""A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at
@@ -244,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
import io
target_w, target_h = logical_render_size(orientation)
img = _render_text(cfg, target_w, target_h)
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
+18 -3
View File
@@ -1,9 +1,12 @@
"""Weather widget: one of four display modes (see models.
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
py's build() dispatch. No real "next"/"back" concept (same as
whiteboard) -- a single "check now" action forces a re-fetch bypassing
the normal throttle."""
py's build() dispatch -- or, for "current"/"daily" modes with
render_style="modern", by app/html_render.py's Jinja2/headless-Chromium
renderer instead (experimental; hourly/multi_city always render classic
regardless of render_style, see html_render's module docstring). No real
"next"/"back" concept (same as whiteboard) -- a single "check now"
action forces a re-fetch bypassing the normal throttle."""
from __future__ import annotations
@@ -27,6 +30,18 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
data = get_or_refresh_weather_widget_data(db, frame, widget)
if data is None:
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
if cfg.render_style == "modern" and cfg.mode in ("current", "daily"):
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic/hourly/multi_city paths, should never pay for it
# (same reasoning as image_pipeline.render_placeholder's local
# `import qrcode`).
from .. import html_render
return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "", theme_name=frame.theme)
return weather_render.build(
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
+12 -2
View File
@@ -15,7 +15,7 @@ from PIL import Image
from sqlalchemy.orm import Session
from ..image_pipeline import compose_into
from ..models import Frame, Widget
from ..models import Frame, Widget, WhiteboardWidgetConfig
from ..routers.common import get_or_refresh_whiteboard_for_widget
from ._shared import placeholder_image
@@ -35,7 +35,17 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# letterbox, never cropped: unlike a photo, losing part of a
# whiteboard to a crop loses actual content, not just some background
# (see the old _render_whiteboard_mode's identical reasoning).
return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode="letterbox")
cfg = db.get(WhiteboardWidgetConfig, widget.id)
if cfg and cfg.render_style == "modern":
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic path, should never pay for it.
from .. import html_render
return html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
"whiteboard")
return composed
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
+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
+2
View File
@@ -11,3 +11,5 @@ icalendar==7.2.2
recurring-ical-events==3.8.2
caldav==3.2.1
pypdfium2==5.12.1
playwright==1.61.0
numpy==2.5.1
+12
View File
@@ -5,6 +5,18 @@
# Then execs uvicorn as the foreground/PID 1 process so it receives
# Docker's stop signal directly.
#
# Before either: fetch headless Chromium (app/html_render.py, the
# weather widget's opt-in "modern" render style) into
# PLAYWRIGHT_BROWSERS_PATH (set in the Dockerfile to a path on the /data
# volume) if it isn't already cached there -- see the Dockerfile's own
# comment for why this happens at startup instead of build time. Only
# the very first boot on a fresh volume actually downloads anything;
# every boot after that is a no-op ls check.
if [ -z "$(ls -A "$PLAYWRIGHT_BROWSERS_PATH" 2>/dev/null)" ]; then
echo "Fetching headless Chromium into $PLAYWRIGHT_BROWSERS_PATH (first boot on this volume)..." >&2
playwright install chromium-headless-shell
fi
#
# Wrapped in a restart loop, not a bare `node ... &`: a bare background
# process that crashes stays dead for good, with nothing to bring it
# back -- turning any single render crash (a not-yet-found jsdom/
+17
View File
@@ -29,6 +29,10 @@ from pathlib import Path
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}"
# Same reasoning as DATABASE_URL above: logging_setup.configure_logging()
# also runs as an app.main import-time side effect and would otherwise
# try to create the real /data directory.
os.environ["LOG_PATH"] = str(Path(_tmp_dir) / "server.log")
import pytest
from fastapi.testclient import TestClient
@@ -108,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
+75
View File
@@ -0,0 +1,75 @@
"""Permission boundary + basic content checks for the admin log viewer
(routers/pages.py's admin_logs_page/admin_logs_download) -- see
CLAUDE.md's note that anything gated by an admin/permission check needs
a same-shape test: admin, non-admin logged in, logged out."""
from __future__ import annotations
import logging
from app.logging_setup import LOG_PATH
from app.models import Frame
from .conftest import login, make_user
def _setup_admin_and_user(client, db_session) -> None:
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "bob")
def test_admin_can_view_logs(client, db_session):
_setup_admin_and_user(client, db_session)
login(client, "alice", "hunter22")
logging.getLogger("app.test").info("marker-line-for-test")
resp = client.get("/admin/logs")
assert resp.status_code == 200
assert "marker-line-for-test" in resp.text
def test_non_admin_forbidden_from_logs(client, db_session):
_setup_admin_and_user(client, db_session)
login(client, "bob")
resp = client.get("/admin/logs")
assert resp.status_code == 403
resp = client.get("/admin/logs/download")
assert resp.status_code == 403
def test_logged_out_redirected_from_logs_page(client, db_session):
_setup_admin_and_user(client, db_session)
client.cookies.clear() # /setup itself logs alice in
resp = client.get("/admin/logs")
assert resp.status_code == 303
assert resp.headers["location"] == "/login"
def test_admin_can_download_log_file(client, db_session):
_setup_admin_and_user(client, db_session)
login(client, "alice", "hunter22")
logging.getLogger("app.test").info("marker-line-for-download")
resp = client.get("/admin/logs/download")
assert resp.status_code == 200
assert b"marker-line-for-download" in resp.content
def test_device_requests_are_logged(client, db_session):
_setup_admin_and_user(client, db_session)
frame = db_session.get(Frame, 1)
frame.device_id = "aabbccddeeff"
db_session.commit()
resp = client.get(f"/frame/config?id={frame.device_id}&token={frame.device_token}")
assert resp.status_code == 200
login(client, "alice", "hunter22")
log_resp = client.get("/admin/logs")
# Jinja HTML-escapes the rendered <pre>, so "->" becomes "-&gt;".
assert f"GET /frame/config id={frame.device_id} -&gt; 200" in log_resp.text
def test_download_404s_before_any_log_written(client, db_session, monkeypatch):
_setup_admin_and_user(client, db_session)
login(client, "alice", "hunter22")
monkeypatch.setattr("app.routers.pages.LOG_PATH", LOG_PATH.parent / "does-not-exist.log")
resp = client.get("/admin/logs/download")
assert resp.status_code == 404
+38 -5
View File
@@ -1,11 +1,10 @@
"""_reject_outlier_drops -- the outlier-rejection pass in the battery
remaining-time estimate (see routers/common.py's battery_estimate_s).
Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in,
filtered pairs out."""
"""_reject_outlier_drops and _smooth_percents -- the two outlier-rejection
passes in the battery remaining-time estimate (see routers/common.py's
battery_estimate_s). Pure functions, no DB/HTTP."""
from __future__ import annotations
from app.routers.common import _reject_outlier_drops
from app.routers.common import _reject_outlier_drops, _smooth_percents
def _steps(drops: list[float]) -> list[tuple[int, float]]:
@@ -69,3 +68,37 @@ def test_never_filters_down_to_nothing():
steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
kept = _reject_outlier_drops(steps)
assert len(kept) > 0
def test_smooth_corrects_isolated_spike():
percents = [70, 70, 70, 70, 70, 90, 70, 70, 70, 70, 70]
smoothed = _smooth_percents(percents)
assert smoothed[5] == 70
assert smoothed[:5] == percents[:5]
assert smoothed[6:] == percents[6:]
def test_smooth_corrects_short_burst():
"""The shape seen in production: several consecutive corrupted
reports (a 1M-ohm divider glitching for a few reports in a row, not
just one) spliced into an otherwise flat run. A step computed
between two of these looks like an ordinary small change, which is
exactly why _reject_outlier_drops alone can't catch this shape."""
percents = [53, 53, 53, 53, 41, 40, 40, 42, 53, 53, 53, 53]
smoothed = _smooth_percents(percents)
assert smoothed[4:8] == [53, 53, 53, 53]
assert smoothed[:4] == percents[:4]
assert smoothed[8:] == percents[8:]
def test_smooth_leaves_gradual_legitimate_trend_alone():
"""A slow, steady climb (recharge) or decline spread over many
reports is a real trend, not a local glitch -- each reading is close
to its own neighborhood's median, so nothing should be flagged."""
percents = list(range(80, 60, -2)) # 80, 78, 76, ... steady discharge
assert _smooth_percents(percents) == percents
def test_smooth_identical_readings_untouched():
percents = [50] * 12
assert _smooth_percents(percents) == percents
+60 -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
@@ -144,3 +147,50 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert cal_cfg.browse_offset == 1
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
def test_widgets_render_concurrently(client, db_session, monkeypatch):
"""Two independent, slow widgets on one frame should render in
roughly the time of the slowest one, not the sum -- the actual fix
for the "hold to cycle layouts times out and shows a false server-
failed status screen" bug: several network-backed widgets (photos,
weather, calendar) rendering one after another could push a single
/frame/* response past the firmware's fixed HTTP timeout even though
the server was simply still working."""
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
from PIL import Image
source = Image.new("RGB", (100, 80), (10, 20, 30))
def _slow_fetch(client, mode, asset_id):
time.sleep(0.25)
return source, None
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", _slow_fetch)
frame = Frame(
name="Concurrency Frame", device_id="112233445566", device_token="devtok-3",
manage_token="mtok-3", orientation="landscape", created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
widget_a = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=4, h=5,
sort_order=0, created_at=time.time())
widget_b = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add_all([widget_a, widget_b])
db_session.flush()
db_session.add(PhotoWidgetConfig(widget_id=widget_a.id, album_id="album-a"))
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b"))
db_session.commit()
start = time.monotonic()
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
elapsed = time.monotonic() - start
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
# Serial would be ~0.5s (2 x 0.25s); concurrent should land near 0.25s.
assert elapsed < 0.45, f"widgets rendered serially, not concurrently ({elapsed:.2f}s)"
+89
View File
@@ -0,0 +1,89 @@
"""POST /api/frames/{id}/firmware/check -- the "Check now" button's
endpoint. update_available compares the latest Gitea release against
what's *staged* (firmware_available_version), not what the device is
actually running (device_firmware_version) -- those can differ once a
release has been staged/auto-applied but the frame hasn't woken up and
picked it up yet. running_version lets the UI tell "up to date" apart
from "staged, waiting for the frame to apply it" instead of collapsing
both into the same message."""
from __future__ import annotations
from app import gitea_releases
from app.models import Frame
from .conftest import csrf_headers
def _setup_frame(db_session, monkeypatch, latest_version, **overrides):
"""firmware_update_checked_at starts at 0, so the endpoint always
tries a real Gitea fetch on a fresh frame regardless of force= --
stub it out rather than hitting the network."""
monkeypatch.setattr(
gitea_releases, "fetch_latest_release", lambda *a, **k: {"version": latest_version, "assets": {}}
)
frame = db_session.get(Frame, 1)
frame.firmware_update_repo_url = "https://git.example.com/owner/repo"
frame.device_board_variant = "devkit"
for key, value in overrides.items():
setattr(frame, key, value)
db_session.commit()
return frame
def test_up_to_date_when_running_matches_latest(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_setup_frame(
db_session,
monkeypatch,
"1.4.1",
firmware_available_version="1.4.1",
device_firmware_version="1.4.1",
)
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["update_available"] is False
assert data["latest_version"] == "1.4.1"
assert data["running_version"] == "1.4.1"
def test_staged_but_not_yet_running_is_not_update_available(client, db_session, monkeypatch):
"""A release already staged (e.g. by a previous auto-update) but not
yet applied by the device isn't "an update is available" -- there's
nothing left to fetch/stage -- but it also isn't silently "up to
date" from the UI's perspective, since running_version still lags."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_setup_frame(
db_session,
monkeypatch,
"1.4.1",
firmware_available_version="1.4.1",
device_firmware_version="1.3.0",
)
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["update_available"] is False
assert data["latest_version"] == "1.4.1"
assert data["staged_version"] == "1.4.1"
assert data["running_version"] == "1.3.0"
def test_update_available_reports_running_version(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_setup_frame(
db_session,
monkeypatch,
"1.4.1",
firmware_available_version="1.3.0",
device_firmware_version="1.3.0",
)
resp = client.post("/api/frames/1/firmware/check", headers=csrf_headers(client))
assert resp.status_code == 200
data = resp.json()
assert data["update_available"] is True
assert data["running_version"] == "1.3.0"
+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
+65
View File
@@ -0,0 +1,65 @@
"""app.html_render's shared ordered-dithering primitives -- ordered_dither
and ordered_dither_regions -- exercised directly against synthetic
images, no Chromium/Playwright involved (these two functions run purely
on whatever Image render_html_to_image already handed back)."""
from __future__ import annotations
from PIL import Image
from app import html_render
from app.image_pipeline import DEFAULT_PALETTE_RGB
_PALETTE = set(DEFAULT_PALETTE_RGB)
# A muddy hue nowhere near any of the 6 exact palette colors -- at the
# "modern" style's tuned default amplitude (48), this should still snap
# flatly to a single nearest ink (see theme_tokens.py's module docstring
# for why 48 was chosen for icon/text legibility); only a much higher
# amplitude (as a rich theme's accent_amplitude would use) stipples it
# into a multi-ink approximation.
_RICH_HUE = (168, 75, 42) # a terracotta-ish RGB, not one of the 6 inks
def test_ordered_dither_output_is_exact_palette_colors():
img = Image.new("RGB", (40, 30), (128, 128, 128))
dithered = html_render.ordered_dither(img, None)
assert set(dithered.getdata()) <= _PALETTE
def test_ordered_dither_regions_outside_the_region_matches_plain_dither():
"""Pixels outside every accent_regions rect must come out identical
to a plain ordered_dither call at base_amplitude -- the region-aware
variant must not perturb anything it wasn't asked to."""
img = Image.new("RGB", (100, 80), (90, 140, 200))
base_only = html_render.ordered_dither(img, None, amplitude=48.0)
regions = html_render.ordered_dither_regions(
img, None, base_amplitude=48.0, accent_regions=[((10, 10, 40, 30), 130.0)]
)
for x in range(100):
for y in range(80):
if 10 <= x < 40 and 10 <= y < 30:
continue # inside the accent region -- expected to differ
assert regions.getpixel((x, y)) == base_only.getpixel((x, y))
def test_ordered_dither_regions_stipples_a_rich_hue_the_base_amplitude_would_flatten():
"""The whole reason ordered_dither_regions exists: a rich accent hue
dithered at the base (icon/text-safe) amplitude just snaps to one
nearest ink, but the same hue in an accent region at a theme's higher
accent_amplitude resolves to a believable multi-ink stipple instead --
assert that difference directly, not just "some image came back"."""
img = Image.new("RGB", (60, 60), _RICH_HUE)
rect = (0, 0, 60, 60)
flat = html_render.ordered_dither(img, None, amplitude=48.0)
richer = html_render.ordered_dither_regions(img, None, base_amplitude=48.0, accent_regions=[(rect, 130.0)])
assert len(set(flat.getdata())) == 1
assert len(set(richer.getdata())) > 1
assert set(richer.getdata()) <= _PALETTE
def test_ordered_dither_regions_with_no_accent_regions_matches_plain_dither():
img = Image.new("RGB", (30, 30), (50, 60, 70))
assert list(html_render.ordered_dither_regions(img, None, base_amplitude=48.0).getdata()) == \
list(html_render.ordered_dither(img, None, amplitude=48.0).getdata())
+156 -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
@@ -92,14 +144,30 @@ def test_expected_columns_exist_on_current_schema():
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
assert "render_style" in weather_widget_columns # migration 31
assert {"photo_palette_rgb", "photo_dither_strength"} <= frame_columns # migration 32
assert "render_style" in battery_widget_columns # migration 33
assert "render_style" in text_widget_columns # migration 34
assert "render_style" in task_widget_columns # migration 35
static_widget_columns = {c["name"] for c in inspector.get_columns("static_widget_configs")}
assert "render_style" in static_widget_columns # migration 36
whiteboard_widget_columns = {c["name"] for c in inspector.get_columns("whiteboard_widget_configs")}
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
@@ -109,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"}
@@ -126,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(
@@ -166,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()
@@ -235,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
@@ -331,6 +421,43 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
assert frame.last_cycled_layout_id is None
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
"""Exercises _migration_30's real guarded ALTER path (frames 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's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 29"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
frame = db_session.get(Frame, 1)
assert frame.last_displayed_image is None
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
@@ -482,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")
@@ -495,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)"

Some files were not shown because too many files have changed in this diff Show More