21 Commits
Author SHA1 Message Date
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
98 changed files with 4640 additions and 556 deletions
+18 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: make-widget 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 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 `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 the project's own "start here" doc per `CLAUDE.md` -- don't ship a
widget without it staying accurate. 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/`) ## Tests (`server/tests/`)
@@ -138,6 +148,13 @@ Pick your template accordingly:
- Any pure-logic helper module (decoding, parsing -- like - Any pure-logic helper module (decoding, parsing -- like
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no `app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
DB, just the function. 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: Run the full suite before calling it done:
@@ -11,6 +11,7 @@ mkdir -p "$SCRATCH"
DATABASE_URL="sqlite:///$SCRATCH/test.db" \ DATABASE_URL="sqlite:///$SCRATCH/test.db" \
CONFIG_PATH="$SCRATCH/config.json" \ CONFIG_PATH="$SCRATCH/config.json" \
LOG_PATH="$SCRATCH/app.log" \
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \ .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
> "$SCRATCH/server.log" 2>&1 & > "$SCRATCH/server.log" 2>&1 &
PID=$! PID=$!
+1 -1
View File
@@ -69,4 +69,4 @@ jobs:
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \ ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
espressoframe_deployer@"$DEPLOY_HOST" \ espressoframe_deployer@"$DEPLOY_HOST" \
'cd ~/espresso-frame && docker compose pull && docker compose up -d' 'cd ~/espresso-frame && docker compose down && docker compose pull && docker compose up -d'
+144
View File
@@ -148,6 +148,145 @@ grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible. 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.
### 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 header/accent color
is the most visible change, but `font_family` applies to *every* text
element in the widget, not just the header title -- day labels,
temperatures, task rows, event times, day numbers all switch fonts too
(e.g. "Moss" is serif, "Ochre" a slab serif), often more noticeable than
the accent color on text-heavy widgets. `radius`/`shadow` change the
card's corners and whether it has a drop shadow at all (e.g. "Slate" is
square-cornered with no shadow). Battery and text/static/whiteboard have
no header, so for those a theme only shows up as font/radius/shadow
differences, not an accent color.
**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 header bar, when `city_label` is set), tasks, and
calendar's four view builders (each already computed a `header_h` in
Python for layout, reused as the accent rect). Weather's `build_current`,
battery, and static/whiteboard's shared `build_framed_image` are
theme-aware for font/radius/shadow only -- no header/accent region to
dither richer, so they call plain `ordered_dither` exactly as before
themes existed.
## Button actions ## Button actions
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
@@ -269,6 +408,11 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
side -- the calendar widget's embedded strip, as a standalone side -- the calendar widget's embedded strip, as a standalone
widget's whole content instead of a strip above an agenda day. 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 `current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../ (`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
weather-location`, geocoded through `weather.geocode_city`); `multi_city` weather-location`, geocoded through `weather.geocode_city`); `multi_city`
+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 \ tzdata fontconfig fonts-dejavu-core nodejs npm \
&& rm -rf /var/lib/apt/lists/* && 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 . 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 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 # render-service/'s dependencies installed as several separate layers
# rather than one `npm install` covering all of them -- a from-scratch # 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 # push of this image once hit Cloudflare's payload-size limit on a
+9 -1
View File
@@ -91,11 +91,19 @@ algorithm itself -- it just streams the response straight to the panel.
again until a recharge is detected and it crosses again. No SMTP again until a recharge is detected and it crosses again. No SMTP
configured, or no email on the relevant account, and both features configured, or no email on the relevant account, and both features
silently no-op rather than erroring. 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 ## Endpoints
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, 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}`. `/frames/{id}/stats`, `/m/{manage_token}`.
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`) ### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
+327
View File
@@ -0,0 +1,327 @@
"""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) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a
header bar -- dithered at the theme's accent_amplitude via
ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = max(14, min(target_w, target_h) // 12)
body_size = max(11, min(target_w, target_h) // 20)
weather_size = max(10, body_size - 2)
row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
# Single day -- no cross-section alignment concern, so header_h can
# simply reflect whether THIS day actually has weather (unlike
# build_today_tomorrow/build_week's vertical layout, which must
# reserve the same header_h for every stacked section regardless).
has_weather = bool(_weather_row(weather_cities, day, weather_units))
header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0)
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, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header=data["header"], title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"],
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
more_count=data["more_count"], row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
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) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data), each with its own
header bar dithered richer via ordered_dither_regions."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2
title_size = max(13, section_h // 8)
body_size = max(10, min(target_w, target_h) // 26)
weather_size = max(9, body_size - 2)
row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C"
day_dates = [start_day + timedelta(days=i) for i in range(2)]
# Uniform across both stacked sections regardless of which day(s)
# actually have weather -- see _day_section_data's own docstring for
# why a per-day header height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0)
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, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=days, title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
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) -> 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 = max(11, min(20, section_h // 6))
body_size = max(9, min(target_w, target_h) // (18 + days))
weather_size = max(8, body_size - 2)
row_h = body_size + 10
day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
# Uniform across all `days` stacked sections -- see
# _day_section_data's own docstring for why a per-day header
# height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0)
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, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=day_sections, title_size=title_size, header_h=header_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h),
theme["accent_amplitude"])
for i in range(len(day_sections))
]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6))
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
# Reserve weather-line room in every column's header uniformly
# (whether or not THIS specific day has a cached forecast) -- a
# per-column height that depends on that day's own data would
# misalign where each column's event rows start across the week
# grid the moment any single day lacks a forecast entry.
header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h)
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, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to
unreadable on a 6-color dithered e-ink panel). The per-owner event
dots are identity-coding (like every other calendar view's chips) and
are never touched by a theme; only the weekday-name row (a flat
accent background, no gradient in this view) dithers richer via
ordered_dither_regions."""
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 = max(11, min(16, target_h // 30))
day_size = max(10, min(15, target_w // 55))
dot_size = max(4, day_size // 2)
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, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks,
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
)
rendered = html_render.render_html_to_image(html, target_w, target_h)
weekday_row_h = header_size + 12
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
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) -> 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)
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)
if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset, theme_name)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name)
+138 -115
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", (ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means "color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time) 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 from __future__ import annotations
@@ -26,6 +26,7 @@ from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import ( from .image_pipeline import (
DEFAULT_PALETTE_RGB, DEFAULT_PALETTE_RGB,
_apply_manage_overlay, _apply_manage_overlay,
@@ -42,12 +43,20 @@ CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (t
"week": "Week", "month": "Month"} "week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 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) BG = (255, 255, 255)
FG = (0, 0, 0) FG = (0, 0, 0)
MUTED = (110, 110, 110) # Structural dividers/grid lines (between stacked day sections, week
# Was a light gray, but that dithers away to near-invisible once quantized # columns, month cells) stay a plain black rule -- gray dithers away to
# to the 6-color e-ink palette -- black reads as an actual line on-panel. # 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) RULE = (0, 0, 0)
# Fallback for any event whose calendar has no manually pinned color # 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 one person's calendar). Usually just one color; more than one is
what tells the "same event, more than one calendar" case apart from what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see 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 (FrameCalendar.color_index -- see routers/api_widgets.py's
api_widget_calendar_color) resolves against whichever palette this frame api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual 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 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: def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` -- """Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried 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") return d.strftime("%b %-d")
# ImageFont.load_default() (used for everything else in this module -- # Neither Inter (panel_style.font_bold/font_regular, this module's own
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't # body/title font -- see MARGIN/BG/FG comment above) nor PIL's bundled
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a # default font has emoji glyphs, and PIL/FreeType don't skip an
# visible filled rectangle) -- reads as a rendering glitch, not "emoji # unsupported codepoint, they substitute a ".notdef" tofu box (a visible
# not supported". So event titles get drawn with two fonts: the normal # 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 # 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, # (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt). # 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 these vertically without duplicating the row-layout/truncation
logic. Weather is drawn above the event list -- eating into the same logic. Weather is drawn above the event list -- eating into the same
row budget the event count is truncated against, exactly like the 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 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 text_w = w - MARGIN * 2
header = day.strftime("%A, %B ") + str(day.day) 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) draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
y = text_y0 + title_font.size + 12 _truncate_to_width(draw, header, title_font, text_w), title_font, BG)
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) y = y0 + header_h + 12
y += 12
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: 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) max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not day_events: 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): for i, event in enumerate(day_events):
if i >= max_rows: 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 break
colors = _event_colors(event, owners_seen, palette_rgb) 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)) time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
prefix = f"{time_str} " prefix = f"{time_str} "
draw_text(img, (text_x0 + 18, y), prefix, body_font) 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" (`title`, truncated to fit -- TaskWidgetConfig.name or the "Tasks"
default; the only widget type with its own on-panel title, since 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 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 content the way a calendar/photo/whiteboard's is), then a color chip
(reusing _event_colors/_draw_color_bar as-is: a task dict's (reusing _event_colors/panel_style.draw_color_chip as-is: a task
top-level owner_display_name/color_index is exactly _event_colors' dict's top-level owner_display_name/color_index is exactly
single-source fallback shape, since caldav_client.merge_tasks _event_colors' single-source fallback shape, since caldav_client.
doesn't cross-list-dedup tasks into a "sources" list the way merge_tasks doesn't cross-list-dedup tasks into a "sources" list the
merge_events dedups events) + checkbox glyph + due date (if any) + way merge_events dedups events) + checkbox glyph + due date (if any)
summary per task, same header/rule/row-cap/truncation shape as + summary per task, same header/row-cap/truncation shape as
_draw_agenda_day's event list so the standalone tasks widget (see _draw_agenda_day's event list so the standalone tasks widget (see
_build_tasks) reads as the same consistent design as everything _build_tasks) reads as the same consistent design as everything
else on-panel, not a bolted-together look. Reuses _draw_mixed_line 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 Outstanding tasks get an empty checkbox; completed ones (only ever
present when TaskWidgetConfig.show_completed is on -- see present when TaskWidgetConfig.show_completed is on -- see
caldav_client.fetch_tasks' completed_since) get a filled one and caldav_client.fetch_tasks' completed_since) get a filled checkbox in
muted text, no due-date prefix (irrelevant once done).""" 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 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 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) draw_text(img, (text_x0, y0 + (header_h - title_font.size) // 2),
y = text_y0 + title_font.size + 12 _truncate_to_width(draw, title or "Tasks", title_font, text_w), title_font, BG)
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) y = y0 + header_h + 12
y += 12
row_h = body_font.size + 14 row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h) max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
if not tasks: 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 return
owners_seen: list[str] = [] owners_seen: list[str] = []
checkbox_fill = panel_style.theme_color("tasks", palette_rgb)
for i, task in enumerate(tasks): for i, task in enumerate(tasks):
if i >= max_rows: 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 break
done = task.get("completed_at") is not None done = task.get("completed_at") is not None
colors = _event_colors(task, owners_seen, palette_rgb) 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 = body_font.size - 6
box_x = text_x0 + 18 box_x = text_x0 + 18
box_y = y + (row_h - box) // 2 - 5 box_y = y + (row_h - box) // 2 - 5
box_r = min(panel_style.CHIP_RADIUS, box // 2)
if done: 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: 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 text_x = box_x + box + 10
due_str = None if done else _fmt_task_due(task.get("due")) due_str = None if done else _fmt_task_due(task.get("due"))
prefix = f"{due_str} " if due_str else "" prefix = f"{due_str} " if due_str else ""
if prefix: 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 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"], _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 y += row_h
@@ -512,17 +513,16 @@ _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, 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, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image: weather_units: str = "fahrenheit") -> Image.Image:
img = Image.new("RGB", (target_w, target_h), BG) img, draw, region = panel_style.card_canvas(target_w, target_h)
draw = ImageDraw.Draw(img)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(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) title_font = panel_style.font_bold(title_size)
body_font = ImageFont.load_default(size=body_size) body_font = panel_style.font_regular(body_size)
weather_font = ImageFont.load_default(size=weather_size) weather_font = panel_style.font_regular(weather_size)
day = datetime.now(tz).date() + timedelta(days=browse_offset) day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = [] 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) palette_rgb, weather_cities, weather_font, weather_units)
return img return img
@@ -540,23 +540,22 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
shifts the whole two-day window together, same "days" unit shifts the whole two-day window together, same "days" unit
_build_agenda already uses, so NEXT/BACK behaves identically across _build_agenda already uses, so NEXT/BACK behaves identically across
both views.""" both views."""
img = Image.new("RGB", (target_w, target_h), BG) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
draw = ImageDraw.Draw(img)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(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) title_font = panel_style.font_bold(title_size)
body_font = ImageFont.load_default(size=body_size) body_font = panel_style.font_regular(body_size)
weather_font = ImageFont.load_default(size=weather_size) weather_font = panel_style.font_regular(weather_size)
start_day = datetime.now(tz).date() + timedelta(days=browse_offset) start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = target_h // 2 section_h = ch // 2
owners_seen: list[str] = [] owners_seen: list[str] = []
for i in range(2): for i in range(2):
section_y0 = i * section_h section_y0 = cy0 + i * section_h
if i > 0: 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, _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) palette_rgb, weather_cities, weather_font, weather_units)
return img return img
@@ -585,8 +584,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 otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 = count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_widgets.py's api_widget_config_save).""" today, see routers/api_widgets.py's api_widget_config_save)."""
img = Image.new("RGB", (target_w, target_h), BG) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
draw = ImageDraw.Draw(img)
tier = _size_tier(target_w, target_h) tier = _size_tier(target_w, target_h)
today = datetime.now(tz).date() today = datetime.now(tz).date()
@@ -599,36 +597,36 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical": if layout == "vertical":
title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier] title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
title_font = ImageFont.load_default(size=max(14, title_base - days)) title_font = panel_style.font_bold(max(14, title_base - days))
body_font = ImageFont.load_default(size=max(11, body_base - days)) body_font = panel_style.font_regular(max(11, body_base - days))
weather_font = ImageFont.load_default(size=max(9, weather_base - days)) weather_font = panel_style.font_regular(max(9, weather_base - days))
section_h = target_h // days section_h = ch // days
for i in range(days): for i in range(days):
section_y0 = i * section_h section_y0 = cy0 + i * section_h
if i > 0: 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) 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, title_font, body_font, owners_seen, palette_rgb,
weather_cities, weather_font, weather_units) weather_cities, weather_font, weather_units)
return img return img
header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier] header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
header_font = ImageFont.load_default(size=header_size) header_font = panel_style.font_bold(header_size)
chip_font = ImageFont.load_default(size=chip_size) chip_font = panel_style.font_regular(chip_size)
weather_font = ImageFont.load_default(size=weather_size) weather_font = panel_style.font_regular(weather_size)
col_w = (target_w - MARGIN * 2) // days col_w = (cw - MARGIN * 2) // days
header_h = 44 header_h = 44
for col in range(days): for col in range(days):
day = week_first_day + timedelta(days=col) day = week_first_day + timedelta(days=col)
x0 = MARGIN + col * col_w x0 = cx0 + MARGIN + col * col_w
if col > 0: 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')}" 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 # Columns are narrow, so only what actually fits gets drawn (see
# weather_render.draw_weather_row) -- typically one city, no label # weather_render.draw_weather_row) -- typically one city, no label
# (the column itself makes which day it's for obvious; a city name # (the column itself makes which day it's for obvious; a city name
@@ -640,14 +638,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, icon_r=8, font=weather_font, units=weather_units, show_labels=False,
palette_rgb=palette_rgb) palette_rgb=palette_rgb)
row_h = chip_font.size + 10 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) day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events): for i, event in enumerate(day_events):
if i >= max_rows: 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 break
colors = _event_colors(event, owners_seen, palette_rgb) 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"]: if event["all_day"]:
_draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20) _draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
else: else:
@@ -672,39 +670,60 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
week_start: int, palette_rgb: list | None = None) -> Image.Image: week_start: int, palette_rgb: list | None = None) -> Image.Image:
"""Density dots per day, not literal event text -- real text at """Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.""" 6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.
img = Image.new("RGB", (target_w, target_h), BG) "Not in this month" day numbers used to be a muted gray -- now
draw = ImageDraw.Draw(img) 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_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
header_font = ImageFont.load_default(size=header_size) header_font = panel_style.font_bold(header_size)
day_font = ImageFont.load_default(size=day_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() today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset) 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)) 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 header_h = 28
grid_top = MARGIN + header_h grid_top = cy0 + MARGIN + header_h
row_h = (target_h - MARGIN - grid_top) // len(weeks) 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] day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
for col, name in enumerate(day_names): 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] = [] owners_seen: list[str] = []
dot_r = 6 dot_r = 6
for row, week in enumerate(weeks): for row, week in enumerate(weeks):
for col, day in enumerate(week): for col, day in enumerate(week):
x0 = MARGIN + col * col_w x0 = cx0 + MARGIN + col * col_w
y0 = grid_top + row * row_h y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE) draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month in_month = day.month == target_month.month
text_color = FG if in_month else MUTED
if day == today: if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG) # A filled accent badge (this widget's own theme color,
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_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) day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8 dot_x = x0 + 8
@@ -718,7 +737,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) 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 dot_x += dot_r * 2 + 5
if len(day_events) > 4: 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 return img
@@ -759,8 +778,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
weather_cities, weather_units) weather_cities, weather_units)
if fetch_summary: if fetch_summary:
font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11) # Drawn as a final overlay onto the already-composited img (not
draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED) # 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 return img
@@ -815,12 +839,11 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l
"""A tasks widget's entire region is the checklist -- unlike the old """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, week-view slot, there's no day columns/header to share space with,
so this is just _draw_tasks over the whole box.""" so this is just _draw_tasks over the whole box."""
img = Image.new("RGB", (target_w, target_h), BG) img, draw, region = panel_style.card_canvas(target_w, target_h)
draw = ImageDraw.Draw(img)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)] title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)]
title_font = ImageFont.load_default(size=title_size) title_font = panel_style.font_bold(title_size)
body_font = ImageFont.load_default(size=body_size) body_font = panel_style.font_regular(body_size)
_draw_tasks(img, draw, (0, 0, target_w, target_h), tasks, title_font, body_font, palette_rgb, title) _draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
return img return img
+571
View File
@@ -0,0 +1,571 @@
"""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": "⛈️",
}
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))
# --- 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 build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image
(see ordered_dither). No header/accent region here (just a centered
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
(no ordered_dither_regions call)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry:
return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
icon_size = max(28, min(target_w, target_h) // 3)
template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
emoji=CATEGORY_EMOJI.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
label_size=max(12, icon_size // 3),
)
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). Has a header bar -- dithered at the theme's
accent_amplitude via ordered_dither_regions, richer than the rest of
the widget (see that function's docstring for why)."""
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"
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
col_w = max(1, target_w // len(days))
icon_size = max(16, min(col_w // 2, 36))
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, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
city_label=city_label, header_h=header_h,
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
unit_suffix=unit_suffix,
)
rendered = render_html_to_image(html, target_w, target_h)
if header_h <= 0:
return ordered_dither(rendered, palette_rgb)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_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(16, int(base // 3 * scale))
pct_size = max(14, int(base // 3 * scale))
line_size = max(9, int(base // 9 * scale))
gap = 8
total = icon_h + gap + pct_size + num_lines * (line_size + gap)
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total}
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/radius 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.
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
the whole stack actually fits the available height -- the classic
PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
problem by truncating lines that don't fit; scaling down instead
keeps every resolved line visible, which reads better for a widget
that only ever has at most 2 short caption lines to begin with."""
avail_h = target_h - panel_style.GUTTER * 2
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, gutter=panel_style.GUTTER, radius=theme["radius"],
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"],
)
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) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_tasks --
same header+checklist shape. Reuses calendar_render's own
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format
logic the classic renderer uses) so a task's color chip/due string
matches classic style exactly; only the drawing differs -- and a
theme's accent never touches those per-owner chip colors (identity-
coding, not style). Has a header bar -- dithered at the theme's
accent_amplitude via ordered_dither_regions. Returns an already-
palette-exact RGB image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
header_h = max(28, min(target_w, target_h) // 8)
body_size = max(11, min(target_w, target_h) // 20)
row_h = body_size + 14
box_size = max(10, body_size - 4)
avail_h = target_h - header_h - 16
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, radius=theme["radius"], shadow=theme["shadow"],
font_regular=theme["font_regular"], font_bold=theme["font_bold"],
title=title, header_h=header_h, title_size=max(14, header_h - 12),
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
)
rendered = render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
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 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 """Draws a border inset within img's own bounds, mutating it in
place -- called once per widget's own region (routers/device.py's place -- called once per widget's own region (routers/device.py's
_render_widgets, and each widget type's own dialog preview) before _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 "solid"/"dashed"/"dotted" are a single thickness-px stroke traced
just inside the image's edge; "fancy" is two thinner concentric just inside the image's edge; "fancy" is two thinner concentric
strokes with a gap between them, picture-frame-mat style. "none" (or 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: if style == "none" or thickness <= 0:
return return
w, h = img.size w, h = img.size
t = max(1, min(int(thickness), min(w, h) // 2)) t = max(1, min(int(thickness), min(w, h) // 2))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
if style == "fancy": if style == "fancy":
line_t = max(1, t // 3) line_t = max(1, t // 3)
gap = max(2, t - 2 * line_t) 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 inset = line_t + gap
if w - 2 * inset > 1 and h - 2 * inset > 1: 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 return
if style == "solid": 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 return
# dashed/dotted trace the same centered-on-the-edge path solid/ # 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"] 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), # 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 # in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
# hardware protocol, never user-configurable. 0x4 is intentionally unused # 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) 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", 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, 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 """The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from (paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run "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) as_png=True returns a normal browser-viewable PNG in logical (upright)
orientation instead of packed native-panel bytes, same convention as orientation instead of packed native-panel bytes, same convention as
render_preview_png -- used for the web UI's live "how it's displaying" 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) logical_w, logical_h = logical_render_size(orientation)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG) canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions: 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) fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength) quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png: if as_png:
buf = io.BytesIO() return _png_bytes(quantized)
quantized.convert("RGB").save(buf, format="PNG") packed = _transpose_and_pack(quantized, orientation)
return buf.getvalue() if capture_snapshot:
return _transpose_and_pack(quantized, orientation) return packed, _png_bytes(quantized)
return packed
def render_preview_png(source: Image.Image, faces: list[dict] | None = None, 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 = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage) fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength) quantized = _quantize(fitted, palette_rgb, dither_strength)
buf = io.BytesIO() return _png_bytes(quantized)
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
def render_placeholder(lines: list[str], qr_url: str | None = None, def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | 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 """A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a 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 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 `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 (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 margin = 24
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255)) 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) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png: if as_png:
buf = io.BytesIO() return _png_bytes(quantized)
quantized.convert("RGB").save(buf, format="PNG") packed = _transpose_and_pack(quantized, orientation)
return buf.getvalue() if capture_snapshot:
return _transpose_and_pack(quantized, orientation) 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:])
+53 -3
View File
@@ -16,14 +16,16 @@ pre-database config.json deployment on first boot."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from . import migration from . import html_render, logging_setup, migration
from .auth import ( from .auth import (
browser_token_valid, browser_token_valid,
current_user, current_user,
@@ -38,12 +40,53 @@ from .routers.common import shell_context
logger = logging.getLogger(__name__) 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. # Schema + legacy-config import, before the first request is served.
migration.run_migrations() 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") 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.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router) app.include_router(device.router)
@@ -60,6 +103,13 @@ def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
@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, allow_legacy: bool) -> str | None: def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
"""The on-frame manage QR points at the server root with the device's """The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware: own credentials (new firmware: ?id=&token=; deployed firmware:
+33 -54
View File
@@ -14,7 +14,8 @@ from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont 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 PADDING = 16
QR_TEXT_GAP = 8 QR_TEXT_GAP = 8
@@ -27,9 +28,12 @@ BODY_FONT_SIZE = 20
BATTERY_ICON_W = 40 BATTERY_ICON_W = 40
BATTERY_ICON_H = 22 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_W = 5
BATTERY_NUB_H = 10
BATTERY_ICON_TEXT_GAP = 8 BATTERY_ICON_TEXT_GAP = 8
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box 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 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: def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
import qrcode 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) 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 """(width, height) of `lines` stacked with LINE_GAP between them, at
`font` -- the box _draw_text_box below will need.""" `font` -- the box _draw_text_box below will need."""
w = 0 w = 0
@@ -64,7 +64,7 @@ def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.Image
return w, h 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: center_x: int, top: int) -> None:
y = top y = top
for line in lines: 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 (the battery, below the manage QR) use it instead of recomputing the
same geometry a second time.""" same geometry a second time."""
qr_img = _qr_image(url) 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_w = max(qr_img.width, text_w)
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0) 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 h = content_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner) 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 center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING)) img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption: 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 return x0, y0, w, h
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None: 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 """White-padded box with centered text lines, placed in one of the
panel's four corners.""" 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) text_w, text_h = _text_box(draw, lines, font)
w = text_w + PADDING * 2 w = text_w + PADDING * 2
h = text_h + PADDING * 2 h = text_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner) 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) _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 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, def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None: anchor_w: int, anchor_h: int) -> None:
"""Battery glyph (now actually filled to `percent`, not just a static """Battery glyph + "NN%" text, right-aligned under the given anchor
outline -- easy now that this renders server-side instead of being a box (the manage QR box) -- a sensible default position, not a
fixed bitmap firmware drew) + "NN%" text, right-aligned under the constraint anything else has to route around; move this call site's
given anchor box (the manage QR box) -- a sensible default position, arguments to place it anywhere else instead. The glyph itself is
not a constraint anything else has to route around; move this call panel_style.draw_battery_icon -- the one shared implementation
site's arguments to place it anywhere else instead.""" replacing what used to be a second, independent copy of widgets/
font = _font(BODY_FONT_SIZE) 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}%" text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
text_w = draw.textlength(text, font=font) 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 x0 = anchor_x0 + anchor_w - w
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP 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_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2 icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
inner_x0, inner_y0 = icon_x + BATTERY_ICON_STROKE, icon_y + BATTERY_ICON_STROKE panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent)
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))
draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2), 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: 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 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 stay fully on-panel -- unlike the four corner boxes (always in-bounds
by construction), a face can be anywhere, including near an edge.""" 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) text_w = draw.textlength(name, font=font)
bbox = draw.textbbox((0, 0), name, font=font) bbox = draw.textbbox((0, 0), name, font=font)
text_h = bbox[3] - bbox[1] 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)) x0 = max(0, min(x0, img_w - w))
y0 = max(0, min(y0, img_h - h)) 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) draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font)
+125
View File
@@ -774,6 +774,121 @@ def _migration_29(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER")) 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'"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -804,6 +919,16 @@ MIGRATIONS = [
(27, _migration_27), (27, _migration_27),
(28, _migration_28), (28, _migration_28),
(29, _migration_29), (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),
] ]
+51 -2
View File
@@ -178,6 +178,19 @@ class Frame(Base):
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's # 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
# original always-on full-strength Floyd-Steinberg dithering. # original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0) dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# 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")
# -- calendar mode (see calendar_feed.py, calendar_render.py, # -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) -- # routers/device.py's RENDERERS["calendar"]) --
@@ -328,6 +341,16 @@ class Frame(Base):
# starts over from the first one, same as an unset value. # starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True) 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 (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0) stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0) stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
@@ -532,6 +555,10 @@ class CalendarWidgetConfig(Base):
week_days: Mapped[int] = mapped_column(Integer, default=7) week_days: Mapped[int] = mapped_column(Integer, default=7)
week_layout: Mapped[str] = mapped_column(String, default="horizontal") week_layout: Mapped[str] = mapped_column(String, default="horizontal")
week_start_offset: Mapped[int] = mapped_column(Integer, default=0) 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): class TaskWidgetConfig(Base):
@@ -570,6 +597,9 @@ class TaskWidgetConfig(Base):
# outstanding ones -- off by default, same "opt into more" posture # outstanding ones -- off by default, same "opt into more" posture
# as calendar_weather_enabled. # as calendar_weather_enabled.
show_completed: Mapped[bool] = mapped_column(Boolean, default=False) 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): class WhiteboardWidgetConfig(Base):
@@ -583,6 +613,10 @@ class WhiteboardWidgetConfig(Base):
url: Mapped[str] = mapped_column(String, default="") url: Mapped[str] = mapped_column(String, default="")
checked_at: Mapped[float] = mapped_column(Float, default=0.0) checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) 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): class WeatherWidgetConfig(Base):
@@ -597,13 +631,19 @@ class WeatherWidgetConfig(Base):
`cached`'s shape depends on `mode`: {"temp","category"} for current, `cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of {"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" __tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) 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 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 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 units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once # Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as # via weather.geocode_city() when set, same idiom as
@@ -652,6 +692,9 @@ class TextWidgetConfig(Base):
font_family: Mapped[str] = mapped_column(String, default="sans") font_family: Mapped[str] = mapped_column(String, default="sans")
align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right" align: Mapped[str] = mapped_column(String, default="left") # "left" | "center" | "right"
background_color: Mapped[str] = mapped_column(String, default="#ffffff") 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): class StaticWidgetConfig(Base):
@@ -673,6 +716,10 @@ class StaticWidgetConfig(Base):
# minus crop_faces -- no face detection for an uploaded image (see # minus crop_faces -- no face detection for an uploaded image (see
# image_pipeline.STATIC_DISPLAY_MODES). # image_pipeline.STATIC_DISPLAY_MODES).
display_mode: Mapped[str] = mapped_column(String, default="crop_fill") 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): class BatteryWidgetConfig(Base):
@@ -683,12 +730,14 @@ class BatteryWidgetConfig(Base):
of anything the widget itself fetches or the user authors. `mode` of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the "compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and 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" __tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed 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 # widget_type -> its per-type extension table, keyed by widget_id. Used
+184
View File
@@ -0,0 +1,184 @@
"""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
# 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 import select
from sqlalchemy.orm import Session 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 ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS from ..global_actions import GLOBAL_ACTIONS
@@ -105,6 +105,10 @@ def api_config_save(
color_boost: float | None = Form(None), color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None), contrast_boost: float | None = Form(None),
dither_strength: 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), hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None), next_hold_action: str | None = Form(None),
back_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)) cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None: if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength)) 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: if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms)) cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None: if next_hold_action is not None:
@@ -244,6 +261,14 @@ def api_status(
"device": { "device": {
"last_seen": frame.last_seen or None, "last_seen": frame.last_seen or None,
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap), "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_version": frame.device_firmware_version or None,
"firmware_available": frame.firmware_available_version or None, "firmware_available": frame.firmware_available_version or None,
"battery": ( "battery": (
@@ -271,6 +296,25 @@ def api_frame_preview(
return Response(content=png, media_type="image/png") 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") @router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute( rows = db.execute(
@@ -384,6 +428,7 @@ def api_firmware_check(
"board": frame.device_board_variant or None, "board": frame.device_board_variant or None,
"latest_version": frame.firmware_gitea_latest_version or None, "latest_version": frame.firmware_gitea_latest_version or None,
"staged_version": frame.firmware_available_version or None, "staged_version": frame.firmware_available_version or None,
"running_version": frame.device_firmware_version or None,
"update_available": update_available, "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"), "photos": ("album_id", "order", "display_mode", "queue_target_len"),
"calendar": ( "calendar": (
"view", "week_start", "weather_enabled", "weather_units", "weather_cities", "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) # widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
+113 -24
View File
@@ -35,11 +35,16 @@ from ..image_pipeline import (
DEFAULT_STATIC_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES, DISPLAY_MODES,
hex_to_rgb, hex_to_rgb,
logical_render_size,
MAX_BORDER_THICKNESS, MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS, MIN_BORDER_THICKNESS,
PALETTE_LABELS, PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
render_preview_png, render_preview_png,
compose_into,
_enhance,
_png_bytes,
_quantize,
) )
from ..image_upload import decode_upload from ..image_upload import decode_upload
from ..models import ( from ..models import (
@@ -317,9 +322,12 @@ def api_widget_config_save(
album_id: str | None = Form(None), album_id: str | None = Form(None),
order: str | None = Form(None), order: str | None = Form(None),
display_mode: 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), queue_target_len: int | None = Form(None),
# calendar # calendar
calendar_view: str | None = Form(None), calendar_view: str | None = Form(None),
calendar_render_style: str | None = Form(None),
calendar_week_start: int | None = Form(None), calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None), calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None), calendar_week_layout: str | None = Form(None),
@@ -329,10 +337,12 @@ def api_widget_config_save(
# tasks # tasks
tasks_name: str | None = Form(None), tasks_name: str | None = Form(None),
tasks_show_completed: bool | None = Form(None), tasks_show_completed: bool | None = Form(None),
tasks_render_style: str | None = Form(None),
# text # text
text_html: str | None = Form(None), text_html: str | None = Form(None),
text_font_size: int | None = Form(None), text_font_size: int | None = Form(None),
text_font_family: str | None = Form(None), text_font_family: str | None = Form(None),
text_render_style: str | None = Form(None),
text_align: str | None = Form(None), text_align: str | None = Form(None),
text_background_color: str | None = Form(None), text_background_color: str | None = Form(None),
# weather # weather
@@ -341,8 +351,10 @@ def api_widget_config_save(
weather_units: str | None = Form(None), weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None), weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None), weather_daily_days: int | None = Form(None),
weather_render_style: str | None = Form(None),
# battery # battery
battery_mode: str | None = Form(None), battery_mode: str | None = Form(None),
battery_render_style: str | None = Form(None),
): ):
"""Every field optional -- same partial-update, form-urlencoded """Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one convention as the old frame-level api_config_save, now scoped to one
@@ -405,6 +417,8 @@ def api_widget_config_save(
# new unit label. # new unit label.
ccfg.weather_checked_at = 0.0 ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units 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": elif widget.widget_type == "tasks":
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg): with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
if tasks_name is not None: if tasks_name is not None:
@@ -415,10 +429,22 @@ def api_widget_config_save(
if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed: if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed:
tcfg.show_completed = tasks_show_completed tcfg.show_completed = tasks_show_completed
tcfg.checked_at = 0.0 # pick up the change promptly 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": elif widget.widget_type == "static":
with widget_locked(db, frame.id, widget.id) as (_, _, scfg): with widget_locked(db, frame.id, widget.id) as (_, _, scfg):
if display_mode is not None: if display_mode is not None:
scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE 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": elif widget.widget_type == "text":
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg): with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
if text_html is not None: if text_html is not None:
@@ -439,6 +465,8 @@ def api_widget_config_save(
xcfg.background_color = ( xcfg.background_color = (
text_background_color if hex_to_rgb(text_background_color) else "#ffffff" 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": elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg): 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"): if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
@@ -469,10 +497,14 @@ def api_widget_config_save(
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours)) wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None: if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days)) 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": elif widget.widget_type == "battery":
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg): with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
if battery_mode is not None: if battery_mode is not None:
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed" 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: with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -833,14 +865,30 @@ def api_widget_preview_calendar(
ccfg = db.get(CalendarWidgetConfig, widget.id) ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) 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 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, target_w, target_h = logical_render_size(frame.orientation)
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, if ccfg.render_style == "modern":
week_start=ccfg.week_start, from zoneinfo import ZoneInfo
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, from .. import calendar_html_render
week_start_offset=ccfg.week_start_offset,
) 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,
)
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,
)
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -859,8 +907,17 @@ def api_widget_preview_tasks(
raise HTTPException(400, "No task lists included on this widget yet") raise HTTPException(400, "No task lists included on this widget yet")
tcfg = db.get(TaskWidgetConfig, widget.id) tcfg = db.get(TaskWidgetConfig, widget.id)
tasks = get_or_refresh_tasks_for_widget(db, frame, widget) tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation, title = tcfg.name or "Tasks"
palette_rgb=frame.palette_rgb, 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)
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)
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -1108,10 +1165,19 @@ def api_widget_preview_weather(
if wcfg.mode == "multi_city": if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet") raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet") raise HTTPException(400, "No location set on this widget yet")
png = weather_render.render_weather_preview_png( if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units, # Same local-import reasoning as widgets/weather.py's render().
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours, 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") return Response(content=png, media_type="image/png")
@@ -1158,11 +1224,22 @@ def api_widget_preview_static(
if not scfg.image: if not scfg.image:
raise HTTPException(400, "No image uploaded to this widget yet") raise HTTPException(400, "No image uploaded to this widget yet")
source = Image.open(io.BytesIO(scfg.image)).convert("RGB") source = Image.open(io.BytesIO(scfg.image)).convert("RGB")
png = render_preview_png( if scfg.render_style == "modern":
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, from .. import html_render
display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, 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") return Response(content=png, media_type="image/png")
@@ -1181,7 +1258,8 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id) xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content): if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet") 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") return Response(content=png, media_type="image/png")
@@ -1297,8 +1375,19 @@ def api_widget_preview_whiteboard(
raise HTTPException(400, "No whiteboard configured on this widget yet") 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") raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
source = Image.open(io.BytesIO(png_bytes)).convert("RGB") source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
png = render_preview_png( if wcfg.render_style == "modern":
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, from .. import html_render
display_mode="letterbox",
) 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") 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 # single noisy reading needs rejecting at the per-wake-drop level, not
# just at the recharge-detection level. # just at the recharge-detection level.
OUTLIER_MODIFIED_Z_THRESHOLD = 3.5 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 # "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it. # 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 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: def battery_estimate_s(frame: Frame, db: Session) -> int | None:
"""Remaining-time estimate from a recency-weighted average of the """Remaining-time estimate from a recency-weighted average of the
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT *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 Consecutive reports are assumed to be consecutive wakes (firmware
reports battery on every wake while on battery), so each step's reports battery on every wake while on battery), so each step's
(prev_percent - next_percent) is that wake's cost. A step where (prev_percent - next_percent) is that wake's cost. Raw percents go
percent went *up* is a recharge, not negative drain, and is skipped through _smooth_percents first, which corrects readings (including
entirely rather than folded in as a weird outlier; a flat step short bursts of them) that are wild outliers against their own local
(0% change) still counts as a real, cheap wake -- excluding those neighborhood -- see that function's docstring for why that catches
would systematically overstate the per-wake cost by only counting noise shapes _reject_outlier_drops can't. A step where percent went
the wakes that happened to tick the percentage down. The remaining *up* is a recharge, not negative drain, and is skipped entirely
steps then get one more pass, _reject_outlier_drops, to catch the rather than folded in as a weird outlier; a flat step (0% change)
single-noisy-reading case that "percent went up" alone can't (see still counts as a real, cheap wake -- excluding those would
that function's docstring). Steps are weighted linearly by recency systematically overstate the per-wake cost by only counting the
(step i of n gets weight i, 1-indexed) so a recent change in usage wakes that happened to tick the percentage down. The remaining steps
pattern shows up quickly instead of being washed out by a long flat then get one more pass, _reject_outlier_drops, to catch whatever
history. 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 resulting %/wake rate is then converted to wall-clock time using
the frame's *current* refresh_interval_s and quiet-hours settings 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: if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
return None return None
percents = list(reversed(rows)) # chronological order percents = list(reversed(rows)) # chronological order
percents = _smooth_percents(percents)
steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct) steps: list[tuple[int, float]] = [] # (recency_weight, drop_pct)
for i in range(1, len(percents)): for i in range(1, len(percents)):
+105 -27
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import logging import logging
import time import time
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
@@ -23,7 +24,7 @@ from sqlalchemy.orm import Session
from .. import grid, mail, quiet_hours from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device 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 ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color 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, 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 """What an unclaimed or widget-less frame displays instead of real
content -- instructions with a QR, rendered at 200 so the device content -- instructions with a QR, rendered at 200 so the device
treats it as a perfectly normal image and never error-loops. The 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, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot,
) )
if frame.owner_user_id is None: if frame.owner_user_id is None:
return render_placeholder( return render_placeholder(
@@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot,
) )
return render_placeholder( return render_placeholder(
["Almost there!", "Add a widget for this frame at", base], ["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, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png, 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, 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 """The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math), into its own region (see app/grid.py for grid-cell -> pixel math),
draws that widget's own optional border directly onto its region 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/ image_pipeline.render_panel for the single shared paste/enhance/
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
dict -- a frame can now show several widgets at once instead of 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( all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all() ).all()
panel_w, panel_h = logical_render_size(frame.orientation) panel_w, panel_h = logical_render_size(frame.orientation)
regions = [] regions = []
for widget in all_widgets: if all_widgets:
module = WIDGET_TYPES.get(widget.widget_type) with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
if module is None: futures = [
continue # unrecognized widget_type -- shouldn't happen, skip defensively rather than 500 pool.submit(
px, py, pw, ph = grid.cell_to_pixels( _render_one_widget, frame.id, widget.id, frame.orientation, panel_w, panel_h,
frame.orientation, panel_w, panel_h, (widget.x, widget.y, widget.w, widget.h) (widget.x, widget.y, widget.w, widget.h), is_normal_wake,
) )
img = module.render(db, frame, widget, pw, ph, is_normal_wake=is_normal_wake) for widget in all_widgets
draw_widget_border( ]
img, widget.border_style, widget.border_thickness, for future in futures:
resolve_border_color(widget.border_color_index, frame.palette_rgb), result = future.result()
) if result is not None:
regions.append(((px, py, pw, ph), img)) regions.append(result)
return render_panel( return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage, as_png=as_png, 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, 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. """The top-level "what does this frame show right now" entry point.
An unclaimed frame or one with no widgets yet gets the setup An unclaimed frame or one with no widgets yet gets the setup
placeholder (needs `request` for its QR URLs -- only available on the 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: if request is None:
return render_placeholder( return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, ["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: def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
@@ -245,6 +295,17 @@ def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1" 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") @router.get("/frame/image")
def frame_image( def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db) request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
@@ -262,9 +323,14 @@ def frame_image(
?manage=1 (the manage button) composites the manage overlay onto ?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content. whatever this would have returned anyway -- see build_manage_content.
This is also the "normal wake" that resets any calendar widget's 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 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") return Response(content=content, media_type="application/octet-stream")
@@ -277,7 +343,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
device's next-photo button.""" device's next-photo button."""
_run_button_actions(db, frame, "next") _run_button_actions(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None 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") return Response(content=content, media_type="application/octet-stream")
@@ -288,7 +357,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.""" with nothing to go back to. Used by the device's back-photo button."""
_run_button_actions(db, frame, "back") _run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None 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") return Response(content=content, media_type="application/octet-stream")
@@ -303,7 +375,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
firmware/main/next_button.c for the short/long split.""" firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next") _run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None 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") return Response(content=content, media_type="application/octet-stream")
@@ -312,7 +387,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
"""The mirror of /frame/global-next, for a held BACK button.""" """The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back") _run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None 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") return Response(content=content, media_type="application/octet-stream")
+5 -1
View File
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import weather from .. import theme_tokens, weather
from ..auth import can_view_frame, current_user from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db from ..db import get_db
@@ -25,6 +25,7 @@ from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import ( from ..image_pipeline import (
BORDER_STYLES, BORDER_STYLES,
BORDER_STYLE_LABELS, BORDER_STYLE_LABELS,
CALIBRATED_SPECTRA6_RGB,
DEFAULT_PALETTE_RGB, DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS, DISPLAY_MODE_LABELS,
MAX_BORDER_THICKNESS, MAX_BORDER_THICKNESS,
@@ -89,9 +90,11 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
timezones=ALL_TIMEZONES, timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS, palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB, default_palette_rgb=DEFAULT_PALETTE_RGB,
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
palette_to_hex=palette_to_hex, palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id, photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS, global_action_labels=GLOBAL_ACTION_LABELS,
themes=theme_tokens.THEMES,
) )
@@ -304,6 +307,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
) )
return templates.TemplateResponse("_widget_dialog_whiteboard.html", { return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user, "request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_cfg": whiteboard_cfg,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg), "whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx, "viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
}) })
+32 -1
View File
@@ -13,7 +13,7 @@ import logging
import time import time
from fastapi import APIRouter, Depends, Form, HTTPException, Request 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 fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -35,6 +35,7 @@ from ..auth import (
verify_password, verify_password,
) )
from ..db import get_db from ..db import get_db
from ..logging_setup import LOG_PATH, read_log_tail
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
from .common import valid_http_url 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), "smtp": get_server_settings(db),
"notice": notice, "notice": notice,
"error": error, "error": error,
"active_admin_tab": "main",
}) })
return templates.TemplateResponse("admin.html", ctx) 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) 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) @router.post("/admin/users", response_class=HTMLResponse)
def admin_create_user( def admin_create_user(
request: Request, 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 // Shared display names for widget_type, everywhere one shows up in the
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
const WIDGET_LABELS = { const WIDGET_LABELS = {
+8
View File
@@ -18,6 +18,14 @@ function renderDeviceStatusBar(device) {
} }
const now = Date.now() / 1000; const now = Date.now() / 1000;
const rows = []; 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)); const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push(['Last seen', `${ago} ago`, device.overdue]); rows.push(['Last seen', `${ago} ago`, device.overdue]);
if (device.firmware_version) { if (device.firmware_version) {
+93 -23
View File
@@ -114,7 +114,7 @@ async function loadControl() {
document.getElementById('take-control').addEventListener('click', takeControl); 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 // Hex field and R/G/B number fields are kept in sync live, both
// directions -- editing either updates the other plus the preview // 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 // 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 // an alternate, more precise way to arrive at the same value than
// eyeballing a color-picker swatch. // 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() { function paletteHexInputs(classPrefix) {
return Array.from(document.querySelectorAll('.palette-hex')) return Array.from(document.querySelectorAll(`.${classPrefix}-hex`))
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index)); .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]; 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}"]`); 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) { function syncPaletteFromHex(classPrefix, index) {
const f = paletteFieldsFor(index); const f = paletteFieldsFor(classPrefix, index);
const rgb = rgbFromHex(f.hex.value); const rgb = rgbFromHex(f.hex.value);
if (!rgb) return; if (!rgb) return;
[f.r.value, f.g.value, f.b.value] = rgb; [f.r.value, f.g.value, f.b.value] = rgb;
f.swatch.style.background = f.hex.value; f.swatch.style.background = f.hex.value;
} }
function syncPaletteFromRgb(index) { function syncPaletteFromRgb(classPrefix, index) {
const f = paletteFieldsFor(index); const f = paletteFieldsFor(classPrefix, index);
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value); const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
f.hex.value = hex; f.hex.value = hex;
f.swatch.style.background = hex; f.swatch.style.background = hex;
} }
const palettePickerCount = paletteHexInputs().length; function wirePaletteInputs(classPrefix) {
for (let i = 0; i < palettePickerCount; i++) { const count = paletteHexInputs(classPrefix).length;
const f = paletteFieldsFor(i); for (let i = 0; i < count; i++) {
f.hex.addEventListener('input', () => syncPaletteFromHex(i)); const f = paletteFieldsFor(classPrefix, i);
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(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 // Sliders: live numeric readout next to each, no save until the button
// below is clicked. // 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 input = document.getElementById(id);
const readout = document.getElementById(`${id}_value`); const readout = document.getElementById(`${id}_value`);
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); }); 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 || {}); const body = new URLSearchParams(extra || {});
for (const input of paletteHexInputs()) { for (const input of paletteHexInputs(classPrefix)) {
body.append('palette', input.value); body.append(paletteFormKey, input.value);
} }
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -195,7 +209,7 @@ async function savePalette(extra) {
} }
document.getElementById('palette-save').addEventListener('click', () => { document.getElementById('palette-save').addEventListener('click', () => {
savePalette({ savePalette('palette', 'palette', {
color_boost: document.getElementById('color_boost').value, color_boost: document.getElementById('color_boost').value,
contrast_boost: document.getElementById('contrast_boost').value, contrast_boost: document.getElementById('contrast_boost').value,
dither_strength: document.getElementById('dither_strength').value, dither_strength: document.getElementById('dither_strength').value,
@@ -203,16 +217,65 @@ document.getElementById('palette-save').addEventListener('click', () => {
}); });
document.getElementById('palette-reset').addEventListener('click', () => { document.getElementById('palette-reset').addEventListener('click', () => {
const inputs = paletteHexInputs(); const inputs = paletteHexInputs('palette');
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => { window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
inputs[i].value = hex; inputs[i].value = hex;
syncPaletteFromHex(i); syncPaletteFromHex('palette', i);
}); });
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => { ['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
document.getElementById(id).value = '1'; document.getElementById(id).value = '1';
document.getElementById(`${id}_value`).textContent = '1.00'; 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 ---- // ---- 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.'; statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
btn.style.display = 'none'; btn.style.display = 'none';
} else if (data.update_available) { } 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'; 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) { } else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`; statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none'; btn.style.display = 'none';
+99 -40
View File
@@ -58,48 +58,31 @@
}); });
})(); })();
// Live "how it's displaying" thumbnail. A real composite render (same // Now-displaying / up-next header preview pair. "Up next" is a real
// pipeline /frame/image uses), not a cached snapshot, so it's on a slow // composite render (same pipeline /frame/image uses), not a cached
// poll rather than something tighter like the 10s device-status poll -- // snapshot, so it's on a slow poll rather than something tighter like
// no need to hit Immich/calendar/whiteboard sources that often just for // the 10s device-status poll -- no need to hit Immich/calendar/
// a header thumbnail. Click enlarges it in a dialog (which also fetches // whiteboard sources that often just for a header thumbnail, and it
// a fresh render); clicking the enlarged image refreshes it again. // 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 () { (function () {
var thumb = document.getElementById('frame-preview-thumb'); var nextThumb = document.getElementById('frame-preview-thumb');
var dialog = document.getElementById('frame-preview-dialog'); var nextDialog = document.getElementById('frame-preview-dialog');
var bigImg = document.getElementById('frame-preview-dialog-img'); var nextBigImg = document.getElementById('frame-preview-dialog-img');
var closeBtn = document.getElementById('frame-preview-dialog-close'); var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
if (!thumb || !window.FRAME_BASE_API) return; 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() { // Same backdrop-click-to-close trick as #widget-dialog: a click that
return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`; // lands on the dialog element itself (not its content box) means the
} // backdrop was hit.
function refreshThumb() { function closeOnBackdropClick(dialog) {
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.
dialog.addEventListener('click', function (e) { dialog.addEventListener('click', function (e) {
if (e.target !== dialog) return; if (e.target !== dialog) return;
var rect = dialog.getBoundingClientRect(); var rect = dialog.getBoundingClientRect();
@@ -107,4 +90,80 @@
if (!inside) dialog.close(); 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 { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; } .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 { h2.card-title, summary.card-title {
font-size: 14.5px; font-size: 14.5px;
font-weight: 650; font-weight: 650;
@@ -243,6 +261,36 @@ input[type="range"] {
} }
.card + .card { margin-top: 20px; } .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 { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; } label:first-child { margin-top: 0; }
@@ -719,13 +767,24 @@ code {
} }
.frame-name-edit button { margin-top: 0; } .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 { .frame-preview-thumb {
height: 44px; height: 44px;
width: auto; width: auto;
max-width: 130px; max-width: 130px;
object-fit: contain; object-fit: contain;
vertical-align: middle; vertical-align: middle;
margin-left: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
background: var(--surface-alt); background: var(--surface-alt);
@@ -733,6 +792,13 @@ code {
transition: opacity .12s ease; transition: opacity .12s ease;
} }
.frame-preview-thumb:hover { opacity: 0.8; } .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 { .frame-preview-dialog {
position: fixed; position: fixed;
@@ -13,6 +13,7 @@ function initBatteryDialog() {
e.preventDefault(); e.preventDefault();
const body = new URLSearchParams({ const body = new URLSearchParams({
battery_mode: document.getElementById('battery_mode').value, battery_mode: document.getElementById('battery_mode').value,
battery_render_style: document.getElementById('battery_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { 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_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value, calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value, calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
calendar_render_style: document.getElementById('calendar_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -38,6 +38,7 @@ function initStaticDialog() {
e.preventDefault(); e.preventDefault();
const body = new URLSearchParams({ const body = new URLSearchParams({
display_mode: document.getElementById('display_mode').value, display_mode: document.getElementById('display_mode').value,
static_render_style: document.getElementById('static_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
+1
View File
@@ -71,6 +71,7 @@ function initTasksDialog() {
const body = new URLSearchParams({ const body = new URLSearchParams({
tasks_name: document.getElementById('tasks_name').value, tasks_name: document.getElementById('tasks_name').value,
tasks_show_completed: String(document.getElementById('tasks_show_completed').checked), tasks_show_completed: String(document.getElementById('tasks_show_completed').checked),
tasks_render_style: document.getElementById('tasks_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
+1
View File
@@ -113,6 +113,7 @@ function initTextDialog() {
text_font_size: document.getElementById('text_font_size').value, text_font_size: document.getElementById('text_font_size').value,
text_align: document.getElementById('text_align').value, text_align: document.getElementById('text_align').value,
text_background_color: document.getElementById('text_background_color').value, text_background_color: document.getElementById('text_background_color').value,
text_render_style: document.getElementById('text_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { 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-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : ''; document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
document.getElementById('weather-cities-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) { function addWeatherWidgetCityRow(label) {
@@ -74,6 +80,7 @@ function initWeatherDialog() {
weather_units: document.getElementById('weather_units').value, weather_units: document.getElementById('weather_units').value,
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value, weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
weather_daily_days: document.getElementById('weather_daily_days').value, weather_daily_days: document.getElementById('weather_daily_days').value,
weather_render_style: document.getElementById('weather_render_style').value,
}); });
try { try {
const resp = await fetch(`${window.FRAME_API}/config`, { const resp = await fetch(`${window.FRAME_API}/config`, {
@@ -90,6 +90,25 @@ function initWhiteboardDialog() {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource); 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 // Shows whatever's already cached (cheap, no refetch) on open; the
// button is the one place that means "no really, go check now" -- // button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_widget_preview_ // 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" id="frame-name-save">Save</button>
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button> <button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
</span> </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"> <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> <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> </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> <option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
</select> </select>
</label> </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> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -40,6 +40,12 @@
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the <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> past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div> </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> <button type="submit">Save</button>
</form> </form>
@@ -32,6 +32,12 @@
exactly without cropping (an image with a different aspect ratio exactly without cropping (an image with a different aspect ratio
looks stretched); <strong>Shrink to fit</strong> shows the whole looks stretched); <strong>Shrink to fit</strong> shows the whole
image, letterboxed if needed.</p> 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> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -55,6 +55,12 @@
<input type="checkbox" id="tasks_show_completed" {% if task_cfg.show_completed %}checked{% endif %}> <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> <label for="tasks_show_completed">Also show tasks completed in the last 24 hours</label>
</div> </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> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -43,6 +43,12 @@
<label>Background color <label>Background color
<input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}"> <input type="color" id="text_background_color" value="{{ text_cfg.background_color if text_cfg else '#ffffff' }}">
</label> </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> <button type="submit">Save</button>
</form> </form>
</section> </section>
@@ -11,6 +11,14 @@
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option> <option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
</select> </select>
</label> </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 <label>Weather source
<select id="weather_provider"> <select id="weather_provider">
{% for value, label in weather_provider_labels.items() %} {% for value, label in weather_provider_labels.items() %}
@@ -50,6 +50,19 @@
{% endif %} {% endif %}
</section> </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_border_fields.html" %}
{% include "_widget_button_fields.html" %} {% include "_widget_button_fields.html" %}
+2
View File
@@ -4,6 +4,8 @@
{% block page_title %}Administration{% endblock %} {% block page_title %}Administration{% endblock %}
{% block content %} {% block content %}
{% include "_admin_tabs.html" %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %} {% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %} {% if error %}<div class="status err">{{ error }}</div>{% endif %}
+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> </script>
<link rel="stylesheet" href="/static/theme.css"> <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 %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
+8
View File
@@ -17,6 +17,14 @@
})(); })();
</script> </script>
<link rel="stylesheet" href="/static/theme.css"> <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 %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
+69 -5
View File
@@ -139,11 +139,12 @@
<details class="card"> <details class="card">
<summary class="card-title">Advanced configuration</summary> <summary class="card-title">Advanced configuration</summary>
<p class="sub">Color-quantization values used when dithering photos <p class="sub">Color-quantization values used for every widget
for this panel -- approximations by default, since exact primaries <strong>except photos</strong> (which has its own separate
aren't published. Tune them by comparing a rendered photo against settings below) -- approximations by default, since exact
the physical panel; different panel units can vary enough to be primaries aren't published. Tune them by comparing a rendered
worth calibrating per frame.</p> widget against the physical panel; different panel units can
vary enough to be worth calibrating per frame.</p>
<div class="palette-table-wrap"> <div class="palette-table-wrap">
<table class="palette-table"> <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> <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-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-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> </details>
<section class="card"> <section class="card">
@@ -221,6 +284,7 @@
<script> <script>
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }}; window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | 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" }}; window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
</script> </script>
<script src="/static/device_status_bar.js"></script> <script src="/static/device_status_bar.js"></script>
@@ -0,0 +1,32 @@
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %}
<div class="day-section">
{#- Fixed height (not auto) -- every stacked day-section's header
must be exactly this tall regardless of whether THIS particular
day has a weather entry, or days with/without weather misalign
where their event rows start (see calendar_week_horizontal's
identical fix/reasoning). #}
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;">
<div class="day-header-title">{{ header }}</div>
{% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %}
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}&deg;/{{ we.low }}&deg;{{ unit_suffix }}</span></div>
{% endfor %}
</div>
{% endif %}
</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,53 @@
<!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; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.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;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.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: 1; color: {{ fill_color }}; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="icon-wrap">
<div class="icon-body"><div class="icon-fill"></div></div>
<div class="icon-nub"></div>
</div>
<div class="pct">{{ percent }}%</div>
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
</div>
</body></html>
@@ -0,0 +1,36 @@
<!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; }
.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.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 4px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
</div>
</body></html>
@@ -0,0 +1,65 @@
<!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; }
.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.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; }
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; }
.week-row { flex: 1 1 0; display: flex; }
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; }
{#- 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="card">
<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,39 @@
<!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; }
.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.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
.day-section + .day-section { border-top: 1px solid #e2e6ec; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); }
.day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 2px; }
</style></head>
<body>
{% import "_calendar_day_section.html.jinja" as ds %}
<div class="card">
{% for day in days %}
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }}
{% endfor %}
</div>
</body></html>
@@ -0,0 +1,60 @@
<!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; }
.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.2);{% endif %}
background: #ffffff;
display: flex;
}
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
.col + .col { border-left: 1px solid #e2e6ec; }
.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;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
padding: 6px 6px;
overflow: hidden;
}
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; }
.col-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="card">
{% for col in cols %}
<div class="col">
<div class="col-header">
<div class="label">{{ col.label }}</div>
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}&deg;/{{ col.weather.low }}&deg;{{ unit_suffix }}</span></div>{% endif %}
</div>
<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>
</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,62 @@
<!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; }
.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.2);{% endif %}
background: #ffffff;
display: flex;
flex-direction: column;
}
.header {
height: {{ header_h }}px;
flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; line-height: 1; }
.rows { flex: 1 1 auto; padding: 8px 14px; 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; }
.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="card">
<div class="header"><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,30 @@
<!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; }
.card {
width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.icon { font-size: {{ icon_size }}px; line-height: 1; filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.18)); }
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; }
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; }
</style></head>
<body>
<div class="card">
<div class="icon">{{ emoji }}</div>
<div class="temp">{{ temp }}&deg;{{ unit_suffix }}</div>
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
</div>
</body></html>
@@ -0,0 +1,51 @@
<!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; }
.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;
}
.header {
height: {{ header_h }}px;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
display: flex;
align-items: center;
padding: 0 16px;
}
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; }
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; }
.col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
}
.day { font-weight: 600; font-size: {{ label_size }}px; color: #17233b; }
.icon { font-size: {{ icon_size }}px; line-height: 1; }
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; }
.temps .low { color: #6b7788; font-weight: 400; }
</style></head>
<body>
<div class="card">
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %}
<div class="body">
{% for d in days %}
<div class="col">
<div class="day">{{ d.label }}</div>
<div class="icon">{{ d.emoji }}</div>
<div class="temps">{{ d.high }}&deg;<span class="low">/{{ d.low }}&deg;{{ unit_suffix }}</span></div>
</div>
{% 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 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) BG = (255, 255, 255)
FG = (0, 0, 0) FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (0, 0, 0)
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]: def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB """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, index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
already this module's BG/FG) -- same resolution idiom as already this module's BG/FG) -- thin wrapper over panel_style.ink
calendar_render.py's _event_colors, so a custom palette override (which generalized this same resolution idiom), kept so every
(Frame.palette_rgb) still gets its own actual yellow/blue, and every draw_weather_icon call site below doesn't need touching."""
fill stays an exact, ditherless palette match either way.""" return panel_style.ink(palette_rgb, index)
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None: 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, 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 """Largest size <= max_size at which every string in `texts` fits
within max_width -- used to size a per-column label/temp font against within max_width -- used to size a per-column label/temp font against
the actual column width instead of an icon-radius-derived guess, the actual column width instead of an icon-radius-derived guess,
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the 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): 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): if all(draw.textlength(t, font=font) <= max_width for t in texts):
return size return size
return min_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, (callers normally catch that earlier and show a placeholder instead,
but this degrades to a blank canvas rather than erroring either but this degrades to a blank canvas rather than erroring either
way).""" 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: if not entry:
return img return img
draw = ImageDraw.Draw(img)
icon_r = max(20, min(target_w, target_h) // 4) icon_r = max(20, min(cw, ch) // 4)
cx, cy = target_w // 2, target_h // 2 - icon_r // 2 cx, cy = cx0 + cw // 2, cy0 + ch // 2 - icon_r // 2
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(target_w, target_h) // 3) temp_size = max(24, min(cw, ch) // 3)
temp_font = ImageFont.load_default(size=temp_size) temp_font = panel_style.font_bold(temp_size)
temp_text = f"{round(entry['temp'])}°{unit_suffix}" temp_text = f"{round(entry['temp'])}°{unit_suffix}"
bbox = draw.textbbox((0, 0), temp_text, font=temp_font) bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
temp_y = cy + icon_r + 12 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: if city_label:
label_size = max(12, temp_size // 3) 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) 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), draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font, MUTED) city_label, label_font)
return img 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 fetch_hourly), each showing an hour label, icon, and temp. Same
"draw however many fit" graceful degradation as draw_weather_row if "draw however many fit" graceful degradation as draw_weather_row if
the box is too narrow for every tick.""" the box is too narrow for every tick."""
img = Image.new("RGB", (target_w, target_h), BG) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
draw = ImageDraw.Draw(img) text_x0 = cx0 + MARGIN
text_x0 = MARGIN text_w = cw - MARGIN * 2
text_w = target_w - MARGIN * 2 y = cy0 + MARGIN
y = MARGIN
title_size = max(14, min(target_w, target_h) // 16) title_size = max(14, min(cw, ch) // 16)
if city_label: if city_label:
title_font = ImageFont.load_default(size=title_size) # A filled header bar (this widget's chosen accent is black, not
draw_text(img, (text_x0, y), city_label, title_font) # a color, so the hand-drawn icons below stay the star -- see
y += title_size + 10 # panel_style module docstring) replaces the old plain title +
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) # thin rule line.
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
# Capped to however many columns actually fit at a legible width # Capped to however many columns actually fit at a legible width
# (narrower widgets/smaller intervals just show fewer ticks) rather # (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: if not ticks:
return img return img
col_w = max(1, text_w // len(ticks)) 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" unit_suffix = "F" if units == "fahrenheit" else "C"
time_labels = [_format_hour_label(e["time"]) for e in ticks] time_labels = [_format_hour_label(e["time"]) for e in ticks]
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" 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_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): for i, entry in enumerate(ticks):
cx = text_x0 + i * col_w + col_w // 2 cx = text_x0 + i * col_w + col_w // 2
time_label = time_labels[i] time_label = time_labels[i]
tbbox = draw.textbbox((0, 0), time_label, font=label_font) tbbox = draw.textbbox((0, 0), time_label, font=time_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font)
cy = y + label_size + 10 + icon_r cy = y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
temp_label = temp_labels[i] temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=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, label_font) draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font)
return img 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 configured day count by app/weather's provider fetch_daily -- this
just draws whatever it's handed, same "stop once it doesn't fit" just draws whatever it's handed, same "stop once it doesn't fit"
graceful degradation as draw_weather_row).""" graceful degradation as draw_weather_row)."""
img = Image.new("RGB", (target_w, target_h), BG) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
draw = ImageDraw.Draw(img) text_x0 = cx0 + MARGIN
text_x0 = MARGIN text_w = cw - MARGIN * 2
text_w = target_w - MARGIN * 2 y = cy0 + MARGIN
y = MARGIN
title_size = max(14, min(target_w, target_h) // 16) title_size = max(14, min(cw, ch) // 16)
if city_label: if city_label:
title_font = ImageFont.load_default(size=title_size) header_h = title_size + 20
draw_text(img, (text_x0, y), city_label, title_font) panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
y += title_size + 10 panel_style.theme_color("weather", palette_rgb))
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) title_font = panel_style.font_bold(title_size)
y += 12 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()) days = list(daily.items())
if not days: if not days:
return img return img
col_w = max(1, text_w // len(days)) 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" unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days] 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] 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_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): for i, (_, d) in enumerate(days):
x0 = text_x0 + i * col_w x0 = text_x0 + i * col_w
label, temps = labels[i], temps_strs[i] label, temps = labels[i], temps_strs[i]
lbbox = draw.textbbox((0, 0), label, font=label_font) 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 cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb) draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
tbbox = draw.textbbox((0, 0), temps, font=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, label_font) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font)
return img 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 reuses draw_weather_row (the same layout calendar_render.py's
embedded strip uses), just as the whole widget's own content instead embedded strip uses), just as the whole widget's own content instead
of a strip above an agenda day.""" 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: if not cities:
return img return img
draw = ImageDraw.Draw(img)
# Just the city name on-panel ("Portland", not the full disambiguated # Just the city name on-panel ("Portland", not the full disambiguated
# "Portland, Oregon, United States") -- that fuller form matters for # "Portland, Oregon, United States") -- that fuller form matters for
# telling apart geocoder candidates when adding a city (see # 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 # calendar_render.py's _weather_for_day already does for its own
# embedded strip. # embedded strip.
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities] 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, # 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 # 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 # 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, # doesn't fit" degradation silently dropped every city after it,
# even in an ordinary-sized widget with plenty of cities configured. # even in an ordinary-sized widget with plenty of cities configured.
col_w = max(1, text_w // len(cities)) 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" unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities] 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_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r,
font = ImageFont.load_default(size=font_size) font_loader=panel_style.font_regular)
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2) font = panel_style.font_regular(font_size)
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, 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) show_labels=True, palette_rgb=palette_rgb)
return img 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 __future__ import annotations
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw
_BG = (245, 245, 245) from .. import panel_style
_FG = (90, 90, 90) from ..image_pipeline import draw_text
_BG = (255, 255, 255)
def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image: def placeholder_image(target_w: int, target_h: int, lines: list[str]) -> Image.Image:
img = Image.new("RGB", (target_w, target_h), _BG) img = Image.new("RGB", (target_w, target_h), _BG)
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
font_size = max(10, min(20, target_h // 8)) 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 line_h = font_size + 4
total_h = line_h * len(lines) total_h = line_h * len(lines)
y = max(4, (target_h - total_h) // 2) 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) bbox = draw.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0] line_w = bbox[2] - bbox[0]
x = max(4, (target_w - line_w) // 2) 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 y += line_h
return img return img
+57 -64
View File
@@ -17,10 +17,11 @@ from __future__ import annotations
import io import io
import time import time
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw
from sqlalchemy.orm import Session 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 ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s from ..routers.common import battery_estimate_s
from ._shared import placeholder_image from ._shared import placeholder_image
@@ -28,45 +29,16 @@ from ._shared import placeholder_image
ACTIONS: dict = {} ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {} 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 def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
# shared code -- that one draws onto the manage-QR overlay in a fixed palette_rgb: list | None = None) -> None:
# small size, this one fills an arbitrary widget region -- but the """Centers panel_style.draw_battery_icon (top-left-anchored) under
# "how worried should I be" color story should read the same wherever a `cx` -- this widget's own layout picks a center point, that helper's
# battery glyph shows up on a panel). Exact panel ink RGB values, not shared implementation (also used by manage_overlay.py's battery
# arbitrary reds/yellows/greens -- a flat fill already at a palette readout) just needs a top-left corner."""
# 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)
nub_w = max(3, icon_w // 10) nub_w = max(3, icon_w // 10)
nub_h = icon_h // 2
x0 = cx - (icon_w + nub_w) // 2 x0 = cx - (icon_w + nub_w) // 2
y0 = top panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
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))
def _format_estimate(seconds: float) -> str: def _format_estimate(seconds: float) -> str:
@@ -88,6 +60,21 @@ def _format_age(as_of: float) -> str:
return f"{round(delta / 86400)}d ago" 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, def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image: is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's """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) cfg = db.get(BatteryWidgetConfig, widget.id)
mode = cfg.mode if cfg else "detailed" 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) if cfg and cfg.render_style == "modern":
draw = ImageDraw.Draw(img) # Local import: html_render pulls in Playwright, a real headless-
cx = target_w // 2 # 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_w = int(icon_h * 1.8)
icon_top = max(4, target_h // 8) icon_top = max(4, cy0 + ch // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent) _draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
pct_font_size = max(18, min(target_w, target_h) // 3) # The percent number picks up the icon's own charge-level color
pct_font = ImageFont.load_default(size=pct_font_size) # (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}%" pct_text = f"{percent}%"
bbox = draw.textbbox((0, 0), pct_text, font=pct_font) bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
pct_y = icon_top + icon_h + 10 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": small_font_size = max(11, pct_font_size // 3)
lines = [] small_font = panel_style.font_regular(small_font_size)
estimate_s = battery_estimate_s(frame, db) y = pct_y + pct_font_size + 12
if estimate_s is not None: for line in lines:
lines.append(_format_estimate(estimate_s)) if y + small_font_size > cy0 + ch - 4:
if frame.battery_as_of: break
lines.append(f"Reported {_format_age(frame.battery_as_of)}") lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
small_font_size = max(11, pct_font_size // 3) y += small_font_size + 6
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
return img return img
+15
View File
@@ -19,6 +19,8 @@ from __future__ import annotations
from PIL import Image from PIL import Image
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from zoneinfo import ZoneInfo
from ..calendar_render import _build from ..calendar_render import _build
from ..db import widget_locked from ..db import widget_locked
from ..models import CalendarWidgetConfig, Frame, Widget from ..models import CalendarWidgetConfig, Frame, Widget
@@ -47,6 +49,19 @@ 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) 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 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,
)
return _build( return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h, 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, timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
+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 several couldn't be composed this cycle; it falls back to a small
placeholder instead, the same resilience calendar mode's old photo-inlay placeholder instead, the same resilience calendar mode's old photo-inlay
already had (see routers/device.py's `except HTTPException: pass` around 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 from __future__ import annotations
@@ -18,7 +34,7 @@ from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours from .. import photo_queue, quiet_hours
from ..db import widget_locked 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 ..models import Frame, PhotoWidgetConfig, Widget
from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets from ..routers.common import fetch_source_and_faces, immich_client_for, list_assets
from ._shared import placeholder_image 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: except HTTPException as e:
return placeholder_image(target_w, target_h, ["Photos widget", str(e.detail)[:40]]) 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: 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: if not cfg.image:
return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"]) return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"])
source = Image.open(io.BytesIO(cfg.image)).convert("RGB") 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
+9 -1
View File
@@ -36,7 +36,15 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
signature regardless of which ones actually care.""" signature regardless of which ones actually care."""
tasks = get_or_refresh_tasks_for_widget(db, frame, widget) tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
cfg = db.get(TaskWidgetConfig, widget.id) 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)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title)
ACTIONS: dict = {} ACTIONS: dict = {}
+40 -63
View File
@@ -20,12 +20,11 @@ block of authored text."""
from __future__ import annotations from __future__ import annotations
import re 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 sqlalchemy.orm import Session
from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text from ..text_content import has_text
@@ -40,66 +39,20 @@ LINE_HEIGHT_FACTOR = 1.35
DEFAULT_FG = (0, 0, 0) DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255) DEFAULT_BG = (255, 255, 255)
_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts" # The font-family table (a small curated set, not an open-ended picker --
# each entry needs a real vendored Regular/Bold/Italic/BoldItalic file)
# A small curated set, not an open-ended picker -- each entry needs a # lives in app/theme_tokens.py now, shared with every modern-style
# real vendored Regular/Bold/Italic/BoldItalic file, so families that # widget's own font resolution -- re-exported here under their original
# only ship as a variable font (Playfair Display, Lora, Merriweather, # names since this was the text widget's own table before the theme
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor # system needed it too (see app/fonts/OFL-*.txt for each non-Noto
# of static builds from their own upstream repos where one exists (see # family's own license/copyright).
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright -- DEFAULT_FONT_FAMILY = theme_tokens.DEFAULT_FONT_FAMILY
# they're all OFL, same as the Noto fonts already vendored here, but FONT_FAMILIES = theme_tokens.FONT_FAMILIES
# each has a different copyright holder so gets its own license file _FONT_FILES = theme_tokens._FONT_FILES
# rather than sharing app/fonts/OFL.txt). _font = theme_tokens.font
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",
},
}
_WORD_OR_SPACE = re.compile(r"\S+|\s+") _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]]: def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
"""One paragraph's styled runs -> word groups: each group is a list """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 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 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, def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image: is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's """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) cfg = db.get(TextWidgetConfig, widget.id)
if cfg is None or not has_text(cfg.content): if cfg is None or not has_text(cfg.content):
return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"]) 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 -- """A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at 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 import io
target_w, target_h = logical_render_size(orientation) 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) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG") quantized.convert("RGB").save(buf, format="PNG")
+18 -3
View File
@@ -1,9 +1,12 @@
"""Weather widget: one of four display modes (see models. """Weather widget: one of four display modes (see models.
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render. PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
py's build() dispatch. No real "next"/"back" concept (same as py's build() dispatch -- or, for "current"/"daily" modes with
whiteboard) -- a single "check now" action forces a re-fetch bypassing render_style="modern", by app/html_render.py's Jinja2/headless-Chromium
the normal throttle.""" 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 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) data = get_or_refresh_weather_widget_data(db, frame, widget)
if data is None: if data is None:
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"]) 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( return weather_render.build(
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units, cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours, 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 sqlalchemy.orm import Session
from ..image_pipeline import compose_into 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 ..routers.common import get_or_refresh_whiteboard_for_widget
from ._shared import placeholder_image 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 # letterbox, never cropped: unlike a photo, losing part of a
# whiteboard to a crop loses actual content, not just some background # whiteboard to a crop loses actual content, not just some background
# (see the old _render_whiteboard_mode's identical reasoning). # (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: def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
+2
View File
@@ -11,3 +11,5 @@ icalendar==7.2.2
recurring-ical-events==3.8.2 recurring-ical-events==3.8.2
caldav==3.2.1 caldav==3.2.1
pypdfium2==5.12.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 # Then execs uvicorn as the foreground/PID 1 process so it receives
# Docker's stop signal directly. # 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 # Wrapped in a restart loop, not a bare `node ... &`: a bare background
# process that crashes stays dead for good, with nothing to bring it # process that crashes stays dead for good, with nothing to bring it
# back -- turning any single render crash (a not-yet-found jsdom/ # back -- turning any single render crash (a not-yet-found jsdom/
+4
View File
@@ -29,6 +29,10 @@ from pathlib import Path
_tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_") _tmp_dir = tempfile.mkdtemp(prefix="espresso_frame_tests_")
os.environ["DATABASE_URL"] = f"sqlite:///{Path(_tmp_dir) / 'test.db'}" 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 import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
+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 """_reject_outlier_drops and _smooth_percents -- the two outlier-rejection
remaining-time estimate (see routers/common.py's battery_estimate_s). passes in the battery remaining-time estimate (see routers/common.py's
Pure function, no DB/HTTP -- (recency_weight, drop_pct) pairs in, battery_estimate_s). Pure functions, no DB/HTTP."""
filtered pairs out."""
from __future__ import annotations 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]]: 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]) steps = _steps([1, 1, 1, 1, 10, 10, 10, 10])
kept = _reject_outlier_drops(steps) kept = _reject_outlier_drops(steps)
assert len(kept) > 0 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
@@ -144,3 +144,50 @@ def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_s
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id) photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert cal_cfg.browse_offset == 1 assert cal_cfg.browse_offset == 1
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it 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"
+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())
+31
View File
@@ -92,6 +92,18 @@ def test_expected_columns_exist_on_current_schema():
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")} 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 "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 {"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
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -331,6 +343,25 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
assert frame.last_cycled_layout_id is None 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_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session): 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 """Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database SQL back to back (the real "existing widget-system database
+94
View File
@@ -0,0 +1,94 @@
"""GET /api/frames/{id}/now-displaying -- the frozen half of the header
preview pair (see routers/device.py's _record_last_displayed). Distinct
from /preview (test_frame_preview.py): that one always live-renders,
this one serves back exactly whatever bytes a device-facing endpoint
last actually sent, recorded as a side effect of /frame/image,
/frame/advance, and /frame/back."""
from __future__ import annotations
import io
from PIL import Image
from app.image_pipeline import logical_render_size
from app.models import Frame
from .conftest import link_user, login, make_user
def test_now_displaying_404s_before_any_device_fetch(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 404
def test_frame_image_records_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp = client.get("/frame/image")
assert resp.status_code == 200
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
assert "X-Displayed-At" in resp.headers
assert float(resp.headers["X-Displayed-At"]) > 0
img = Image.open(io.BytesIO(resp.content))
assert img.size == logical_render_size(frame.orientation)
def test_advance_and_back_also_update_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
db_session.get(Frame, 1)
client.get("/frame/image")
first = client.get("/api/frames/1/now-displaying")
assert first.status_code == 200
resp = client.post("/frame/advance")
assert resp.status_code == 200
after_advance = client.get("/api/frames/1/now-displaying")
assert after_advance.status_code == 200
resp = client.post("/frame/back")
assert resp.status_code == 200
after_back = client.get("/api/frames/1/now-displaying")
assert after_back.status_code == 200
assert float(after_back.headers["X-Displayed-At"]) >= float(first.headers["X-Displayed-At"])
def test_now_displaying_visible_to_linked_user(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
client.get("/frame/image")
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 200
def test_now_displaying_hidden_from_unrelated_user(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.get("/frame/image")
client.cookies.clear()
login(client, "mallory")
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code == 404
def test_now_displaying_requires_login(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
client.get("/frame/image")
client.cookies.clear()
resp = client.get("/api/frames/1/now-displaying")
assert resp.status_code in (401, 403)
+53
View File
@@ -27,6 +27,7 @@ from app.models import (
TaskWidgetConfig, TaskWidgetConfig,
User, User,
Widget, Widget,
WeatherWidgetConfig,
) )
from .conftest import csrf_headers, link_user, login, make_user from .conftest import csrf_headers, link_user, login, make_user
@@ -56,6 +57,16 @@ def _add_tasks_widget(db_session, frame_id=1, x=3, y=0, w=2, h=2, sort_order=2)
return widget return widget
def _add_weather_widget(db_session, frame_id=1, x=0, y=0, w=2, h=2, sort_order=1) -> Widget:
widget = Widget(frame_id=frame_id, widget_type="weather", x=x, y=y, w=w, h=h,
sort_order=sort_order, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WeatherWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _setup_alice(client) -> None: def _setup_alice(client) -> None:
resp = client.post("/setup", data={"username": "alice", "password": "hunter22"}) resp = client.post("/setup", data={"username": "alice", "password": "hunter22"})
assert resp.status_code == 303, resp.text assert resp.status_code == 303, resp.text
@@ -89,6 +100,48 @@ def test_save_captures_placement_and_photo_settings_but_not_queue_state(client,
"queue_target_len": 30} "queue_target_len": 30}
def test_save_and_apply_round_trip_weather_settings(client, db_session):
_setup_alice(client)
weather = _add_weather_widget(db_session)
with db_session.no_autoflush:
wcfg = db_session.get(WeatherWidgetConfig, weather.id)
wcfg.mode = "daily"
wcfg.provider = "nws"
wcfg.units = "celsius"
wcfg.city_label = "Boston, MA"
wcfg.city_latitude = 42.36
wcfg.city_longitude = -71.06
wcfg.hourly_interval_hours = 6
wcfg.daily_days = 7
wcfg.checked_at = 12345.0
wcfg.cached = {"stale": "runtime state, not a setting"}
db_session.commit()
db_session.expunge(wcfg)
save_resp = client.post("/api/frames/1/layouts", json={"name": "Weather Layout"}, headers=csrf_headers(client))
assert save_resp.status_code == 200, save_resp.text
layout = db_session.query(SavedLayout).filter_by(user_id=1, name="Weather Layout").one()
snap = db_session.query(SavedLayoutWidget).filter_by(saved_layout_id=layout.id, widget_type="weather").one()
assert snap.config == {
"mode": "daily", "provider": "nws", "units": "celsius", "city_label": "Boston, MA",
"city_latitude": 42.36, "city_longitude": -71.06, "hourly_interval_hours": 6, "daily_days": 7,
"cities": None, "render_style": "classic",
}
client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
apply_resp = client.post(f"/api/frames/1/layouts/{layout.id}/apply", headers=csrf_headers(client))
assert apply_resp.status_code == 200, apply_resp.text
new_widget = db_session.query(Widget).filter_by(frame_id=1, widget_type="weather").one()
new_cfg = db_session.get(WeatherWidgetConfig, new_widget.id)
assert (new_cfg.mode, new_cfg.provider, new_cfg.units) == ("daily", "nws", "celsius")
assert (new_cfg.city_label, new_cfg.city_latitude, new_cfg.city_longitude) == ("Boston, MA", 42.36, -71.06)
assert (new_cfg.hourly_interval_hours, new_cfg.daily_days) == (6, 7)
# Runtime fetch-cache state is never captured/restored by a saved layout.
assert new_cfg.checked_at == 0.0
assert new_cfg.cached is None
def test_save_captures_calendar_sources_and_button_actions(client, db_session): def test_save_captures_calendar_sources_and_button_actions(client, db_session):
_setup_alice(client) _setup_alice(client)
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete() db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
+92
View File
@@ -0,0 +1,92 @@
"""app.theme_tokens -- resolve_theme()'s fallback logic (does "classic"
stay a byte-identical no-op vs. each widget kind's pre-theme-system
look?) and the font table every preset resolves against. No rendering
here -- see test_html_render.py for ordered_dither_regions and each
widget's own test_widgets_*.py for dispatch-level "does a theme actually
change the output" coverage."""
from __future__ import annotations
from pathlib import Path
from app import panel_style, theme_tokens
def test_classic_weather_matches_historical_fixed_gradient():
"""Weather's modern style never went through panel_style.THEME --
its header was always this fixed blue gradient (see html_render.py's
removed ACCENT_START/ACCENT_END). "classic" must reproduce it
byte-for-byte so themes are additive, not a silent regression."""
resolved = theme_tokens.resolve_theme("classic", "weather", None)
assert resolved["accent_hex"] == "#1c4fd6"
assert resolved["accent_hex_dark"] == "#6fa8ff"
def test_classic_tasks_and_calendar_use_flat_theme_ink():
"""Tasks/calendar's classic accent was always a flat single ink (no
gradient) resolved through panel_style.THEME -- both ends of the
"gradient" must be that same ink, not two different shades."""
tasks = theme_tokens.resolve_theme("classic", "tasks", None)
calendar = theme_tokens.resolve_theme("classic", "calendar", None)
assert tasks["accent_hex"] == tasks["accent_hex_dark"]
assert calendar["accent_hex"] == calendar["accent_hex_dark"]
from app.image_pipeline import DEFAULT_PALETTE_RGB
expected_tasks = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_TASKS])
expected_calendar = "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.THEME_CALENDAR])
assert tasks["accent_hex"] == expected_tasks
assert calendar["accent_hex"] == expected_calendar
def test_classic_unmapped_widget_kind_falls_back_to_black():
resolved = theme_tokens.resolve_theme("classic", "battery", None)
from app.image_pipeline import DEFAULT_PALETTE_RGB
assert resolved["accent_hex"] == "#%02x%02x%02x" % tuple(DEFAULT_PALETTE_RGB[panel_style.BLACK])
def test_unknown_theme_name_falls_back_to_classic():
assert theme_tokens.resolve_theme("not-a-real-theme", "weather", None) == \
theme_tokens.resolve_theme("classic", "weather", None)
assert theme_tokens.resolve_theme(None, "weather", None) == \
theme_tokens.resolve_theme("classic", "weather", None)
def test_every_preset_resolves_without_error_for_every_widget_kind():
widget_kinds = ["weather", "tasks", "calendar", "battery", "text", "static", "whiteboard"]
for theme_name in theme_tokens.THEMES:
for kind in widget_kinds:
resolved = theme_tokens.resolve_theme(theme_name, kind, None)
assert resolved["accent_hex"].startswith("#") and len(resolved["accent_hex"]) == 7
assert resolved["accent_hex_dark"].startswith("#") and len(resolved["accent_hex_dark"]) == 7
assert resolved["font_family"] in theme_tokens.FONT_FAMILIES
assert Path(resolved["font_regular"]).exists()
assert Path(resolved["font_bold"]).exists()
def test_non_gradient_theme_has_equal_accent_stops():
""""moss" is configured with gradient=False -- its two CSS gradient
stops must be identical (a flat fill), unlike a gradient theme's."""
resolved = theme_tokens.resolve_theme("moss", "tasks", None)
assert resolved["gradient"] is False
assert resolved["accent_hex"] == resolved["accent_hex_dark"]
def test_gradient_theme_has_a_darker_second_stop():
resolved = theme_tokens.resolve_theme("terracotta", "tasks", None)
assert resolved["gradient"] is True
assert resolved["accent_hex"] == "#a84b2a"
assert resolved["accent_hex_dark"] != resolved["accent_hex"]
def test_font_path_covers_every_family_and_style_combination():
for family in theme_tokens.FONT_FAMILIES:
for bold in (False, True):
for italic in (False, True):
assert theme_tokens.font_path(family, bold, italic).exists()
def test_font_path_falls_back_to_default_family_for_unknown_name():
assert theme_tokens.font_path("not-a-real-family", False, False) == \
theme_tokens.font_path(theme_tokens.DEFAULT_FONT_FAMILY, False, False)
@@ -243,6 +243,35 @@ def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
def test_preview_weather_honors_modern_render_style(client, db_session, monkeypatch):
"""Regression test: api_widget_preview_weather originally called
weather_render.render_weather_preview_png directly, unconditionally --
the dialog's own live preview never reflected render_style="modern" at
all, even though the real device-facing render (widgets/weather.py's
render()) did. Route through html_render instead for modern/current or
modern/daily, same as the device path -- assert it's actually reached,
not just that the request 200s (it would 200 either way if this
silently fell back to classic)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_weather_widget(db_session, mode="current", render_style="modern")
monkeypatch.setattr(
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
)
calls = []
def _fake_render_html_to_image(html, target_w, target_h):
calls.append((target_w, target_h))
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr("app.html_render.render_html_to_image", _fake_render_html_to_image)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert calls, "html_render.render_html_to_image was never called -- preview endpoint didn't honor render_style"
def test_config_save_truncates_an_overlong_tasks_name(client, db_session): def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session) widget = _add_tasks_widget(db_session)
+21 -3
View File
@@ -7,17 +7,19 @@ from __future__ import annotations
import time import time
from app import widgets from PIL import Image
from app import html_render, widgets
from app.models import BatteryWidgetConfig, Frame, Widget from app.models import BatteryWidgetConfig, Frame, Widget
def _make_widget(db_session, mode="detailed") -> tuple[Frame, Widget]: def _make_widget(db_session, mode="detailed", render_style="classic") -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1, widget = Widget(frame_id=frame.id, widget_type="battery", x=0, y=0, w=1, h=1,
sort_order=0, created_at=time.time()) sort_order=0, created_at=time.time())
db_session.add(widget) db_session.add(widget)
db_session.flush() db_session.flush()
db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode)) db_session.add(BatteryWidgetConfig(widget_id=widget.id, mode=mode, render_style=render_style))
db_session.commit() db_session.commit()
return frame, widget return frame, widget
@@ -81,3 +83,19 @@ def test_no_button_actions():
advance/back/check.""" advance/back/check."""
assert widgets.battery.ACTIONS == {} assert widgets.battery.ACTIONS == {}
assert widgets.battery.ACTION_LABELS == {} assert widgets.battery.ACTION_LABELS == {}
def test_render_modern_style(db_session, monkeypatch):
frame, widget = _make_widget(db_session, render_style="modern")
frame.battery_percent = 42
frame.battery_as_of = time.time()
db_session.commit()
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
img = widgets.battery.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
+77
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import time import time
import pytest
from app import widgets from app import widgets
from app.db import widget_locked from app.db import widget_locked
from app.models import CalendarWidgetConfig, Frame, Widget from app.models import CalendarWidgetConfig, Frame, Widget
@@ -163,3 +165,78 @@ def test_month_view_falls_back_to_agenda_layout_below_small_tier(db_session, mon
_stub_fetches(monkeypatch) _stub_fetches(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 300, 192) img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (300, 192) assert img.size == (300, 192)
# --- "modern" style (app/calendar_html_render.py) -----------------------
# No real browser here -- html_render.render_html_to_image is monkeypatched
# to a stub, so these exercise calendar.py's dispatch + calendar_html_
# render's own layout/data logic, not Playwright/Chromium itself.
def _stub_render_html_to_image(monkeypatch):
from PIL import Image
from app import html_render
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
@pytest.mark.parametrize("view", ["agenda", "today_tomorrow", "week", "month"])
def test_render_modern_style_every_view(db_session, monkeypatch, view):
"""All four view modes have a modern-style builder (unlike weather's
own current/daily-only modern style) -- each must dispatch correctly
from calendar.py's render()."""
frame, widget = _make_widget(db_session, view=view, render_style="modern")
events = [{"summary": "Standup", "start": "2026-07-31T09:00:00+00:00",
"end": "2026-07-31T09:30:00+00:00", "all_day": False,
"sources": [{"owner_display_name": "Alice", "color_index": None}]}]
_stub_fetches(monkeypatch, events=events)
_stub_render_html_to_image(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 380, 300)
assert img.size == (380, 300)
assert img.mode == "RGB"
def test_render_modern_month_falls_back_to_agenda_below_small_tier(db_session, monkeypatch):
"""Same "month needs real column width" fallback classic has (see
test_month_view_falls_back_to_agenda_layout_below_small_tier above),
still honored when render_style is modern."""
frame, widget = _make_widget(db_session, view="month", render_style="modern")
_stub_fetches(monkeypatch)
_stub_render_html_to_image(monkeypatch)
img = widgets.calendar.render(db_session, frame, widget, 300, 192)
assert img.size == (300, 192)
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
equivalent test -- confirms calendar.py's render() passes frame.theme
into calendar_html_render.build (proving the widget-level threading,
not re-testing ordered_dither_regions itself, which
test_html_render.py already covers)."""
from app import calendar_html_render, theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(calendar_html_render, "theme_tokens", theme_tokens)
frame, widget = _make_widget(db_session, view="agenda", render_style="modern")
frame.theme = "moss"
# calendar.py's render() takes widget_locked's write path (it resets
# browse_offset on a normal wake), which commits and would otherwise
# expire-and-reload frame from the DB, discarding this uncommitted
# attribute change.
db_session.commit()
_stub_fetches(monkeypatch)
_stub_render_html_to_image(monkeypatch)
widgets.calendar.render(db_session, frame, widget, 380, 300)
assert ("moss", "calendar") in calls
+29
View File
@@ -109,6 +109,35 @@ def test_advance_action_is_a_no_op_when_unconfigured(db_session):
assert cfg.current_asset_id == "" assert cfg.current_asset_id == ""
def test_render_quantizes_against_photo_palette_not_the_main_frame_palette(db_session, monkeypatch):
"""Regression/design test: photos.py's render() must quantize against
Frame.photo_palette_rgb, genuinely independent of Frame.palette_rgb --
the whole point of giving photos its own palette (see widgets/photos.
py's module docstring). Uses a custom photo_palette_rgb whose "black"
slot is a distinctive color that doesn't appear anywhere in
DEFAULT_PALETTE_RGB, so the assertion only passes if photo_palette_rgb
was actually the one used."""
frame, widget = _make_widget(db_session)
custom_photo_palette = [
[10, 20, 30], [255, 255, 255], [255, 219, 0], [207, 0, 15], [0, 39, 133], [0, 133, 55],
]
frame.photo_palette_rgb = custom_photo_palette
frame.photo_dither_strength = 0.0 # flat quantize -- exact, no diffusion noise to account for
db_session.commit()
assert frame.palette_rgb is None # main palette stays at its default throughout
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
# A near-black source photo -- under the MAIN default palette this
# would quantize to (0, 0, 0); under custom_photo_palette's distinctive
# "black" slot it must quantize to exactly (10, 20, 30) instead.
source = Image.new("RGB", (100, 80), (5, 5, 5))
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
img = widgets.photos.render(db_session, frame, widget, 400, 300)
assert set(img.getdata()) == {(10, 20, 30)}
def test_render_does_not_advance_when_locked(db_session, monkeypatch): def test_render_does_not_advance_when_locked(db_session, monkeypatch):
frame, widget = _make_widget(db_session) frame, widget = _make_widget(db_session)
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object()) monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
+15
View File
@@ -72,3 +72,18 @@ def test_no_button_actions():
"""A fixed uploaded image -- nothing to advance/back/check.""" """A fixed uploaded image -- nothing to advance/back/check."""
assert widgets.static_image.ACTIONS == {} assert widgets.static_image.ACTIONS == {}
assert widgets.static_image.ACTION_LABELS == {} assert widgets.static_image.ACTION_LABELS == {}
def test_render_modern_style(db_session, monkeypatch):
from app import html_render
frame, widget = _make_widget(db_session, image=_png_bytes(), render_style="modern")
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
img = widgets.static_image.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
+54
View File
@@ -129,3 +129,57 @@ def test_no_button_actions():
weather -- nothing to advance/back/force.""" weather -- nothing to advance/back/force."""
assert widgets.tasks.ACTIONS == {} assert widgets.tasks.ACTIONS == {}
assert widgets.tasks.ACTION_LABELS == {} assert widgets.tasks.ACTION_LABELS == {}
def test_render_modern_style(db_session, monkeypatch):
from PIL import Image
from app import html_render
frame, widget = _make_widget(db_session, render_style="modern", name="Chores")
tasks = [
{"summary": "Buy milk", "due": None, "completed_at": None,
"owner_display_name": "Alice", "color_index": None},
{"summary": "Walk the dog", "due": "2026-08-01", "completed_at": None,
"owner_display_name": "Alice", "color_index": None},
]
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: tasks)
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
img = widgets.tasks.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_modern_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""Same spy-on-resolve_theme approach as test_widgets_weather.py's
equivalent test -- confirms tasks.py's render() passes frame.theme
into html_render.build_tasks (proving the widget-level threading, not
re-testing ordered_dither_regions itself, which test_html_render.py
already covers)."""
from PIL import Image
from app import html_render, theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
frame, widget = _make_widget(db_session, render_style="modern")
frame.theme = "slate"
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert ("slate", "tasks") in calls
+21
View File
@@ -121,3 +121,24 @@ def test_no_button_actions():
"""Fixed authored text -- nothing to advance/back/check.""" """Fixed authored text -- nothing to advance/back/check."""
assert widgets.text.ACTIONS == {} assert widgets.text.ACTIONS == {}
assert widgets.text.ACTION_LABELS == {} assert widgets.text.ACTION_LABELS == {}
def test_render_modern_style(db_session, monkeypatch):
from PIL import Image
from app import html_render
frame, widget = _make_widget(
db_session,
content=[[_run("Hello, ", bold=True), _run("world!", italic=True, color="#cc0000")]],
render_style="modern",
)
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
img = widgets.text.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
+110 -1
View File
@@ -11,7 +11,9 @@ from __future__ import annotations
import time import time
from app import grid, widgets from PIL import Image
from app import grid, html_render, widgets
from app.models import Frame, WeatherWidgetConfig, Widget from app.models import Frame, WeatherWidgetConfig, Widget
@@ -109,3 +111,110 @@ def test_check_now_forces_a_refetch(db_session, monkeypatch):
def test_action_labels(): def test_action_labels():
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"} assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"} assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}
# --- render_style="modern" (app/html_render.py) -------------------------
# No real browser here -- html_render.render_html_to_image is monkeypatched
# to a stub, so these tests exercise weather.py's dispatch + html_render's
# own template-rendering/ordered_dither logic, not Playwright/Chromium
# itself (that needs a real browser install -- see the run-server-driven
# manual verification these tests don't replace).
def _stub_render_html_to_image(monkeypatch, fill=(10, 20, 200)):
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), fill)
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
def test_render_modern_current_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current", city_label="Portland", render_style="modern")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
_stub_render_html_to_image(monkeypatch)
img = widgets.weather.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"
def test_render_modern_daily_mode(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5, render_style="modern")
daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
_stub_render_html_to_image(monkeypatch)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
assert img.mode == "RGB"
def test_render_modern_ordered_dither_is_exact_palette(db_session, monkeypatch):
"""The whole point of doing ordered dithering inside html_render (see
its module docstring) is that its output is already exact palette
colors before the shared whole-canvas Floyd-Steinberg pass ever sees
it -- assert that directly, not just "an image came back"."""
frame, widget = _make_widget(db_session, mode="current", render_style="modern")
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
# A mid-gray fill is nowhere near any of DEFAULT_PALETTE_RGB's 6 exact
# colors -- if ordered_dither's nearest-palette-match ran, every pixel
# must land on one of them regardless.
_stub_render_html_to_image(monkeypatch, fill=(128, 128, 128))
img = widgets.weather.render(db_session, frame, widget, 120, 100)
from app.image_pipeline import DEFAULT_PALETTE_RGB
palette = set(DEFAULT_PALETTE_RGB)
assert set(img.getdata()) <= palette
def test_render_modern_falls_back_to_classic_for_hourly_and_multi_city(db_session, monkeypatch):
"""hourly/multi_city have no "modern" template yet (see html_render's
module docstring) -- render_style="modern" on those modes must still
produce the classic PIL render, not error or silently do nothing.
Deliberately does NOT stub html_render, so this also proves the
classic path never imports it for these modes."""
frame, widget = _make_widget(db_session, mode="multi_city", render_style="modern")
cities = [{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"}]
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities)
img = widgets.weather.render(db_session, frame, widget, 400, 300)
assert img.size == (400, 300)
def test_render_style_default_is_classic(db_session, monkeypatch):
frame, widget = _make_widget(db_session, mode="current")
cfg = db_session.get(WeatherWidgetConfig, widget.id)
assert cfg.render_style == "classic"
def test_render_modern_daily_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch):
"""widgets/weather.py's render() must pass frame.theme all the way
into html_render.build_daily's theme resolution -- spies on
theme_tokens.resolve_theme (still delegating to the real
implementation) rather than diffing final pixels, since the stubbed
render_html_to_image below never actually executes the template's CSS
(that's the whole point of stubbing out Chromium), so a theme's
accent color has nothing to visibly change in the fake screenshot."""
from app import theme_tokens
calls = []
real_resolve = theme_tokens.resolve_theme
def _spy(theme_name, widget_kind, palette_rgb):
calls.append((theme_name, widget_kind))
return real_resolve(theme_name, widget_kind, palette_rgb)
monkeypatch.setattr(theme_tokens, "resolve_theme", _spy)
monkeypatch.setattr(html_render, "theme_tokens", theme_tokens)
daily = {"2026-07-31": {"high": 75, "low": 55, "category": "clear"}}
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
_stub_render_html_to_image(monkeypatch)
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", render_style="modern")
frame.theme = "terracotta"
widgets.weather.render(db_session, frame, widget, 200, 160)
assert ("terracotta", "weather") in calls
+20 -2
View File
@@ -16,13 +16,14 @@ from app import widgets
from app.models import Frame, Widget, WhiteboardWidgetConfig from app.models import Frame, Widget, WhiteboardWidgetConfig
def _make_widget(db_session) -> tuple[Frame, Widget]: def _make_widget(db_session, render_style="classic") -> tuple[Frame, Widget]:
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time()) sort_order=0, created_at=time.time())
db_session.add(widget) db_session.add(widget)
db_session.flush() db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard")) db_session.add(WhiteboardWidgetConfig(widget_id=widget.id, url="https://example.com/board.whiteboard",
render_style=render_style))
db_session.commit() db_session.commit()
return frame, widget return frame, widget
@@ -83,3 +84,20 @@ def test_both_buttons_map_to_check_now():
"""No real "next"/"back" concept for a static board -- both physical """No real "next"/"back" concept for a static board -- both physical
buttons mean the same thing for a whiteboard widget.""" buttons mean the same thing for a whiteboard widget."""
assert set(widgets.whiteboard.ACTIONS) == {"check_now"} assert set(widgets.whiteboard.ACTIONS) == {"check_now"}
def test_render_modern_style(db_session, monkeypatch):
from app import html_render
frame, widget = _make_widget(db_session, render_style="modern")
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
lambda db, frame, widget: _tiny_png())
def _stub(html, target_w, target_h):
return Image.new("RGB", (target_w, target_h), (255, 255, 255))
monkeypatch.setattr(html_render, "render_html_to_image", _stub)
img = widgets.whiteboard.render(db_session, frame, widget, 300, 200)
assert img.size == (300, 200)
assert img.mode == "RGB"