Author SHA1 Message Date
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
tfaour 08960c9eec Swap combo button tiers: quick press resets, ~3s hold shows menu
Firmware build check / build-check (push) Successful in 2m45s
Build and release firmware / build-and-release (push) Successful in 2m45s
Quick reset is now the fast/default action; summoning the management
menu takes a deliberate hold. Factory reset at ~15s is unchanged.
Renamed FRAME_COMBO_SOFT_RESET_HOLD_MS -> FRAME_COMBO_MENU_HOLD_MS to
match its new meaning. Bumps firmware to 1.4.1.
2026-07-27 22:40:44 +00:00
tfaour f0c21af220 Update CLAUDE.md: mark button-actions, battery-widget, scan-to-download TODOs done 2026-07-27 22:33:56 +00:00
tfaour 8602ee3add Add build-firmware skill: native ESP-IDF build, no Docker needed
CI builds firmware inside the espressif/idf Docker image, but this
sandbox can't run containers at all -- it strips cap_sys_admin (and
blocks unshare) from the capability set even for root, which container
image-layer extraction and namespace setup both need. Confirmed by
hand: docker.io installs and dockerd starts fine, but even a bare
`docker run hello-world` fails to extract its own layer.

Works around it by installing ESP-IDF natively instead (git clone +
its own install.sh, scoped to just this project's esp32c6 target) --
the same way a developer would set it up on their own machine, needing
nothing this sandbox disallows. Verified end-to-end: both board
variants (devkit, xiao) build clean from a fresh checkout via the
packaged setup.sh/build.sh.
2026-07-27 22:31:30 +00:00
tfaour 7d34eca5d7 Bump firmware version to 1.4.0
Firmware build check / build-check (push) Successful in 2m53s
Build and release firmware / build-and-release (push) Successful in 2m51s
Release build for the per-widget button actions + hold-for-global-
action firmware changes (short/long press detection on next/back,
POST /frame/global-next|back). CI's firmware-build-check.yml already
confirmed both board variants compile clean at this commit.
2026-07-27 22:18:47 +00:00
tfaour fcf3aec4c0 Move button actions to per-widget config, add hold-for-global-action
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s
Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
2026-07-27 22:09:33 +00:00
tfaour 9911151d8d Add per-photo-widget lock (freezes current photo until unlocked)
Build and push server image / test (push) Successful in 33s
Build and push server image / build-and-push (push) Successful in 2m32s
Build and push server image / deploy (push) Successful in 57s
A "Lock this photo" button in the photos widget's dialog toggles
PhotoWidgetConfig.locked, which suppresses both the timer-elapsed
auto-advance and the advance/back button actions until unlocked. The
Layout tab canvas shows a lock badge on any locked photo widget's box.
2026-07-27 21:07:54 +00:00
tfaour 4e8c6e534b Install Node.js from Debian's own repo, drop NodeSource dependency
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m34s
Build and push server image / deploy (push) Successful in 53s
deb.nodesource.com started intermittently 403ing today on both its
setup_*.x scripts and its GPG key (confirmed directly, not just via CI --
some setup_NN.x paths 403, others 200, no consistent pattern), and the
curl-piped-into-bash install pattern silently swallowed that failure
instead of breaking the build loudly: curl -f exits non-zero on a 403,
but bash then runs on empty stdin and exits 0, so the RUN kept going
into a broken fallback (Debian's own split nodejs package with no
bundled npm) rather than stopping.

This base image now tracks Debian trixie, whose own nodejs package
(20.19.2) is inside jsdom 29's engines range and clears express/
resvg-js's much lower floors -- the version gap that originally required
routing through NodeSource is gone, so this drops that whole external
dependency (and the curl/gnupg install-then-purge dance) rather than
just swapping to a different NodeSource script.
2026-07-27 20:40:30 +00:00
tfaour b15747a604 Add per-widget border option (style, thickness, palette color)
Build and push server image / test (push) Has been cancelled
Build and push server image / build-and-push (push) Has been cancelled
Build and push server image / deploy (push) Has been cancelled
A Widget-level property (border_style/border_thickness/border_color_index),
not a per-type config field, since every widget type can have one -- drawn
once centrally in device.py's _render_widgets before compositing, using
an exact panel palette color so it never dithers. Styles: solid, dashed,
dotted, and a fancy double-line picture-frame-mat look. Configurable from
a shared "Border" card in every widget's gear-icon dialog.
2026-07-27 19:51:04 +00:00
tfaour eb7127718b Add battery widget (device's own last-reported level, no live upstream)
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 50s
Shows Frame.battery_percent/battery_as_of, already set by every device
wake-on-battery report, plus routers/common.py's existing
battery_estimate_s time-remaining estimate -- nothing new to fetch or
cache. Compact (icon + percent) or detailed (+ estimate, last report
age) display mode. No button actions.
2026-07-27 19:02:21 +00:00
tfaour 90a014d161 Revert to hand-drawn weather icons, styled after EC's set but exact panel colors
Build and push server image / test (push) Successful in 30s
Build and push server image / build-and-push (push) Successful in 2m4s
Build and push server image / deploy (push) Successful in 49s
The vendored EC bitmaps looked good but dither into a visible speckle
once quantized to the panel's 6-color palette (their colors are
anti-aliased/arbitrary RGB, essentially never an exact palette match).
Hand-drawn icons filled with the frame's actual ink colors quantize with
zero dithering error to diffuse -- confirmed by running both through the
real quantize pass: the bitmap version speckles, the hand-drawn one is
pixel-identical before and after.

Redrawn to look more like EC's style this time around: pointed
triangular sun rays (the earlier attempt's thin-line rays read as a
crosshair, not a sun) and dendrite snowflakes (tick marks near each tip,
not a bare asterisk), plus the same cloud/raindrop/lightning-bolt shapes
as before. Removed the vendored server/app/weather_icons/ directory
entirely -- no longer used, and removes the icon-image licensing
question along with it.
2026-07-27 17:32:51 +00:00
tfaour efb0f2e22d Swap hand-drawn weather icons for Environment Canada's real icon set
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m10s
Build and push server image / deploy (push) Successful in 51s
The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/
draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared
weather category, sourced from weather.gc.ca's public icon set -- these
are small, flat-shaded images that dither cleanly onto the panel's
6-color palette and read as recognizable weather icons in a way the
hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for
every provider's rendering (Open-Meteo, NWS, EC), not just when EC is
selected.

Vendored (not fetched live at render time), matching this project's
existing convention for the Noto Emoji fonts -- server/app/weather_icons/
SOURCE.md documents the source, attribution, and the licensing caveat
(this is a personal, non-commercial project; the icon images' own
copyright terms are less clearly permissive than the weather data's own
End-use Licence, since they're served from the public website rather
than ECCC's data servers).

draw_weather_icon's signature changes from (draw, cx, cy, r, category,
palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the
Image object, not just an ImageDraw handle, and palette_rgb is no longer
needed since the shared _quantize step already maps whatever's on the
composited canvas to the frame's actual palette -- no per-icon color
resolution required anymore.
2026-07-27 17:22:59 +00:00
tfaour 270979949f Add Environment Canada as a third weather provider
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 4m32s
Build and push server image / deploy (push) Successful in 49s
app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.

EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.

Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
2026-07-27 16:50:40 +00:00
tfaour 52ebafab78 Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s
New widget type with four display modes -- current conditions, an
hourly forecast strip, a multi-day forecast, and several cities' current
day side by side -- backed by a pluggable provider registry (app/weather/,
mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and
NWS (US-only) both wired up now, Environment Canada documented as the
next one to add given its more involved station/grid-lookup API.

The calendar widget's existing embedded weather strip is untouched and
still Open-Meteo-only; this lifts the same underlying icon-drawing
primitives (now shared via app/weather_render.py, calendar_render.py
still imports draw_weather_row unchanged) into a widget that can be
placed and sized on its own. Icons are redrawn in the panel's actual ink
colors (yellow sun/bolt, blue rain/snow) instead of flat black, and
build_multi_city's icon/font sizing now scales with how many cities need
to fit rather than the box's height alone -- both fixed after catching
them via live browser verification, along with a mode-switch cache-shape
crash and a mobile-width dialog overflow.

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
2026-07-27 16:16:43 +00:00
tfaour 6118705c37 Fix corrupted text in CLAUDE.md
Two bullets had a stray "a \"coming up this week\" widget" phrase
overwriting their actual sentence ending (the AGPL bullet's "silently
accepting it)" and the mobile-breakpoint bullet's "below it"), likely
from an earlier bad edit. Restored both from git history; the phrase
still exists correctly once, as its own CURRENT TODO bullet.
2026-07-27 14:46:17 +00:00
tfaour 5247f5e512 Merge remote-tracking branch 'origin/main'
Build and push server image / test (push) Successful in 1m15s
Build and push server image / build-and-push (push) Successful in 5m1s
Build and push server image / deploy (push) Successful in 52s
Resolved CURRENT TODO conflict: kept Thomas's reformatted list and new
items, dropped the two scan-to-download lines this branch just finished.
2026-07-27 14:39:27 +00:00
tfaour 8bcc574f99 Update CLAUDE.md: mark scan-to-download TODOs done, add commit/push convention
Standing convention: commit and push once a task is verified working,
without waiting for a separate go-ahead each time -- see the new bullet
under Conventions specific to this repo.
2026-07-27 14:38:36 +00:00
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00
119 changed files with 7775 additions and 1209 deletions
+128
View File
@@ -0,0 +1,128 @@
---
name: build-firmware
description: Compile the espresso_frame ESP32-C6 firmware (firmware/) for both board variants without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check both the devkit and xiao board targets.
---
Compiles `firmware/` (ESP-IDF, targeting ESP32-C6) locally, without
Docker -- CI's `firmware-build-check.yml`/`firmware-release-build.yml`
build inside the `espressif/idf:release-v6.0` container image, but
**this sandbox cannot run containers at all**: `docker.io` installs and
`dockerd` starts fine even as root, but the sandbox strips
`cap_sys_admin` (and blocks the bare `unshare` syscall) from the
capability set regardless of uid, which container image-layer
extraction and namespace setup both require. Confirmed by hand:
`docker run hello-world` fails to extract even the tiny hello-world
layer ("failed to extract layer... operation not permitted" with the
overlayfs snapshotter; "unshare: operation not permitted" even with
the vfs storage driver instead). This is a hard restriction of the
sandbox itself, not a permissions/setup problem -- don't spend time
re-trying `--privileged`-equivalent flags or alternate storage drivers,
none of it routes around a missing `cap_sys_admin`.
The workaround: skip containers entirely and install ESP-IDF the same
way a developer would set it up on their own machine (`git clone` +
ESP-IDF's own `install.sh`) -- that path needs nothing this sandbox
disallows, just normal file/process operations.
## Setup (once per fresh container)
```bash
bash .claude/skills/build-firmware/setup.sh
```
Installs (via real `apt-get` -- this container actually has root and a
working package manager, unlike run-server's Chromium bootstrap which
had neither):
- OS build deps: `python3`/`venv`/`pip`, `cmake`, `ninja-build`,
`flex`/`bison`/`gperf`, `build-essential`, `libusb-1.0-0`.
- ESP-IDF itself: a shallow, single-branch, recursive-submodule clone
of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches
the IDF version CI's Docker image pins. Only clones once; re-running
`setup.sh` never touches an existing checkout.
- The esp32c6 toolchain + Python venv, via ESP-IDF's own
`./install.sh esp32c6` -- scoped to just this project's one target
(see `firmware/README.md`'s board table), not every chip ESP-IDF
supports, to keep the download/disk footprint down. `install.sh` is
already idempotent on its own, so `setup.sh` always calls it rather
than duplicating that check -- a re-run costs a few seconds once
everything's cached.
Takes a few minutes on a cold run (mostly `install.sh`'s own pip/tool
downloads), well under a minute on a re-run. Needs real root (`apt-get
install`) -- if this container ever runs as non-root, this setup
doesn't apply as-is (would need the same non-root apt-download +
`dpkg-deb -x` extraction dance `run-server`'s `setup.sh` uses for
Chromium).
Disk: budget ~4GB free before starting (esp-idf checkout + toolchain +
Python env land around 3.4GB in `~/.espressif`, plus the ~700MB
checkout itself). Confirmed working with as little as ~7GB free.
## Build
```bash
bash .claude/skills/build-firmware/build.sh # devkit (default)
bash .claude/skills/build-firmware/build.sh xiao
bash .claude/skills/build-firmware/build.sh both # both variants
```
Each board gets its own build directory and generated sdkconfig (see
`firmware/build_for_board.sh`'s own comment) -- building one never
disturbs the other. `build.sh` auto-runs `set-target esp32c6` the very
first time a board is built (no generated sdkconfig yet); later builds
skip straight to `idf.py build`. Extra arguments pass straight through
to `idf.py`, e.g.:
```bash
bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0
```
`flash`/`monitor` need an actual attached device and serial port --
this sandbox has neither, so those only work when this skill runs
somewhere hardware is actually plugged in (a real dev machine, or a
differently-configured environment with device passthrough).
A clean build of one board takes ~30s once the target's already been
configured (~1,000 build steps total split across both boards, most of
it ESP-IDF's own components -- this project's own `firmware/main/*.c`
and `firmware/components/*` sources are a small fraction of that and
compile in a few seconds). Output lands at
`firmware/build/espresso_frame.bin` (devkit) or
`firmware/build_xiao/espresso_frame.bin` (xiao) -- both paths are
gitignored (`firmware/.gitignore`... actually the repo root
`.gitignore`'s "ESP-IDF firmware build output" section), so nothing
here needs cleaning up before a commit.
## Verified
Both board variants (`devkit` set-target esp32c6 + build, `xiao`
set-target esp32c6 + build) built successfully end-to-end using this
exact setup.sh/build.sh pair, producing real
`espresso_frame.bin` images with normal free-space margins (41%/36%
of their respective app partitions) and no errors -- only one
pre-existing, unrelated warning (`battery.c`'s unused `TAG` when that
file's logging is compiled out). This is a real compile check, not
just a syntax read -- if a future change breaks the build, this skill
will actually catch it.
## Troubleshooting
- **`docker: ... unshare: operation not permitted` / `failed to
extract layer ... operation not permitted`**: expected in this
sandbox, see the top of this file. Don't debug it further -- use this
skill's native install instead.
- **`ESP-IDF not found at ... -- run setup.sh first`**: `build.sh`'s
own check for a missing `$IDF_DIR/export.sh` -- run `setup.sh` (see
above) before the first build.
- **`idf.py: command not found` if you try to run it directly**: same
gotcha `firmware/build_for_board.sh` already documents -- `idf.py` is
normally a shell *function* from ESP-IDF's `export.sh`, not on PATH
as a real executable, so it isn't inherited into a script's own
subshell even after sourcing `export.sh` in your interactive shell
first. Use `build.sh` (or `build_for_board.sh`, which calls
`python "$IDF_PATH/tools/idf.py"` directly) instead of typing
`idf.py` in a fresh script/subshell.
- **Disk pressure during `install.sh`**: this environment runs close to
full (single-digit GB free is normal, not a sign of a leak) --
`df -h /` before running `setup.sh` if a build mysteriously fails
partway with a "no space left on device"-shaped error.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Builds (or flashes/monitors, if a serial port is actually attached)
# the espresso_frame firmware for one board variant, via the project's
# own firmware/build_for_board.sh -- this script just sources the
# ESP-IDF environment first and auto-runs `set-target esp32c6` on a
# board's very first build (a fresh clone has no generated sdkconfig
# yet, same reasoning as CI's own build steps -- see firmware/README.md's
# "Building for the Seeed XIAO ESP32-C6" section).
#
# Usage:
# build.sh # build devkit (default)
# build.sh devkit
# build.sh xiao
# build.sh both # build both board variants
# build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
firmware_dir="$(git -C "$script_dir" rev-parse --show-toplevel)/firmware"
IDF_DIR="$HOME/.espressif-idf/esp-idf"
if [ ! -f "$IDF_DIR/export.sh" ]; then
echo "ESP-IDF not found at $IDF_DIR -- run setup.sh first" >&2
exit 1
fi
# export.sh is chatty and assumes an interactive shell prompt in spots;
# redirect its own stdout, not ours, so build.sh's actual output (and a
# real failure's stderr) stays visible.
source "$IDF_DIR/export.sh" > /dev/null
cd "$firmware_dir"
build_one() {
local board="$1"
shift
local sdkconfig
case "$board" in
devkit) sdkconfig="sdkconfig" ;;
xiao) sdkconfig="sdkconfig.xiao_local" ;;
*) echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2; exit 1 ;;
esac
if [ ! -f "$sdkconfig" ]; then
echo "==> $board: no generated sdkconfig yet, setting target esp32c6"
./build_for_board.sh "$board" set-target esp32c6
fi
local args=("$@")
if [ ${#args[@]} -eq 0 ]; then
args=(build)
fi
./build_for_board.sh "$board" "${args[@]}"
}
board="${1:-devkit}"
shift || true
if [ "$board" = "both" ]; then
build_one devkit "$@"
build_one xiao "$@"
else
build_one "$board" "$@"
fi
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# One-time (idempotent) environment bootstrap for compiling the
# espresso_frame firmware (firmware/) without Docker -- see this
# skill's SKILL.md for why not Docker, even though that's what CI uses.
# Re-run any time; every step is safe/fast to repeat once already done.
set -euo pipefail
IDF_ROOT="$HOME/.espressif-idf"
IDF_DIR="$IDF_ROOT/esp-idf"
# Matches the espressif/idf:release-v6.0 image CI's
# firmware-build-check.yml/firmware-release-build.yml use -- keep this
# in sync with those workflow files if the project's pinned IDF version
# ever changes.
IDF_BRANCH="release/v6.0"
# 1. OS packages ESP-IDF's own install.sh needs (python3 + venv/pip,
# cmake, ninja, a C toolchain for the odd host-side code generator, git
# for the clone below, flex/bison/gperf for mbedtls/etc.'s generated
# parsers, libusb for esptool's USB/JTAG bits even though this skill
# doesn't flash real hardware). Installed via apt with real root --
# unlike run-server's Chromium bootstrap, this container actually has
# root and a working apt, so no non-root extraction dance is needed
# here.
PKGS="git python3 python3-venv python3-pip cmake ninja-build ccache libusb-1.0-0 wget flex bison gperf build-essential"
missing=()
for pkg in $PKGS; do
dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "installing OS packages: ${missing[*]}"
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
fi
# 2. ESP-IDF checkout -- shallow, single branch, recursive submodules
# also shallow (~700MB total, vs. several GB for a full clone). Only
# clones once; re-running this script never re-clones or resets it, so
# any local changes you made for debugging survive a re-run.
if [ ! -d "$IDF_DIR/.git" ]; then
echo "cloning esp-idf $IDF_BRANCH into $IDF_DIR ..."
mkdir -p "$IDF_ROOT"
git clone --branch "$IDF_BRANCH" --depth 1 --shallow-submodules --recursive \
https://github.com/espressif/esp-idf.git "$IDF_DIR"
else
echo "esp-idf already cloned at $IDF_DIR"
fi
# 3. Toolchain + Python virtualenv, scoped to esp32c6 only -- this
# project's one target (see firmware/README.md's board table). Scoping
# avoids downloading toolchains for every chip ESP-IDF supports, which
# matters given this container's disk headroom. install.sh is already
# idempotent on its own (checks what's present and skips it), so this
# always calls it rather than trying to duplicate that check here --
# a re-run only costs a few seconds once everything's cached.
echo "running esp-idf install.sh esp32c6 (fast if already installed) ..."
(cd "$IDF_DIR" && ./install.sh esp32c6)
echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)"
+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=$!
+14 -14
View File
@@ -4,20 +4,12 @@ A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
self-hosted FastAPI server (`server/`) that pulls from Immich, does all self-hosted FastAPI server (`server/`) that pulls from Immich, does all
image processing (crop/dither/quantize/pack), and serves a placeable image processing (crop/dither/quantize/pack), and serves a placeable
photos/calendar/whiteboard widget system to the device. photos/calendar/whiteboard/weather widget system to the device.
CURRENT TODO CURRENT TODO
-add more actions for buttons (i.e. change widget/layout)
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
-FIX Scan to download
-make a weather widget
-widget border option
-battery life widget
-sharing layouts with linked users -sharing layouts with linked users
-when multiple photo widgets on layout, the "scan to download" should create a share with all the photos on
-a "coming up this week" widget -a "coming up this week" widget
-scan to download for non-immich photos too? -on reset dismiss the menu.
-switch button reset action? and on reset dismiss the menu.
Start here, don't re-derive from scratch: Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and - [`docs/architecture.md`](docs/architecture.md) -- how firmware and
@@ -41,8 +33,7 @@ Start here, don't re-derive from scratch:
license via `pip show`/package metadata -- including transitive deps, license via `pip show`/package metadata -- including transitive deps,
not just the top-level package -- and present the finding and tradeoff not just the top-level package -- and present the finding and tradeoff
in plain text rather than picking an approach unilaterally (hand-rolling in plain text rather than picking an approach unilaterally (hand-rolling
an alternative, swapping packages, silently accepting an alternative, swapping packages, silently accepting it). This project
a "coming up this week" widget). This project
has knowingly accepted AGPL-3.0-or-later exposure once already has knowingly accepted AGPL-3.0-or-later exposure once already
(`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate, (`icalendar-searcher`, a transitive dep of `caldav`) as a deliberate,
explicit call -- not a precedent for skipping the check next time. explicit call -- not a precedent for skipping the check next time.
@@ -53,6 +44,16 @@ Start here, don't re-derive from scratch:
tempted to exclude and why. This repo shipped a token gate once that tempted to exclude and why. This repo shipped a token gate once that
covered `/api/*` but left `/frame/image` -- the actual photo bytes -- covered `/api/*` but left `/frame/image` -- the actual photo bytes --
open; caught immediately in production. open; caught immediately in production.
- **Commit and push once a task is verified working, without waiting to
be asked separately.** Once tests pass (and, for UI changes, the
browser check has been done), stage the relevant files, write a normal
commit message, and push to the current branch -- the maintainer's
standing authorization for the commit/push step itself. This doesn't
relax anything else: still run `git status`/review the diff before
staging, still never force-push/amend a pushed commit/skip hooks, and
still surface anything that looks like it needs a real decision (e.g.
a change that would trigger `main`'s deploy workflow, see below)
instead of pushing through it silently.
## Working in this repo ## Working in this repo
@@ -73,8 +74,7 @@ Start here, don't re-derive from scratch:
- **New/changed UI must work at both desktop and mobile widths -- - **New/changed UI must work at both desktop and mobile widths --
screenshot both, don't assume one implies the other.** The layout screenshot both, don't assume one implies the other.** The layout
genuinely forks at the 860px breakpoint (`theme.css`): the sidebar genuinely forks at the 860px breakpoint (`theme.css`): the sidebar
goes off-canvas behind a hamburger below goes off-canvas behind a hamburger below it. A dialog, header
a "coming up this week" widget. A dialog, header
control, or new widget that looks right at a wide viewport can control, or new widget that looks right at a wide viewport can
overflow, overlap the mobile bar, or mis-center at phone widths. overflow, overlap the mobile bar, or mis-center at phone widths.
`run-server`'s driver has a `viewport` command for exactly this `run-server`'s driver has a `viewport` command for exactly this
+3 -3
View File
@@ -102,9 +102,9 @@ placement grid, and button-action dispatch.
- Deep sleep for the server-configured interval on success, or a - Deep sleep for the server-configured interval on success, or a
shorter retry interval on any failure. shorter retry interval on any failure.
The menu/reset button's soft-reset and factory-reset tiers (held ~3s The menu/reset button's soft-reset (quick press) and factory-reset
or ~15s) are handled earlier, before any of this, and never return -- (held ~15s) tiers are handled earlier, before any of this, and never
see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting). return -- see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
See [`docs/hardware.md`](hardware.md) for wiring and See [`docs/hardware.md`](hardware.md) for wiring and
[`server/README.md`](../server/README.md) for the server side. [`server/README.md`](../server/README.md) for the server side.
+4 -4
View File
@@ -40,10 +40,10 @@ pressed):
photo; see photo; see
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo). [`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
- **Menu / reset (GPIO1)**: one button, three actions by hold duration -- - **Menu / reset (GPIO1)**: one button, three actions by hold duration --
a quick press overlays a "scan to manage" QR code on the current photo a quick press soft-resets the device (config kept); holding ~3s then
for 30 seconds; holding ~3s then releasing soft-resets the device releasing overlays a "scan to manage" QR code on the current photo for
(config kept); holding ~15s factory-resets it (clears WiFi/server 30 seconds; holding ~15s factory-resets it (clears WiFi/server config,
config, reprovisions); see reprovisions); see
[`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting). [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
All three pins were picked because they're within GPIO 0-7 -- the only All three pins were picked because they're within GPIO 0-7 -- the only
+201 -32
View File
@@ -1,10 +1,10 @@
# Widget system # Widget system
A frame's panel isn't one fixed "mode" anymore -- it holds N independently A frame's panel isn't one fixed "mode" anymore -- it holds N independently
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
arranging icons on an Android home screen. A frame can hold several widgets of the weather/battery), like arranging icons on an Android home screen. A frame
same type (e.g. two photo widgets pointed at different Immich albums side can hold several widgets of the same type (e.g. two photo widgets pointed
by side). at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column (and the other now-dead per-mode `Frame`
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
@@ -20,14 +20,32 @@ a button press does.
## Data model ## Data model
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type` - `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h` (`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
`"text"` | `"weather"` | `"battery"`), `x`/`y`/`w`/`h`
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in (grid cells), `sort_order`. Widgets never overlap (enforced server-side in
`routers/api_widgets.py`, re-validated regardless of what the client `routers/api_widgets.py`, re-validated regardless of what the client
already checked) -- that's what keeps compositing simple: no z-order, already checked) -- that's what keeps compositing simple: no z-order,
no blending, just N independent regions pasted onto one shared canvas. no blending, just N independent regions pasted onto one shared canvas.
Also carries an optional per-widget border (`border_style` -- `"none"`
| `"solid"` | `"dashed"` | `"dotted"` | `"fancy"`, `border_thickness`,
`border_color_index`, an index into the frame's palette so a border
always renders as one of the panel's exact 6 ink colors) directly on
`Widget` itself rather than a per-type config table, since every
widget type can have one regardless of `widget_type`. Drawn by
`image_pipeline.draw_widget_border` onto each widget's own region in
`routers/device.py`'s `_render_widgets`, before that region is pasted
onto the shared canvas -- one central integration point instead of
every `app/widgets/*.py` module needing to know about it. Set via the
gear-icon dialog's shared "Border" card (`_widget_border_fields.html`,
included by every `_widget_dialog_*.html` template) and
`POST .../widgets/{id}/border`, its own endpoint (not folded into
`api_widget_config_save`) since that endpoint's per-type dispatch is
keyed on a config row via `widget_locked`, and border fields live on
`Widget` itself, not any per-type config table.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
`BatteryWidgetConfig`, each keyed by `widget_id` with
`ondelete="CASCADE"` -- rather than one wide table with every type's `ondelete="CASCADE"` -- rather than one wide table with every type's
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
text (paragraphs of styled runs), never raw HTML -- see text (paragraphs of styled runs), never raw HTML -- see
@@ -36,11 +54,27 @@ a button press does.
`PhotoWidgetConfig` `PhotoWidgetConfig`
mirrors `app/photo_queue.py`'s attribute names exactly, so that module's mirrors `app/photo_queue.py`'s attribute names exactly, so that module's
advance/back/queue logic ports across widget instances unchanged. advance/back/queue logic ports across widget instances unchanged.
`PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id`
against both the timer-elapsed auto-advance
(`photo_queue.get_current`) and the advance/back button actions
(`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a
"Lock this photo" button in the widget's own dialog
(`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's
box on the Layout tab canvas.
`TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto
`CalendarWidgetConfig` (a week-view-only, single-list task list); split `CalendarWidgetConfig` (a week-view-only, single-list task list); split
into its own widget type (migration 17) so a task list can be placed into its own widget type (migration 17) so a task list can be placed
and sized independent of any calendar's view/footprint, then (migration and sized independent of any calendar's view/footprint, then (migration
18) given the same multi-source shape a calendar widget already has. 18) given the same multi-source shape a calendar widget already has.
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
weather strip (still present and unchanged, `weather_*` columns) out
into its own placeable widget type (migration 24) -- see "Weather
widget" below. `BatteryWidgetConfig` (migration 25) is the odd one out
-- its actual content (`Frame.battery_percent`/`battery_as_of`) isn't
in this table at all, already existing frame-level state set by
`routers/device.py`'s `frame_battery` regardless of whether a battery
widget is even placed; the config row only holds a display-mode
setting (`"compact"` | `"detailed"`).
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not - `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
`frame_id`) since a frame can now have more than one independent `frame_id`) since a frame can now have more than one independent
calendar/tasks widget, each with its own included set. Identical calendar/tasks widget, each with its own included set. Identical
@@ -65,8 +99,13 @@ orientation change rather than trying to remap coordinates.
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`): Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1. size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
Enforced both client-side weather 2x2 (its hourly/daily strips need the room; current/multi_city
modes would tolerate smaller, but every mode shares one footprint value),
battery 1x1 (just an icon + a percent, legible even at a single cell,
like photos/static -- though see `MIN_FOOTPRINT`'s own comment in
`grid.py` on a mobile-width gear-icon click-target gap at that size,
already pre-existing for photos/static too). Enforced both client-side
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`) (UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
and server-side (`routers/api_widgets.py`) -- the client is never trusted and server-side (`routers/api_widgets.py`) -- the client is never trusted
alone. alone.
@@ -75,7 +114,7 @@ alone.
`app/widgets/` is the render/action registry -- one module per `app/widgets/` is the render/action registry -- one module per
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`, `widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
`static_image.py`, `text.py`), each exposing: `static_image.py`, `text.py`, `weather.py`, `battery.py`), each exposing:
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`: - `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
an unquantized RGB image exactly `target_w x target_h`, the widget's an unquantized RGB image exactly `target_w x target_h`, the widget's
@@ -85,9 +124,11 @@ alone.
bad moment doesn't blank the whole panel. bad moment doesn't blank the whole panel.
- `ACTIONS: dict[str, Callable]` -- named button actions this type - `ACTIONS: dict[str, Callable]` -- named button actions this type
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"` supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
for whiteboard). Empty for tasks, static image, and text -- nothing to for whiteboard and weather -- both throttled external fetches with a
advance/back/force for a passive checklist, a fixed uploaded image, or forced-refetch action). Empty for tasks, static image, text, and
a fixed block of authored text. battery -- nothing to advance/back/force for a passive checklist, a
fixed uploaded image, a fixed block of authored text, or a number the
device itself pushes on every wake.
- `ACTION_LABELS: dict[str, str]` -- human labels for the button- - `ACTION_LABELS: dict[str, str]` -- human labels for the button-
assignment UI. assignment UI.
@@ -109,28 +150,49 @@ for month view to stay legible.
## Button actions ## Button actions
Each physical button (NEXT/BACK) maps to an **ordered list** of Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be every widget on the frame that has one -- **at most one binding per
"photo widget A: advance" *and* "calendar widget B: advance" together, or widget per button** (a widget can't be bound to two different actions on
even a mismatched combination on purpose. On a press, the same button). On a press, `routers/device.py`'s `_run_button_actions`
`routers/device.py`'s `_run_button_actions` runs every assigned action for runs every widget's assigned action for that button (each in its own
that button in order (each in its own `widget_locked` span -- never nested, `widget_locked` span -- never nested, since the underlying per-frame lock
since the underlying per-frame lock isn't reentrant), catching and isn't reentrant), catching and logging any single action's failure
logging any single action's failure without blocking the rest, then without blocking the rest, then re-renders and returns the whole composed
re-renders and returns the whole composed panel once at the end regardless panel once at the end regardless of which actions succeeded. Which
of which actions succeeded. widget's action runs first never matters -- each only touches its own
state, and the shared re-render happens once, after all of them finish.
The web UI for this is the "Button assignments" card on a frame's The UI for this lives in each widget's own gear-icon config dialog (the
Configuration tab (`static/frame_config.js`, `GET`/`PUT "Button actions" card, `templates/_widget_button_fields.html` +
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of `static/widget_dialog_button_actions.js`, `POST
the same type would otherwise both just say "Photos" in the assignment /api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
dropdowns; the UI disambiguates using each widget's grid position (e.g. tab, since assigning a widget's next/back behavior is naturally part of
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them configuring that widget. The card only renders for widget types with a
apart by eye on the Layout canvas. non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
static/text/battery have nothing to bind so the card is omitted for
them. An empty selection ("(none)") clears that button's binding for the
widget.
A newly-created widget (including the one auto-migrated from a frame's old A newly-created widget (including the one auto-migrated from a frame's
`mode` on upgrade) gets a sensible default binding reproducing its old old `mode` on upgrade) gets a sensible default binding reproducing its
button behavior -- see `migration.py`'s `_default_button_actions`. old button behavior -- see `widgets.default_button_actions` (called from
both `migration.py`'s backfill and `api_widgets.py`'s
`api_widget_create`), so a widget is never left with nothing bound until
someone deliberately reassigns it.
### Hold-for-global-action
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
minimum 3000ms, set on the Configuration tab) triggers a **global**
action instead of the per-widget one -- not scoped to any widget, e.g.
cycling through the user's saved layouts. See `app/global_actions.py`'s
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
device calls these instead of `/frame/advance`/`/frame/back` once it
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
any) each button's hold triggers; unset is a silent no-op, same
convention as an unbound short-press button.
## Per-widget config UI ## Per-widget config UI
@@ -190,6 +252,113 @@ The web UI lives in the Layout tab's "Saved layouts" card
saved layouts each with Apply/rename/delete, incompatible ones shown saved layouts each with Apply/rename/delete, incompatible ones shown
greyed-out with a "different orientation" badge rather than hidden. greyed-out with a "different orientation" badge rather than hidden.
## Weather widget
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
weather.py`) -- distinct from, and unrelated in code to,
`CalendarWidgetConfig`'s own embedded weather strip (still present,
still Open-Meteo-only, still working exactly as before). Four display
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
`calendar_view`):
- `current` -- one city's current temp + a condition icon.
- `hourly` -- one city, a row of ticks across the day at a configurable
interval (`hourly_interval_hours`: 3/4/6/12).
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
- `multi_city` -- several cities' current-day high/low/icon side by
side -- the calendar widget's embedded strip, as a standalone
widget's whole content instead of a strip above an agenda day.
**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default
| `"modern"`, experimental): `current`/`daily` only -- `hourly`/
`multi_city` always render classic regardless of this setting. `modern`
draws the widget as an HTML/CSS card (Jinja2 templates under
`app/templates/widget_html/`) through a persistent headless Chromium
browser (`app/html_render.py`, Playwright) instead of `app/weather_render.
py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading
PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd-
Steinberg) commits the rendered widget to exact palette colors *before*
compositing, so it's safe to mix with photo/other classic-rendered
widgets on the same frame without a Floyd-Steinberg seam at the boundary
(see that module's docstring for why ordered dithering doesn't have this
problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting
was needed. Playwright/Chromium is a real, heavyweight runtime dependency
imported lazily only when a weather widget actually uses this style, and
its Docker packaging has a known likely-blocking image-size problem not
yet resolved (see `server/Dockerfile`'s own comment) -- treat this style
as unshipped/local-only until that's sorted out.
`current`/`hourly`/`daily` share one configured location
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
has its own list (`cities`, add/remove via `POST .../weather-widget-
cities/add`|`remove` -- named to avoid colliding with the calendar
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
which share the same `{widget_id}`-parameterized path shape).
**Providers** (`app/weather/`, a dispatch registry over pluggable
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
provider` selects which of `app/weather.PROVIDERS` actually fetches --
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
only, no API key, approximates "current" with the first hourly forecast
period rather than a real station observation), or `"ec"` (Environment
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
Every provider function returns already-normalized `{"category": ...}`
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
always goes through Open-Meteo's free geocoder regardless of which
provider is chosen to fetch with the result.
EC's `citypageweather-realtime` collection is only queryable by bounding
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
`_nearest_site` widens the box progressively and picks the closest site
by straight-line distance, rejecting anything beyond 300 km (calibrated
against a real bug caught in development: an unconditional "nearest
site, however far" matched a Miami, FL query to a site in Ontario,
1824 km away, once the box widened enough to cover the whole country).
`app/weather_render.py` holds every weather-related drawing primitive:
`draw_weather_icon`/`draw_weather_row` (extracted out of
`calendar_render.py`, which still imports `draw_weather_row` for its own
embedded strip, unchanged) plus this widget's own `build_current`/
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
`render_tasks_preview_png` relationship. Icons are hand-drawn (no custom
font/icon asset), styled after Environment Canada's own icon set
(pointed sun rays, a puffy cloud, teardrop rain, dendrite snowflakes, a
zigzag bolt) but filled with the panel's *exact* ink RGB values rather
than an arbitrary bitmap's anti-aliased colors -- a flat fill that's
already a palette color quantizes with zero dithering error to diffuse,
where a fetched/vendored icon's colors (almost never an exact match)
dither into a visible speckle at these small on-panel sizes (confirmed
by actually running one through the real quantize pass during
development). Used for every provider's rendering, not just when EC is
selected as the provider.
## Battery widget
The simplest widget type (`models.BatteryWidgetConfig`, `app/widgets/
battery.py`): shows this frame's own last-reported battery level. Unlike
every other widget type, there's no live upstream to poll and nothing to
cache -- the content is `Frame.battery_percent`/`battery_as_of`, set by
`routers/device.py`'s `frame_battery` on every device wake-on-battery
report, which already existed for the Device panel's own history chart
regardless of whether a battery widget is placed anywhere. The widget's
own config is just a display mode: `"compact"` (icon + percent) or
`"detailed"` (default, adds `routers/common.py`'s existing
`battery_estimate_s` time-remaining estimate and the last report's age).
`render()` falls back to a "No reports yet" placeholder for a frame that
has never reported (never run on battery, or not yet claimed by a
device) rather than showing a stale or fabricated number. The battery
icon fill color (red/yellow/green by percent) uses the same exact-panel-
ink-RGB approach as the weather icons above and `manage_overlay.py`'s own
battery glyph on the "scan to manage" overlay -- a separate, unrelated
piece of code with its own fixed small size, not shared with this
widget, but drawing from the same thresholds/colors so a battery glyph
reads the same wherever one shows up on a panel.
## Known gaps (Phase 6, not yet done) ## Known gaps (Phase 6, not yet done)
The original 8-phase rollout plan's last phase is still open: The original 8-phase rollout plan's last phase is still open:
+45 -12
View File
@@ -66,8 +66,9 @@ Under **ESPresso Frame Configuration**:
| `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) | | `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) |
| `FRAME_BACK_BUTTON_GPIO` | 0 | Back-photo button GPIO (-1 to disable). Must be 0-7 | | `FRAME_BACK_BUTTON_GPIO` | 0 | Back-photo button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 | | `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset | | `FRAME_COMBO_MENU_HOLD_MS` | 3000 | How long the combo button must be held (then released) to show the management menu |
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset | | `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below | | `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains | | `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
@@ -84,6 +85,34 @@ reflashing. The Kconfig value only applies before the device has ever
successfully reached a configured server, or if the response doesn't successfully reached a configured server, or if the response doesn't
include a valid interval. include a valid interval.
### Holding NEXT/BACK for a global action
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
back this widget" and instead triggers whatever frame-wide action (if
any) is configured for that button's hold on the server's Configuration
tab -- e.g. cycling through saved layouts (see
`server/app/global_actions.py`). Fires immediately at the threshold,
without waiting for release -- same convention as the combo button's
factory-reset tier below.
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
one more wrinkle: the server's actual `hold_duration_ms` (set on the
Configuration tab, `GET /frame/config`'s response) can't be used for
*this* wake's button decision -- that decision happens in `main.c`
before WiFi even connects, but `/frame/config` isn't fetched until near
the end of the wake cycle (after the image fetch, deliberately -- see
`frame_client_run`'s own comment on why). So the device always acts on
whatever value the *previous* wake fetched (persisted in NVS via
`frame_config_set_hold_duration_ms`), falling back to
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
In practice this means changing the duration on the Configuration tab
takes effect starting with the wake *after* the next one, not
immediately.
Holding a button through the poll loop keeps the device awake and
connected longer than a normal short-press wake -- the same tradeoff
already accepted for the combo button's menu/reset holds below.
### WiFi fast-connect ### WiFi fast-connect
After a successful home-WiFi connection, the device caches the AP's After a successful home-WiFi connection, the device caches the AP's
@@ -204,6 +233,8 @@ device (if asleep) and tells the server to advance to the next photo
right away, regardless of the configured refresh interval -- no long hold right away, regardless of the configured refresh interval -- no long hold
needed, since advancing is easily reversible by pressing again. See needed, since advancing is easily reversible by pressing again. See
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature. `FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
entirely -- see "Holding NEXT/BACK for a global action" above.
Normal wakes and reboots never advance the photo on their own -- the Normal wakes and reboots never advance the photo on their own -- the
server decides when to advance based on its own clock (see server decides when to advance based on its own clock (see
@@ -223,7 +254,8 @@ disable the feature.
If there's nothing to go back to yet (freshly provisioned, or you've If there's nothing to go back to yet (freshly provisioned, or you've
already gone back as far as there is history), it's a no-op -- the already gone back as far as there is history), it's a no-op -- the
current photo stays exactly as it was, no flash on the panel. current photo stays exactly as it was, no flash on the panel. Same
long-hold caveat as the next-photo button above.
## Battery (XIAO ESP32-C6) ## Battery (XIAO ESP32-C6)
@@ -262,9 +294,13 @@ One more button, wired between GPIO1 and GND (same wiring style as the
other buttons), covers three actions -- disambiguated purely by how other buttons), covers three actions -- disambiguated purely by how
long it's held: long it's held:
**A quick press** wakes the device and overlays several corners of **A quick press** soft-resets the device -- `esp_restart()`, keeping the
whatever photo is currently showing, leaving the middle of the photo stored WiFi/server config. Useful for recovering a hung device without
visible and unchanged: losing setup.
**Holding it ~3 seconds, then releasing** wakes the device (if asleep)
and overlays several corners of whatever photo is currently showing,
leaving the middle of the photo visible and unchanged:
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the - **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
server's config page. server's config page.
@@ -283,8 +319,9 @@ for faces Immich hasn't been told a name for; no face detection happens
on the device or the server, this is entirely Immich's own People on the device or the server, this is entirely Immich's own People
feature). A third press exits immediately rather than waiting out the feature). A third press exits immediately rather than waiting out the
30-second timer. Holding the button during this stage doesn't trigger 30-second timer. Holding the button during this stage doesn't trigger
either reset tier below -- the hold-duration read only ever happens the factory-reset tier below -- the hold-duration read only ever
once, right when the device first wakes, before any menu is shown. happens once, right when the device first wakes, before any menu is
shown.
The device stays awake for the whole menu interaction (up to three The device stays awake for the whole menu interaction (up to three
physical refreshes: the base overlay, the escalated one, and physical refreshes: the base overlay, the escalated one, and
@@ -292,17 +329,13 @@ reverting), so this costs meaningfully more power than a normal wake --
expected for a deliberate, occasional action, same tradeoff as the expected for a deliberate, occasional action, same tradeoff as the
other buttons. other buttons.
**Holding it ~3 seconds, then releasing** soft-resets the device --
`esp_restart()`, keeping the stored WiFi/server config. Useful for
recovering a hung device without losing setup.
**Holding it ~15 seconds** (whether or not you're still holding it -- **Holding it ~15 seconds** (whether or not you're still holding it --
this fires immediately, it doesn't wait for release) clears the stored this fires immediately, it doesn't wait for release) clears the stored
WiFi/server config and restarts into provisioning. From either power-on WiFi/server config and restarts into provisioning. From either power-on
or while the device is deep-asleep, since this GPIO is armed as a or while the device is deep-asleep, since this GPIO is armed as a
wakeup source. wakeup source.
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_SOFT_RESET_HOLD_MS`, and See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_MENU_HOLD_MS`, and
`FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold `FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold
durations, or disable all three actions. durations, or disable all three actions.
+29 -13
View File
@@ -154,13 +154,12 @@ menu "ESPresso Frame Configuration"
Button wired between this GPIO and GND (active-low, internal Button wired between this GPIO and GND (active-low, internal
pull-up enabled in firmware -- no external resistor needed). pull-up enabled in firmware -- no external resistor needed).
One pin, three actions depending on how long it's held: One pin, three actions depending on how long it's held:
a quick press shows the management menu (same as before); a quick press soft-resets the device (reboots, keeps the
holding it FRAME_COMBO_SOFT_RESET_HOLD_MS then releasing stored WiFi/server config); holding it FRAME_COMBO_MENU_HOLD_MS
soft-resets the device (reboots, keeps the stored WiFi/ then releasing shows the management menu; holding it all the
server config); holding it all the way to way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored config config and restarts into provisioning, regardless of whether
and restarts into provisioning, regardless of whether it's it's released yet. Must be GPIO 0-7 for the same deep-sleep-
released yet. Must be GPIO 0-7 for the same deep-sleep-
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
a different pin than the other buttons. Set to -1 to a different pin than the other buttons. Set to -1 to
disable the feature entirely (also disables the management disable the feature entirely (also disables the management
@@ -168,14 +167,14 @@ menu "ESPresso Frame Configuration"
reconfiguring then only works by erasing NVS over USB, see reconfiguring then only works by erasing NVS over USB, see
firmware/README.md). firmware/README.md).
config FRAME_COMBO_SOFT_RESET_HOLD_MS config FRAME_COMBO_MENU_HOLD_MS
int "Soft-reset hold duration (ms)" int "Management-menu hold duration (ms)"
default 3000 default 3000
depends on FRAME_COMBO_BUTTON_GPIO >= 0 depends on FRAME_COMBO_BUTTON_GPIO >= 0
help help
How long the menu/reset button must be held before releasing How long the menu/reset button must be held before releasing
it triggers a soft reset (reboot, config kept). Long enough it shows the management menu instead of soft-resetting. Long
to be clearly distinct from a quick menu-opening press. enough to be clearly distinct from a quick reset tap.
config FRAME_COMBO_FACTORY_RESET_HOLD_MS config FRAME_COMBO_FACTORY_RESET_HOLD_MS
int "Factory-reset hold duration (ms)" int "Factory-reset hold duration (ms)"
@@ -185,8 +184,25 @@ menu "ESPresso Frame Configuration"
How long the menu/reset button must be held continuously How long the menu/reset button must be held continuously
before the device clears its stored config and reboots into before the device clears its stored config and reboots into
provisioning, regardless of release. Comfortably longer than provisioning, regardless of release. Comfortably longer than
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be FRAME_COMBO_MENU_HOLD_MS so the two tiers can't be confused
confused for each other. for each other.
config FRAME_HOLD_ACTION_MS
int "Next/back hold-for-global-action duration (ms)"
default 3000
range 3000 10000
help
How long the NEXT or BACK button must be held before it
triggers a frame-wide action (see server/app/global_actions.py
-- e.g. cycling saved layouts) instead of that button's normal
short-press behavior. Only a first-boot/never-connected
fallback: once the device has fetched GET /frame/config at
least once, the server's own Frame.hold_duration_ms (set on
the Configuration tab) overrides this on every later boot --
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
Floor matches the server's own minimum, so a long-held button
never means something different depending on which value
happened to apply.
config FRAME_BATTERY_ADC_GPIO config FRAME_BATTERY_ADC_GPIO
int "Battery voltage-divider ADC GPIO (-1 to disable)" int "Battery voltage-divider ADC GPIO (-1 to disable)"
+40 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "wifi_provisioning.h"
#include "back_button.h" #include "back_button.h"
static const char *TAG = "back_button"; static const char *TAG = "back_button";
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO) #define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20 #define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3 #define BACK_BUTTON_DEBOUNCE_CHECKS 3
#define BACK_BUTTON_POLL_MS 100
void back_button_init(void) void back_button_init(void)
{ {
@@ -36,7 +41,7 @@ void back_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW); esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
} }
bool back_button_check(void) back_button_result_t back_button_check(void)
{ {
/* A quick tap can easily release before this runs (~0.4-0.5s into /* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here * boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -44,32 +49,49 @@ bool back_button_check(void)
* status register is latched at the moment of waking and isn't * status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap * cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */ * regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) { bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
return true;
}
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh if (!caused_wake) {
* power-on/reflash) -- fall back to a live, debounced level check so /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* holding the button down while powering on also works. */ * fresh power-on/reflash) -- fall back to a live, debounced level
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) { * check so holding the button down while powering on also
return false; * works. */
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) { if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */ return BACK_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
} }
} }
ESP_LOGI(TAG, "Back-photo button held during power-on, going back"); /* Confirmed pressed -- measure how long, same reasoning/pattern as
return true; * next_button_check(). */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return BACK_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
elapsed_ms += BACK_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
return BACK_BUTTON_SHORT_PRESS;
} }
#else #else
void back_button_init(void) {} void back_button_init(void) {}
bool back_button_check(void) { return false; } back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
#endif #endif
+18 -4
View File
@@ -12,9 +12,23 @@
*/ */
void back_button_init(void); void back_button_init(void);
typedef enum {
BACK_BUTTON_NOT_PRESSED,
/** A short press -- same immediate-response reasoning as the
* next-photo button. */
BACK_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
* Fires immediately at the threshold, without waiting for release. */
BACK_BUTTON_HOLD,
} back_button_result_t;
/** /**
* Returns whether the back-photo button is currently held, debounced with * Checks the back-photo button and, if it's pressed at all, blocks
* a couple of short re-checks to reject noise. Same immediate-response * polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
* reasoning as the next-photo button -- no long hold-to-confirm gate. * or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
* next_button_check(). Evaluated once per wake.
*/ */
bool back_button_check(void); back_button_result_t back_button_check(void);
+7 -7
View File
@@ -53,8 +53,8 @@ bool combo_button_check(void)
return false; /* not pressed, and didn't cause this wake either */ return false; /* not pressed, and didn't cause this wake either */
} }
ESP_LOGI(TAG, "Combo button held -- quick press for menu, %dms for soft reset, %dms for factory reset", ESP_LOGI(TAG, "Combo button held -- quick press for soft reset, %dms for menu, %dms for factory reset",
CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS); CONFIG_FRAME_COMBO_MENU_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
int elapsed_ms = 0; int elapsed_ms = 0;
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) { while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
@@ -70,13 +70,13 @@ bool combo_button_check(void)
} }
} }
if (elapsed_ms >= CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS) { if (elapsed_ms >= CONFIG_FRAME_COMBO_MENU_HOLD_MS) {
ESP_LOGW(TAG, "Held %dms and released, soft-restarting (config kept)", elapsed_ms); ESP_LOGI(TAG, "Held %dms and released, showing management menu", elapsed_ms);
esp_restart(); return true;
} }
ESP_LOGI(TAG, "Quick press (%dms), showing management menu", elapsed_ms); ESP_LOGW(TAG, "Quick press (%dms), soft-restarting (config kept)", elapsed_ms);
return true; esp_restart();
} }
bool combo_button_is_pressed(void) bool combo_button_is_pressed(void)
+4 -4
View File
@@ -16,11 +16,11 @@ void combo_button_init(void);
* Checks the combined menu/reset button and acts on how long it was * Checks the combined menu/reset button and acts on how long it was
* held, evaluated once per wake: * held, evaluated once per wake:
* - Not pressed: returns false immediately. * - Not pressed: returns false immediately.
* - Released before CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS (a quick * - Released before CONFIG_FRAME_COMBO_MENU_HOLD_MS (a quick press):
* press): returns true -- caller should show the management menu. * a soft reset (esp_restart(), stored WiFi/server config kept) --
* - Released between the soft-reset and factory-reset thresholds: a
* soft reset (esp_restart(), stored WiFi/server config kept) --
* never returns. * never returns.
* - Released between the menu and factory-reset thresholds: returns
* true -- caller should show the management menu.
* - Held through CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS: a factory * - Held through CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS: a factory
* reset (frame_config_clear() + esp_restart(), fires immediately * reset (frame_config_clear() + esp_restart(), fires immediately
* without waiting for release) -- never returns. * without waiting for release) -- never returns.
+29 -6
View File
@@ -276,6 +276,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
typedef struct { typedef struct {
bool reachable; bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */ uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
/* How long NEXT/BACK must be held to trigger a global action instead
* of a short press (see next_button.h/back_button.h) --
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
* the *next* boot's button-hold decision -- this fetch happens too
* late in the cycle for its own boot's decision, see that function's
* own doc comment. */
uint32_t hold_duration_ms;
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */ char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
/* Per-frame token the server pushes until this device has /* Per-frame token the server pushes until this device has
* authenticated with it once; empty when absent. Persisted via * authenticated with it once; empty when absent. Persisted via
@@ -370,6 +378,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
frame_server_config_t result = { frame_server_config_t result = {
.reachable = false, .reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S, .refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
}; };
result.firmware_version[0] = '\0'; result.firmware_version[0] = '\0';
result.device_token[0] = '\0'; result.device_token[0] = '\0';
@@ -419,6 +428,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url, ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s); (int)result.refresh_interval_s);
} }
uint32_t hold_ms;
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
result.hold_duration_ms = hold_ms;
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version)); json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token)); json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
@@ -443,12 +456,13 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
return n > 0 ? (size_t)n : 0; return n > 0 ? (size_t)n : 0;
} }
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or /* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
* /frame/back to force a move in either direction (FETCH_ADVANCE / * /frame/global-next, or /frame/global-back to force a move/action
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the * (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
* manage button) appends &manage=1, telling the server to bake its * FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
* overlay into this same response instead of returning the bare * manage=true (the manage button) appends &manage=1, telling the server
* content -- see server/app/routers/device.py. Returning non-ESP_OK * to bake its overlay into this same response instead of returning the
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream() * means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/ * (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen * wrong-size stream, so a failure here always leaves the visible screen
@@ -460,6 +474,10 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
path = "frame/advance"; path = "frame/advance";
} else if (action == FETCH_BACK) { } else if (action == FETCH_BACK) {
path = "frame/back"; path = "frame/back";
} else if (action == FETCH_GLOBAL_NEXT) {
path = "frame/global-next";
} else if (action == FETCH_GLOBAL_BACK) {
path = "frame/global-back";
} }
char url[256]; char url[256];
@@ -720,6 +738,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
report_battery(cfg, battery_percent); report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg); frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S; sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
if (server_cfg.reachable) {
/* For next boot's button-hold decision, not this one -- see
* frame_config_get_hold_duration_ms()'s own doc comment. */
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
}
/* One-time identity handshake: the server pushes this frame's /* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it * own token until we've authenticated with it once. Persist it
+8 -3
View File
@@ -9,14 +9,19 @@
* Which photo-fetch behavior this wake cycle should use -- normally the * Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to * idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain * advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST * wake/reboot never skips a photo just by asking), POST /frame/advance /
* /frame/advance / POST /frame/back to force a move in either direction * POST /frame/back to force a move in either direction (a short press of
* (the next-photo / back-photo buttons). * the next-photo / back-photo buttons), or POST /frame/global-next /
* POST /frame/global-back to run whatever frame-wide action (if any) is
* configured for a held press (see next_button.h/back_button.h's
* *_HOLD result and app/global_actions.py server-side).
*/ */
typedef enum { typedef enum {
FETCH_NORMAL, FETCH_NORMAL,
FETCH_ADVANCE, FETCH_ADVANCE,
FETCH_BACK, FETCH_BACK,
FETCH_GLOBAL_NEXT,
FETCH_GLOBAL_BACK,
} fetch_action_t; } fetch_action_t;
/** /**
+13 -4
View File
@@ -40,12 +40,21 @@ void app_main(void)
back_button_init(); back_button_init();
combo_button_init(); combo_button_init();
bool next_pressed = next_button_check(); next_button_result_t next_result = next_button_check();
bool back_pressed = back_button_check(); back_button_result_t back_result = back_button_check();
/* Next takes priority over back if somehow both read pressed at once /* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but * (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */ * deterministic tie-break, not expected to matter in practice. Same
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL; * priority applies whether the winning button resolved to a short
* press or a hold. */
fetch_action_t action;
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
} else {
action = FETCH_NORMAL;
}
/* Soft-resets or clears config + restarts internally for a medium/ /* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here * long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */ * for "not pressed" (false) or "quick press" (true, show the menu). */
+45 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "wifi_provisioning.h"
#include "next_button.h" #include "next_button.h"
static const char *TAG = "next_button"; static const char *TAG = "next_button";
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO) #define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
#define NEXT_BUTTON_DEBOUNCE_MS 20 #define NEXT_BUTTON_DEBOUNCE_MS 20
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3 #define NEXT_BUTTON_DEBOUNCE_CHECKS 3
#define NEXT_BUTTON_POLL_MS 100
void next_button_init(void) void next_button_init(void)
{ {
@@ -40,7 +45,7 @@ void next_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW); esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
} }
bool next_button_check(void) next_button_result_t next_button_check(void)
{ {
/* A quick tap can easily release before this runs (~0.4-0.5s into /* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here * boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -48,32 +53,54 @@ bool next_button_check(void)
* status register is latched at the moment of waking and isn't * status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap * cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */ * regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) { bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
return true;
}
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh if (!caused_wake) {
* power-on/reflash) -- fall back to a live, debounced level check so /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* holding the button down while powering on also works. */ * fresh power-on/reflash) -- fall back to a live, debounced level
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) { * check so holding the button down while powering on also
return false; * works. */
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) { if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */ return NEXT_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
} }
} }
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance"); /* Confirmed pressed (either the wake cause, or debounced during
return true; * power-on) -- measure how long, same polling pattern as
* combo_button.c's own hold-tier detection. Reads the last hold
* duration the server reported (persisted from a previous cycle,
* see frame_config_get_hold_duration_ms's own doc comment), falling
* back to the Kconfig default before the device has ever fetched
* one. */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return NEXT_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
elapsed_ms += NEXT_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
return NEXT_BUTTON_SHORT_PRESS;
} }
#else #else
void next_button_init(void) {} void next_button_init(void) {}
bool next_button_check(void) { return false; } next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
#endif #endif
+22 -5
View File
@@ -12,10 +12,27 @@
*/ */
void next_button_init(void); void next_button_init(void);
typedef enum {
NEXT_BUTTON_NOT_PRESSED,
/** A short press -- advancing a photo is low-stakes and should feel
* immediate, so this fires the moment the button releases (or right
* away for a wake-triggered press, once it's confirmed not a hold). */
NEXT_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
* Fires immediately at the threshold, without waiting for release --
* same convention as combo_button.c's factory-reset tier. */
NEXT_BUTTON_HOLD,
} next_button_result_t;
/** /**
* Returns whether the next-photo button is currently held, debounced with * Checks the next-photo button and, if it's pressed at all (either what
* a couple of short re-checks to reject noise. No long hold-to-confirm * caused this wake, per the latched wakeup-status register, or held
* gate -- advancing a photo is low-stakes and should feel immediate, so * through a debounced power-on check), blocks polling its level until
* this returns right away either way. * either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
* release). Evaluated once per wake.
*/ */
bool next_button_check(void); next_button_result_t next_button_check(void);
+23
View File
@@ -234,6 +234,29 @@ void frame_config_invalidate_last_display_crc32(void)
nvs_close(handle); nvs_close(handle);
} }
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out)
{
nvs_handle_t handle;
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle);
if (err != ESP_OK) {
return err;
}
err = nvs_get_u32(handle, "hold_ms", out);
nvs_close(handle);
return err;
}
void frame_config_set_hold_duration_ms(uint32_t ms)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_u32(handle, "hold_ms", ms);
nvs_commit(handle);
nvs_close(handle);
}
/* ------------------------------------------------------------------------ /* ------------------------------------------------------------------------
* WiFi fast-connect cache * WiFi fast-connect cache
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
+21
View File
@@ -94,6 +94,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
*/ */
void frame_config_invalidate_last_display_crc32(void); void frame_config_invalidate_last_display_crc32(void);
/**
* Returns the hold_duration_ms the server most recently reported via GET
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
* -- how long NEXT/BACK must be held before next_button_check()/
* back_button_check() treat it as a hold-for-global-action instead of a
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
* fetched one yet (fresh install/factory reset); caller should fall back
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
*
* Deliberately a *previous* cycle's value: this cycle's own button
* decision happens in main.c before WiFi even connects, but
* /frame/config isn't fetched until near the end of frame_client_run
* (after the image fetch, for connection-warmth/timeout reasons -- see
* its own comment) -- so there's no same-cycle fresh value to use yet.
*/
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
/** Persists the hold duration reported by the server, for the *next*
* boot's button-hold decision to use. */
void frame_config_set_hold_duration_ms(uint32_t ms);
/** /**
* Returns this device's provisioning AP identity: a fixed SSID (from * Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted * Kconfig) and a password that's generated once on first use and persisted
+1 -1
View File
@@ -1 +1 @@
1.3.0 1.4.1
+82 -25
View File
@@ -2,39 +2,96 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# tzdata/fonts in their own layer, kept separate from the much larger # tzdata/fonts/Node.js all from Debian's own repo in one layer -- no
# Node.js/npm layers below -- see those layers' own comments for why # external curl/gnupg dance needed (see below for why that changed).
# they're split up the way they are. tzdata: python:3.12-slim doesn't # tzdata: python:3.12-slim doesn't include it by default, so the
# include it by default, so the zoneinfo database backing the web UI's # zoneinfo database backing the web UI's "Timezone" setting (used by
# "Timezone" setting (used by "Quiet hours") would have no named zones # "Quiet hours") would have no named zones to resolve without this --
# to resolve without this -- ZoneInfo() would raise for anything other # ZoneInfo() would raise for anything other than "UTC".
# than "UTC". fontconfig/fonts-dejavu-core: whiteboard mode's # fontconfig/fonts-dejavu-core: whiteboard mode's render-service/ (own
# render-service/ (own README there) needs something to render # README there) needs something to render whiteboard text with.
# whiteboard text with. #
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Node.js: whiteboard frame mode's render-service/ runs as a second # Node.js: whiteboard frame mode's render-service/ runs as a second
# process in this same container rather than a separate compose service # process in this same container rather than a separate compose service
# -- it's a lightweight, stateless, localhost-only sidecar with nothing # -- it's a lightweight, stateless, localhost-only sidecar with nothing
# worth independently scaling or restarting. NodeSource's setup script is # worth independently scaling or restarting. Used to be installed via
# used instead of Debian bookworm's own apt Node package, which is both # NodeSource's setup script (Debian's own nodejs package was too old for
# older than jsdom's minimum (20.19+) and inconsistently available. # jsdom's minimum back when this base image tracked Debian bookworm) --
# curl/gnupg are only needed to add and fetch NodeSource's repo -- purged # switched to Debian's own `nodejs`/`npm` packages after NodeSource's
# again in this same RUN (not a later one; Docker layers are immutable, # deb.nodesource.com started intermittently 403ing on both its setup_*.x
# so removing them in a *different* instruction wouldn't shrink this # scripts *and* its GPG key (a live NodeSource-side S3/CDN issue,
# one's actual pushed size) so their bytes don't end up in the image at # confirmed 2026-07-27 by hitting deb.nodesource.com directly -- some
# all, only nodejs's. # setup_NN.x paths 403, others 200, no consistent pattern, so no
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates gnupg \ # NodeSource-hosted install path could be trusted not to silently break
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ # again). This base image now tracks Debian trixie, whose own `nodejs`
&& apt-get install -y --no-install-recommends nodejs \ # package is 20.19.2 -- inside jsdom 29's stated engines range
&& apt-get purge -y --auto-remove curl gnupg \ # (`^20.19.0 || ^22.13.0 || >=24.0.0`) and well above express/resvg-js's
# much lower floors -- so there's no longer a version gap to route
# around NodeSource for. One less external dependency, and no more
# curl-piped-into-bash (that pattern is also what let the NodeSource
# failure go undetected here in the first place: `curl -f ... | bash -`
# on a 403 hands bash an empty, "successful" script instead of failing
# the RUN outright).
RUN apt-get update && apt-get install -y --no-install-recommends \
tzdata fontconfig fonts-dejavu-core nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# EXPERIMENTAL, likely unmergeable as-is -- see below. 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/* && 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
# KNOWN LIKELY BLOCKER, not resolved by pulling this into its own layer:
# `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. This almost certainly exceeds the same Cloudflare single-
# blob/layer limit that forced the pip/npm splits elsewhere in this file
# (see their comments) -- an actual push to this project's registry
# hasn't been attempted (would require pushing to `main`, which triggers
# deploy) to confirm, but there is no reason to expect a single 181MB
# blob to fit where combined ~113MB of many small wheels didn't. Needs a
# real resolution (a registry without this limit, hosting the browser
# binary outside the image, etc.) before this branch can actually ship --
# tracked as open, not silently assumed away.
RUN playwright install chromium-headless-shell
# 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
+20 -10
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=`)
@@ -121,9 +129,11 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
this response may grow. this response may grow.
- `GET /frame/photo-info` -- location/date overlay text for the manage - `GET /frame/photo-info` -- location/date overlay text for the manage
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`). menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich - `GET /frame/share/{manage_token}` -- creates a 30-minute public Immich
share link and 302s to it; scoped to the photo currently showing or share link covering every photo widget's currently-showing photo on
queued on *this* frame only. *this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`.
- `GET /frame/face-labels` -- up to 4 named faces with 800x480 - `GET /frame/face-labels` -- up to 4 named faces with 800x480
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser. flat-scalar parser.
@@ -200,12 +210,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
in the web UI (or using "Show next") only rearranges what's already in in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album. that lookahead; it doesn't add or remove photos from the album.
- Auth in one breath: browsers use sessions (+CSRF), devices use - Auth in one breath: browsers use sessions (+CSRF), devices use
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own per-frame tokens (`?id=` + `?token=`), the manage QR and the
limited token, and `MANAGEMENT_TOKEN` survives only as the migration scan-to-download QR both use the frame's own `manage_token` (device
credential for pre-multi-frame firmware. `/frame/share` stays scoped tokens don't work for either -- neither is ever called by firmware,
to photos this frame is actually showing or has queued, not any both are opened by a phone that has no way to supply `?id=`/`?token=`),
Immich asset ID someone might guess -- a second layer a leaked device and `MANAGEMENT_TOKEN` survives only as the migration credential for
token alone wouldn't bypass. pre-multi-frame firmware.
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events - Calendar frame mode (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/), (RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed which is LGPL-3.0-or-later -- the only non-permissively-licensed
+149 -214
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,
@@ -35,18 +36,27 @@ from .image_pipeline import (
logical_render_size, logical_render_size,
) )
from .weather import weather_category from .weather import weather_category
from .weather_render import draw_weather_row
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"] CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)", CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
"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
@@ -66,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
@@ -90,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
@@ -157,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).
@@ -389,98 +382,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
return entries return entries
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one black pass slightly larger than
the shapes, then the same shapes again in white on top. Overlapping
ellipses each drawn with their own `outline=` would leave visible
seams where they cross; this double-draw trick sidesteps that
entirely regardless of how the lobes overlap."""
stroke = 2
lobes = [
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=BG)
draw.rectangle(base, fill=BG)
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
"""A small hand-drawn glyph for one weather category -- no custom
font/icon asset, same hand-primitives-only approach the rest of this
module uses (colored rectangles for owner indicators, density dots
for month view)."""
if category == "clear":
# Kept within a ~1.1r visual radius overall (rays included) to
# match _draw_cloud's own footprint -- _draw_weather_row lays
# icons out assuming each one stays roughly within icon_r of its
# center, and the first entry in a row sits flush against the
# region's own left margin, so any icon that draws wider than
# that pokes out past it with nothing to visually connect to.
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
fill=FG, width=3)
return
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
if category == "partly_cloudy":
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
_draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.5 + i * (r * 0.45)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.6, 0, 0.6):
x = cx + dx * r
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
elif category == "snow":
for dx in (-0.6, 0, 0.6):
x, y = cx + dx * r, cloud_cy + r * 0.9
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
elif category == "thunderstorm":
x, y = cx, cloud_cy + r * 0.5
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
fill=FG, width=2)
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True) -> int:
"""Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities --
same graceful-degradation approach month view takes with density
dots). Returns the row height consumed (0 if there was nothing to
draw, so callers can skip reserving space entirely)."""
if not entries:
return 0
unit_suffix = "F" if units == "fahrenheit" else "C"
row_h = icon_r * 2 + 8
x = x0
drew_any = False
for entry in entries:
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
label = f"{entry['label']} {temps}" if show_labels else temps
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
if drew_any and x + entry_w > x0 + max_w:
break
cx, cy = x + icon_r, y0 + icon_r
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w
drew_any = True
return row_h + 6
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo, def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont, region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None, body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
@@ -492,33 +393,36 @@ 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:
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries, y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units) icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
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 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)
@@ -535,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
@@ -550,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
@@ -602,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
@@ -630,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
@@ -675,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()
@@ -689,53 +597,55 @@ 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
# _draw_weather_row) -- typically one city, no label (the column # weather_render.draw_weather_row) -- typically one city, no label
# itself makes which day it's for obvious; a city name wouldn't fit # (the column itself makes which day it's for obvious; a city name
# anyway). Never more than that -- this is already the tight view. # wouldn't fit anyway). Never more than that -- this is already
# the tight view.
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries, y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
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)
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:
@@ -760,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
@@ -806,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
@@ -847,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
@@ -903,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
+115
View File
@@ -0,0 +1,115 @@
"""Frame-wide actions triggered by holding NEXT/BACK past
Frame.hold_duration_ms, instead of the per-widget action a short press
runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not
scoped to any one widget -- e.g. cycling through the owner's saved
layouts -- so this is its own registry rather than living in a widget
module.
Each function's signature is (db, frame) -> None, the frame-level
analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and
each is responsible for its own locking/commit internally (frame_locked/
widget_locked), same convention as app/widgets/*.py. routers/device.py's
/frame/global-next and /frame/global-back look up which (if any) of
these Frame.next_hold_action/back_hold_action points to and call it,
same "unset/unknown -> silent no-op" posture as an unbound short-press
button."""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import grid
from .db import frame_locked
from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget
from .routers.api_layouts import apply_layout_to_frame
from .widgets import WIDGET_TYPES
logger = logging.getLogger(__name__)
def cycle_layout(db: Session, frame: Frame) -> None:
"""Applies the owner's next saved layout compatible with this
frame's current grid size, in a stable order (by id), wrapping back
to the first past the last one. A silent no-op if the frame is
unclaimed or its owner has no compatible saved layouts -- same
posture as every other action here when there's nothing to do."""
if frame.owner_user_id is None:
return
cols, rows = grid.grid_dims(frame.orientation)
candidates = db.scalars(
select(SavedLayout)
.where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows)
.order_by(SavedLayout.id)
).all()
if not candidates:
return
next_layout = candidates[0]
if frame.last_cycled_layout_id is not None:
for i, layout in enumerate(candidates):
if layout.id == frame.last_cycled_layout_id:
next_layout = candidates[(i + 1) % len(candidates)]
break
apply_layout_to_frame(db, frame, next_layout)
with frame_locked(db, frame.id) as locked:
locked.last_cycled_layout_id = next_layout.id
def refresh_all_widgets(db: Session, frame: Frame) -> None:
"""Runs every widget's own check_now (calendar/weather/whiteboard),
regardless of which button it's normally bound to -- a manual "sync
everything now" global action. One widget's failure doesn't block
the rest, same posture as routers/device.py's _run_button_actions."""
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
for widget in widgets:
module = WIDGET_TYPES.get(widget.widget_type)
check_now = module.ACTIONS.get("check_now") if module else None
if check_now is None:
continue
try:
check_now(db, frame, widget)
except Exception:
logger.exception(
"refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id
)
def toggle_all_photo_locks(db: Session, frame: Frame) -> None:
"""Flips PhotoWidgetConfig.locked for every photo widget on the frame
at once. Target state is the opposite of "everything's already
locked" -- one hold freezes every photo widget unless they're all
already frozen, in which case it unfreezes all of them. A no-op if
the frame has no photo widgets."""
widget_ids = [
w.id for w in db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
)
]
if not widget_ids:
return
configs = db.scalars(
select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids))
).all()
if not configs:
return
target = not all(c.locked for c in configs)
with frame_locked(db, frame.id):
for config in configs:
config.locked = target
GLOBAL_ACTIONS = {
"cycle_layout": cycle_layout,
"refresh_all_widgets": refresh_all_widgets,
"toggle_all_photo_locks": toggle_all_photo_locks,
}
GLOBAL_ACTION_LABELS = {
"cycle_layout": "Cycle saved layouts",
"refresh_all_widgets": "Refresh all widgets now",
"toggle_all_photo_locks": "Freeze/unfreeze all photo widgets",
}
+12 -1
View File
@@ -24,7 +24,16 @@ GRID_SHORT = 5
# scaling (see calendar_render.py); whiteboard needs enough room to be # scaling (see calendar_render.py); whiteboard needs enough room to be
# worth looking at; photos can go as small as a single cell; tasks needs # worth looking at; photos can go as small as a single cell; tasks needs
# enough width for a due-date prefix plus a couple words of summary # enough width for a due-date prefix plus a couple words of summary
# without truncating on every row. # without truncating on every row; weather needs enough room for its
# hourly/daily strips to stay legible (its current/multi_city modes
# would tolerate smaller, but every mode shares one footprint value).
# battery is just an icon + a percent (+ two optional small lines in
# "detailed" mode) -- legible even at a single cell, like photos/static.
# NOTE: a 1x1 widget-box on a narrow mobile canvas can clip its own
# gear/remove buttons behind theme.css's overflow: hidden (their fixed
# pixel offsets overflow the box's clipped width) -- a pre-existing
# layout gap that already affects photos/static at 1x1 too, not fixed
# here; see the finding called out where this was discovered.
MIN_FOOTPRINT: dict[str, tuple[int, int]] = { MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"photos": (1, 1), "photos": (1, 1),
"calendar": (3, 2), "calendar": (3, 2),
@@ -32,6 +41,8 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
"tasks": (2, 2), "tasks": (2, 2),
"static": (1, 1), "static": (1, 1),
"text": (2, 1), "text": (2, 1),
"weather": (2, 2),
"battery": (1, 1),
} }
Rect = tuple[int, int, int, int] # (x, y, w, h) Rect = tuple[int, int, int, int] # (x, y, w, h)
+327
View File
@@ -0,0 +1,327 @@
"""Experimental "modern" weather widget render style: Jinja2 + a
persistent headless Chromium browser (Playwright) instead of the hand-
drawn PIL primitives in weather_render.py -- see docs/widgets.md and the
`html-widget-render` branch's PR description for the design rationale
(gradients/shadows/soft shading that PIL can't easily do, at the cost of
a real browser-process dependency).
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
from .image_pipeline import DEFAULT_PALETTE_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": "⛈️",
}
# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's
# card header -- deliberately not routed through panel_style.theme_color
# (unlike every classic-rendered widget's chrome), since the whole point
# of this style is the gradient look ordered_dither below then commits
# to exact palette colors anyway; which literal hex this starts from
# doesn't matter to the end result the way it would for a flat PIL fill.
ACCENT_START = "#1c4fd6"
ACCENT_END = "#6fa8ff"
# --- 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")
# --- 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 = "") -> 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)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry:
return img
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=panel_style.CARD_RADIUS,
font_dir=str(_FONT_DIR), 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 = "") -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see
ordered_dither)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items())
if not days:
return img
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=panel_style.CARD_RADIUS,
font_dir=str(_FONT_DIR), city_label=city_label, header_h=header_h,
title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END,
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)
return ordered_dither(rendered, palette_rgb)
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 = "") -> 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)
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "") -> 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)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
+181 -15
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import io import io
import math
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
@@ -32,6 +33,104 @@ def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0) mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask) img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
def _dashed_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
width: int, color: tuple[int, int, int], dash: float, gap: float) -> None:
length = math.hypot(x1 - x0, y1 - y0)
if length <= 0:
return
ux, uy = (x1 - x0) / length, (y1 - y0) / length
pos = 0.0
while pos < length:
end = min(pos + dash, length)
draw.line([(x0 + ux * pos, y0 + uy * pos), (x0 + ux * end, y0 + uy * end)], fill=color, width=width)
pos += dash + gap
def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
width: int, color: tuple[int, int, int], spacing: float) -> None:
length = math.hypot(x1 - x0, y1 - y0)
if length <= 0:
return
ux, uy = (x1 - x0) / length, (y1 - y0) / length
r = max(1, width / 2)
pos = 0.0
while pos <= length:
cx, cy = x0 + ux * pos, y0 + uy * pos
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
pos += spacing
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int],
radius: int = 0) -> None:
"""Draws a border inset within img's own bounds, mutating it in
place -- called once per widget's own region (routers/device.py's
_render_widgets, and each widget type's own dialog preview) before
that region's image is pasted onto the shared canvas, so a border
never straddles the boundary between two adjacent widgets. `color`
should already be an exact palette RGB (see resolve_border_color) so
the stroke quantizes with zero dithering error, same reasoning as
the weather/battery icons' exact-panel-ink-RGB fills.
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
just inside the image's edge; "fancy" is two thinner concentric
strokes with a gap between them, picture-frame-mat style. "none" (or
a non-positive thickness) draws nothing. `radius` is opt-in and only
honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) --
"dashed"/"dotted" trace each of the 4 edges as independent straight
segments (see _dashed_edge/_dotted_edge) and ignore it, a documented
limitation rather than a bug. Defaults to 0 (unchanged sharp-corner
behavior) and no call site passes non-zero today -- this ships the
capability for a future per-widget "rounded border" setting without
changing default behavior anywhere (see tests/test_widget_border.py's
exact-corner-pixel assertions)."""
if style == "none" or thickness <= 0:
return
w, h = img.size
t = max(1, min(int(thickness), min(w, h) // 2))
draw = ImageDraw.Draw(img)
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
if style == "fancy":
line_t = max(1, t // 3)
gap = max(2, t - 2 * line_t)
if r:
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t)
else:
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
inset = line_t + gap
if w - 2 * inset > 1 and h - 2 * inset > 1:
inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0
if inner_r:
draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r,
outline=color, width=line_t)
else:
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
return
if style == "solid":
if r:
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t)
else:
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
return
# dashed/dotted trace the same centered-on-the-edge path solid/
# fancy's rectangle outline draws, so all four styles sit at the
# same inset regardless of which is chosen.
half = t / 2
x0, y0, x1, y1 = half, half, w - 1 - half, h - 1 - half
edges = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
if style == "dashed":
dash, gap = t * 3, t * 2
for ex0, ey0, ex1, ey1 in edges:
_dashed_edge(draw, ex0, ey0, ex1, ey1, t, color, dash, gap)
elif style == "dotted":
spacing = max(t * 2, t + 4)
for ex0, ey0, ex1, ey1 in edges:
_dotted_edge(draw, ex0, ey0, ex1, ey1, t, color, spacing)
# How each orientation maps the logically-composed image onto the native # How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the # 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native # crop ratio matches how the frame actually hangs) and rotate into native
@@ -88,12 +187,48 @@ 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
# upstream. # upstream.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6] PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
# Per-widget optional border (models.Widget.border_style, see
# draw_widget_border below). "none" is the default/no-op; the rest are
# thickness-px strokes inset within the widget's own region.
BORDER_STYLES = ["none", "solid", "dashed", "dotted", "fancy"]
BORDER_STYLE_LABELS = {
"none": "None",
"solid": "Solid",
"dashed": "Dashed",
"dotted": "Dotted",
"fancy": "Fancy (double line)",
}
MIN_BORDER_THICKNESS = 1
MAX_BORDER_THICKNESS = 8
DEFAULT_BORDER_THICKNESS = 3
def palette_to_hex(palette_rgb: list) -> list[str]: def palette_to_hex(palette_rgb: list) -> list[str]:
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced """[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
@@ -101,6 +236,21 @@ def palette_to_hex(palette_rgb: list) -> list[str]:
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb] return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
def resolve_border_color(color_index: int, palette_rgb: list | None) -> tuple[int, int, int]:
"""Widget.border_color_index -> an actual RGB tuple, against this
frame's tuned palette if it has one (falls back to
DEFAULT_PALETTE_RGB) -- so a border always renders as one of the
panel's real 6 ink colors and never needs to be dithered, same
reasoning as the weather/battery icons' exact-panel-ink-RGB fills
(see docs/widgets.md). Out-of-range indexes (a stale value from a
frame that used to have more colors, though that never happens
today) fall back to Black rather than raising."""
palette = palette_rgb or DEFAULT_PALETTE_RGB
if 0 <= color_index < len(palette):
return tuple(palette[color_index])
return tuple(palette[0])
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None: def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly """"#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
a 6-hex-digit color (what <input type="color"> always sends, but a a 6-hex-digit color (what <input type="color"> always sends, but a
@@ -388,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
@@ -422,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:
@@ -432,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,
@@ -451,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
@@ -466,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))
@@ -530,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
+5 -5
View File
@@ -80,10 +80,10 @@ class ImmichClient:
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
def create_share_link(self, asset_id: str, expires_in_s: int) -> str: def create_share_link(self, asset_ids: list[str], expires_in_s: int) -> str:
"""Creates a public, view-only Immich share link for a single """Creates a public, view-only Immich share link covering one or
asset, expiring expires_in_s seconds from now, and returns its more assets, expiring expires_in_s seconds from now, and returns
public URL. Used by the manage-button overlay's share QR -- its public URL. Used by the manage-button overlay's share QR --
created lazily (only when someone actually scans it), not when created lazily (only when someone actually scans it), not when
the button's pressed, so the expiry clock starts when it's the button's pressed, so the expiry clock starts when it's
actually used.""" actually used."""
@@ -93,7 +93,7 @@ class ImmichClient:
headers=self._headers, headers=self._headers,
json={ json={
"type": "INDIVIDUAL", "type": "INDIVIDUAL",
"assetIds": [asset_id], "assetIds": list(asset_ids),
"expiresAt": expires_at, "expiresAt": expires_at,
"allowUpload": False, "allowUpload": False,
"allowDownload": True, "allowDownload": True,
+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)
+203 -25
View File
@@ -24,7 +24,6 @@ from .models import (
BatteryLog, BatteryLog,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction,
FrameTaskList, FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
@@ -32,6 +31,7 @@ from .models import (
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
Widget, Widget,
) )
from .widgets import default_button_actions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -385,8 +385,9 @@ def _migration_17(conn) -> None:
"SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id" "SELECT COALESCE(MAX(sort_order), 0) FROM widgets WHERE frame_id = :frame_id"
), {"frame_id": row["frame_id"]}).scalar() ), {"frame_id": row["frame_id"]}).scalar()
result = conn.execute(text( result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at) " "INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at)" "border_style, border_thickness, border_color_index) "
"VALUES (:frame_id, 'tasks', :x, :y, :w, :h, :sort_order, :created_at, 'none', 3, 0)"
), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h, ), {"frame_id": row["frame_id"], "x": x, "y": y, "w": w, "h": h,
"sort_order": max_sort + 1, "created_at": now}) "sort_order": max_sort + 1, "created_at": now})
new_widget_id = result.lastrowid new_widget_id = result.lastrowid
@@ -624,6 +625,195 @@ def _migration_23(conn) -> None:
)) ))
def _migration_24(conn) -> None:
"""New widget type: standalone weather (current/hourly/daily/
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
Lifts the calendar widget's embedded weather strip's underlying
fetch/render building blocks (app/weather/open_meteo.py, the icon-
drawing primitives now in app/weather_render.py) out into a widget
that can be placed/sized on its own -- CalendarWidgetConfig's own
weather_* columns are untouched, still working exactly as before.
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
migration 20/21/23's own comments: create_all always reflects
models.py's CURRENT shape, so replaying the full chain on an old
database could collide with a later migration's ALTER TABLE on this
same table."""
conn.execute(text(
"CREATE TABLE weather_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"mode TEXT NOT NULL DEFAULT 'current', "
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
"city_label TEXT, "
"city_latitude REAL, "
"city_longitude REAL, "
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
"daily_days INTEGER NOT NULL DEFAULT 5, "
"cities TEXT, "
"checked_at REAL NOT NULL DEFAULT 0.0, "
"cached TEXT)"
))
def _migration_25(conn) -> None:
"""New widget type: battery (see models.BatteryWidgetConfig,
app/widgets/battery.py) -- shows the frame's own last-reported
battery level. No live upstream to poll and nothing to cache: unlike
every other widget type added since migration 20, the content is
frame-level state (Frame.battery_percent/battery_as_of) that already
existed before this widget did, so the only new column is a display
mode.
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
migration 20/21/23/24's own comments: create_all always reflects
models.py's CURRENT shape, so replaying the full chain on an old
database could collide with a later migration's ALTER TABLE on this
same table."""
conn.execute(text(
"CREATE TABLE battery_widget_configs ("
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
"mode TEXT NOT NULL DEFAULT 'detailed')"
))
def _migration_26(conn) -> None:
"""Per-widget border (see models.Widget.border_style/border_thickness/
border_color_index, image_pipeline.draw_widget_border) -- a shared
property on the widgets table itself, not a per-type config table,
since every widget type can have one regardless of widget_type.
border_style defaults to 'none' so existing widgets keep rendering
exactly as before until someone opts in via a widget's dialog.
Guarded per-column (unlike every earlier ALTER TABLE ADD COLUMN
migration in this file) because widgets is the one table
test_migrations.py's upgrade-path tests deliberately leave un-dropped
across a simulated old-schema_version replay (see those tests' own
comments: it hasn't changed shape since migration 16 created it, so
reusing the fresh-install create_all() copy -- which, unlike this
ALTER, already reflects models.py's current border_* columns -- was
safe up to now). Without the guard, replaying this migration in that
scenario re-adds a column that's already there and SQLite raises
"duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
if "border_style" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_style TEXT NOT NULL DEFAULT 'none'"))
if "border_thickness" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_thickness INTEGER NOT NULL DEFAULT 3"))
if "border_color_index" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0"))
def _migration_27(conn) -> None:
"""Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes
current_asset_id against both the timer-elapsed auto-advance
(photo_queue.get_current) and the advance/back button actions
(app/widgets/photos.py's ACTIONS) until unlocked. Defaults to
unlocked so existing widgets keep rotating exactly as before.
Guarded per-column, same reasoning as migration 26's own comment:
photo_widget_configs isn't touched by test_migrations.py's simulated
pre-widget-system replays (unlike calendar/task/widgets tables those
tests DROP and recreate in an old shape), so it keeps the fresh-
install create_all() copy -- which already has this column -- 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("photo_widget_configs")}
if "locked" not in existing:
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
def _migration_28(conn) -> None:
"""One action per (widget, button) instead of an ordered per-button
list -- button-action editing moved from the frame-level "Button
assignments" card into each widget's own config dialog (see
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
api_widget_config_save). Cross-widget execution order never actually
mattered (each widget's action only touches its own state), so this
only needs to de-dupe down to one row before the new unique index can
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
but deterministically, since which specific extra binding a user's
old list happened to have doesn't matter anymore."""
conn.execute(text(
"DELETE FROM frame_button_actions WHERE id NOT IN "
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
))
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
"ON frame_button_actions (widget_id, button)"
))
def _migration_29(conn) -> None:
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
BACK past hold_duration_ms triggers a frame-wide action instead of
the per-widget one a short press runs. next_hold_action/
back_hold_action are NULL (disabled) by default -- existing frames
get no new button behavior until someone opts in on the
Configuration tab. last_cycled_layout_id tracks where a repeated
"cycle saved layouts" hold should resume from.
Guarded per-column, same reasoning as migration 26/27's own
comments: frames is a table test_migrations.py's pre-widget-system
replay tests leave un-dropped (unlike calendar/task/widget tables
those tests DROP and recreate in an old shape), 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 "hold_duration_ms" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
if "next_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
if "back_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
if "last_cycled_layout_id" not in existing:
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'"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -648,6 +838,14 @@ MIGRATIONS = [
(21, _migration_21), (21, _migration_21),
(22, _migration_22), (22, _migration_22),
(23, _migration_23), (23, _migration_23),
(24, _migration_24),
(25, _migration_25),
(26, _migration_26),
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
(30, _migration_30),
(31, _migration_31),
] ]
@@ -858,26 +1056,6 @@ def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWid
) )
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is
(see app/widgets/ for the actual action registry, built in a later
phase) -- reproduces each mode's exact old button behavior for the
one auto-migrated widget, so upgrading changes nothing about what the
physical buttons do until someone deliberately reassigns them."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons already meant "check now" before this migration (see
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None: def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the """Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type widget system existed to after tasks became their own widget type
@@ -927,7 +1105,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.flush() # assign ids before the FK'd config rows reference them db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id)) db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id)) db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar")) db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget( _maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2 db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
) )
@@ -943,7 +1121,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.add(_calendar_config_from_frame(frame, widget.id)) db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard": elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id)) db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode)) db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar": if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1) _maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
+112 -9
View File
@@ -311,6 +311,33 @@ class Frame(Base):
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0) firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="") firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
# -- hold-for-global-action (see app/global_actions.py) -- holding
# NEXT/BACK past hold_duration_ms triggers a global action instead of
# the per-widget one that a short press runs (models.FrameButtonAction).
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
# own pair of frame-level columns rather than living in that table.
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
# it applied, so repeated holds advance through the list instead of
# re-applying the same one every time. Deliberately not a real FK:
# this is just a resume cursor, not a relationship needing cascade/
# referential integrity -- if that layout's since been deleted or
# renamed away, global_actions.cycle_layout just doesn't find it and
# starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- "now displaying" (see routers/device.py's _record_last_displayed,
# api_frames.py's /now-displaying endpoint) -- exactly what the last
# device-facing render (/frame/image, /frame/advance, /frame/back, or
# a global hold action) actually sent, as an upright PNG, so the web
# UI's header preview can show it frozen alongside a live "up next"
# re-render instead of conflating the two. NULL until a real device
# has fetched at least once.
last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
# -- stats (flattened from the old nested FrameStats) -- # -- stats (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)
@@ -433,7 +460,7 @@ class Widget(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE")) frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather" | "battery"
x: Mapped[int] = mapped_column(Integer) x: Mapped[int] = mapped_column(Integer)
y: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer)
w: Mapped[int] = mapped_column(Integer) w: Mapped[int] = mapped_column(Integer)
@@ -445,6 +472,20 @@ class Widget(Base):
# table needs to match a specific attribute name here. # table needs to match a specific attribute name here.
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[float] = mapped_column(Float, default=time.time) created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Optional decorative border, drawn once around this widget's own
# region (routers/device.py's _render_widgets) regardless of
# widget_type -- a Widget-level property, not a per-type config
# column, since every widget type can have one. See
# image_pipeline.BORDER_STYLES/draw_widget_border. "none" (the
# default) draws nothing, so existing widgets don't suddenly grow a
# border. border_color_index indexes into the frame's palette_rgb
# (0-5, Black/White/Yellow/Red/Blue/Green) rather than storing an
# arbitrary hex -- an exact palette color quantizes with zero
# dithering error, same reasoning as the weather/battery icons'
# exact-panel-ink-RGB fills (see docs/widgets.md).
border_style: Mapped[str] = mapped_column(String, default="none")
border_thickness: Mapped[int] = mapped_column(Integer, default=3)
border_color_index: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),) __table_args__ = (Index("ix_widgets_frame", "frame_id"),)
@@ -469,6 +510,7 @@ class PhotoWidgetConfig(Base):
queue_cursor: Mapped[int] = mapped_column(Integer, default=0) queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
locked: Mapped[bool] = mapped_column(Boolean, default=False)
class CalendarWidgetConfig(Base): class CalendarWidgetConfig(Base):
@@ -553,6 +595,47 @@ class WhiteboardWidgetConfig(Base):
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
class WeatherWidgetConfig(Base):
"""One weather widget's settings + cached-fetch state. Four display
modes (see app/widgets/weather.py): "current" (one city, current
temp + icon), "hourly" (one city, a row of ticks across the day),
"daily" (one city, a multi-day strip), "multi_city" (several cities'
current-day high/low/icon side by side -- the calendar widget's
embedded weather strip, lifted out into its own widget type).
`provider` selects which of app/weather/'s PROVIDERS actually fetches
("open_meteo" | "nws" -- see that package's own module docstring).
`cached`'s shape depends on `mode`: {"temp","category"} for current,
a list of {"time","temp","category"} for hourly, a
{"YYYY-MM-DD": {...}} dict for daily, or a list of
{"label","high","low","category"} for multi_city.
`render_style` picks which renderer draws the widget: "classic" (the
hand-drawn PIL primitives in app/weather_render.py, unchanged
default) or "modern" (app/html_render.py's Jinja2/headless-Chromium
path, "current"/"daily" modes only for now -- see weather.py's
render())."""
__tablename__ = "weather_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
render_style: Mapped[str] = mapped_column(String, default="classic") # classic | modern
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
# Single-location modes only (current/hourly/daily) -- geocoded once
# via weather.geocode_city() when set, same idiom as
# CalendarWidgetConfig.weather_cities' per-entry shape.
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
daily_days: Mapped[int] = mapped_column(Integer, default=5)
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
# same shape as CalendarWidgetConfig.weather_cities.
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
class TextWidgetConfig(Base): class TextWidgetConfig(Base):
"""One text widget's authored content + display settings -- another """One text widget's authored content + display settings -- another
no-live-upstream type like StaticWidgetConfig, just parsed rich text no-live-upstream type like StaticWidgetConfig, just parsed rich text
@@ -608,6 +691,22 @@ class StaticWidgetConfig(Base):
display_mode: Mapped[str] = mapped_column(String, default="crop_fill") display_mode: Mapped[str] = mapped_column(String, default="crop_fill")
class BatteryWidgetConfig(Base):
"""One battery widget's display settings -- another no-live-upstream
type like StaticWidgetConfig/TextWidgetConfig, just showing existing
frame-level state (Frame.battery_percent/battery_as_of, already set
by routers/device.py's frame_battery on every device report) instead
of anything the widget itself fetches or the user authors. `mode`
"compact" is icon + percent only; "detailed" (default) adds the
routers.common.battery_estimate_s time-remaining estimate and the
last report's age."""
__tablename__ = "battery_widget_configs"
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
mode: Mapped[str] = mapped_column(String, default="detailed") # compact | detailed
# widget_type -> its per-type extension table, keyed by widget_id. Used # widget_type -> its per-type extension table, keyed by widget_id. Used
# by db.widget_locked() to resolve the right config row without importing # by db.widget_locked() to resolve the right config row without importing
# app/widgets/'s heavier render/action registry just for this lookup. # app/widgets/'s heavier render/action registry just for this lookup.
@@ -617,21 +716,24 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
"whiteboard": WhiteboardWidgetConfig, "whiteboard": WhiteboardWidgetConfig,
"tasks": TaskWidgetConfig, "tasks": TaskWidgetConfig,
"static": StaticWidgetConfig, "static": StaticWidgetConfig,
"battery": BatteryWidgetConfig,
"text": TextWidgetConfig, "text": TextWidgetConfig,
"weather": WeatherWidgetConfig,
} }
class FrameButtonAction(Base): class FrameButtonAction(Base):
"""One (widget, action) binding for one of a frame's two physical """One (widget, action) binding for one of a frame's two physical
buttons -- e.g. {button: "next", widget_id: <photo widget>, action: buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
"advance"}. A button can have several of these (sort_order gives "advance"}. At most one binding per (widget, button) -- edited from
execution order); on a press, every row for that (frame, button) runs that widget's own config dialog (routers/api_widgets.py's
-- see routers/device.py's frame_advance/frame_back. Deliberately api_widget_config_save), prefilled with a sane default at widget
unconstrained about which widget/action pairs with which button (the creation (app/widgets/default_button_actions). On a press, every
user's own idea for resolving "what does NEXT even mean with several widget's row for that (frame, button) runs -- see routers/device.py's
widgets on screen": let them assign literally anything to either frame_advance/frame_back. sort_order is unused (which widget's action
button, including mismatched combinations, rather than the server runs first never matters: each only touches its own state, and one
guessing a sensible default).""" shared re-render happens after all of them finish) but kept around so
dispatch has a stable, deterministic query order."""
__tablename__ = "frame_button_actions" __tablename__ = "frame_button_actions"
@@ -645,6 +747,7 @@ class FrameButtonAction(Base):
__table_args__ = ( __table_args__ = (
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"), Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
) )
+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))
+9 -2
View File
@@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo
/api/queue), so without this an open browser tab polling overnight /api/queue), so without this an open browser tab polling overnight
would silently advance the current photo on raw elapsed time alone, would silently advance the current photo on raw elapsed time alone,
even though the device itself is correctly asleep through the even though the device itself is correctly asleep through the
window (see main.py's _effective_refresh_interval_s).""" window (see main.py's _effective_refresh_interval_s).
cfg.locked suppresses the elapsed-time trigger the same way
in_quiet_hours does -- a locked widget still needs an initial pick
if it somehow has none (an unconfigured widget just locked, or a
changed album), but once it has a current photo the whole point of
locking is that it stops moving on its own until explicitly
unlocked."""
valid_ids = {a["id"] for a in assets} valid_ids = {a["id"] for a in assets}
needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids
time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s
stale = needs_pick or (time_elapsed and not in_quiet_hours) stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked)
if not stale: if not stale:
return False return False
advance_forced(cfg, assets, frame) advance_forced(cfg, assets, frame)
+51 -92
View File
@@ -22,17 +22,16 @@ import time
import httpx import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from sqlalchemy import select
from sqlalchemy import delete, 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
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 ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, FrameButtonAction, Widget from ..models import BatteryLog, Frame, Widget
from ..widgets import WIDGET_TYPES
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png from .device import render_frame_preview_png
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped") ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
# See app/global_actions.py -- how long NEXT/BACK must be held before the
# device treats it as a hold instead of a short press.
MIN_HOLD_DURATION_MS = 3000
MAX_HOLD_DURATION_MS = 10000
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None: def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's """A widget's x/y/w/h are grid cells relative to the OLD orientation's
@@ -101,6 +105,9 @@ 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),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
frame: Frame = Depends(require_frame_control), frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -120,7 +127,14 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is _reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid.""" visually wrong but literally out of bounds on the new grid.
hold_duration_ms/next_hold_action/back_hold_action configure hold-
for-global-action (see app/global_actions.py) -- a frame-wide
setting, not per-widget, hence living here rather than on
api_widgets.py's per-widget button-actions endpoint. An unrecognized
action value clears the binding rather than erroring, same posture
as this endpoint's other enum-ish fields (orientation, timezone)."""
with frame_locked(db, frame.id) as cfg: with frame_locked(db, frame.id) as cfg:
if name is not None: if name is not None:
cfg.name = name.strip()[:64] or cfg.name cfg.name = name.strip()[:64] or cfg.name
@@ -168,6 +182,12 @@ 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 hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None:
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
if back_hold_action is not None:
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -224,6 +244,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": (
@@ -251,93 +279,23 @@ def api_frame_preview(
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
BUTTONS = ("next", "back") @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
@router.get("/api/frames/{frame_id}/buttons") routers/device.py's _record_last_displayed) -- the frozen "now
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): displaying" half of the header preview pair, as opposed to /preview's
"""Everything the button-assignment UI needs in one call: every always-live "up next" re-render. 404 (not a placeholder image) until
widget on the frame with the actions its type supports (see the device has fetched at least once, so the web UI can show its own
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current empty state instead of a broken image. X-Displayed-At carries the
ordered list of (widget, action) bindings. capture time (unix seconds) for a "N ago" label -- a header, not the
body, since the body is the raw PNG bytes."""
Includes each widget's placement (x/y/w/h) and the frame's grid if frame.last_displayed_image is None:
dimensions -- two widgets of the same type otherwise look identical raise HTTPException(404, "This frame hasn't displayed anything yet")
in the assignment UI's dropdowns (both just say "Photos"); the return Response(
client derives a position label ("top-left" etc.) from this to tell content=frame.last_displayed_image,
them apart, the same way you'd tell them apart by eye on the Layout media_type="image/png",
canvas.""" headers={"X-Displayed-At": str(frame.last_displayed_at)},
cols, rows = grid.grid_dims(frame.orientation) )
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
widget_options = [
{
"id": w.id,
"widget_type": w.widget_type,
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
"actions": [
{"action": action, "label": label}
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
],
}
for w in widgets
]
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
for button in BUTTONS:
rows = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
.order_by(FrameButtonAction.sort_order)
).all()
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
return result
class ButtonActionItem(BaseModel):
widget_id: int
action: str
class ButtonActionsRequest(BaseModel):
actions: list[ButtonActionItem]
@router.put("/api/frames/{frame_id}/buttons/{button}")
def api_buttons_save(
button: str, body: ButtonActionsRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces the whole ordered action list for one button in a single
call -- simpler and more atomic than separate add/remove/reorder
endpoints for what's normally a list of one to a handful of entries,
and the UI always has the full list in hand anyway (see
static/frame_config.js)."""
if button not in BUTTONS:
raise HTTPException(404, "No such button")
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
for item in body.actions:
widget = widgets_by_id.get(item.widget_id)
if widget is None:
raise HTTPException(400, f"No such widget: {item.widget_id}")
module = WIDGET_TYPES.get(widget.widget_type)
if module is None or item.action not in module.ACTIONS:
raise HTTPException(
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
)
with frame_locked(db, frame.id):
db.execute(
delete(FrameButtonAction).where(
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
)
)
for i, item in enumerate(body.actions):
db.add(FrameButtonAction(
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
sort_order=i, created_at=time.time(),
))
db.commit()
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/battery-log") @router.get("/api/frames/{frame_id}/battery-log")
@@ -453,6 +411,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,
} }
+34 -17
View File
@@ -62,6 +62,11 @@ LAYOUT_CONFIG_FIELDS: dict[str, tuple[str, ...]] = {
"static": ("display_mode", "original_filename"), "static": ("display_mode", "original_filename"),
"text": ("content", "font_size", "font_family", "align", "background_color"), "text": ("content", "font_size", "font_family", "align", "background_color"),
"whiteboard": ("user_id", "url"), "whiteboard": ("user_id", "url"),
"battery": ("mode",),
"weather": (
"mode", "provider", "units", "city_label", "city_latitude", "city_longitude",
"hourly_interval_hours", "daily_days", "cities", "render_style",
),
} }
# widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind) # widget_type -> (FrameCalendar|FrameTaskList model, SavedLayoutSource.kind)
@@ -236,25 +241,19 @@ def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(ge
return {"status": "deleted"} return {"status": "deleted"}
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply") def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
def api_layout_apply( """Replaces frame's entire widget arrangement with layout's snapshot
layout_id: int, request: Request, -- every current widget (and its own config/sources/button actions,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db), all ondelete="CASCADE") is deleted first, same "act unconditionally
): on the server, confirm on the client" posture as
"""Replaces this frame's entire widget arrangement with a saved
layout's -- every current widget (and its own config/sources/button
actions, all ondelete="CASCADE") is deleted first, same "act
unconditionally on the server, confirm on the client" posture as
api_widgets.api_widgets_clear. A source whose owning user account api_widgets.api_widgets_clear. A source whose owning user account
(or a whiteboard's user_id) no longer exists is silently dropped (or a whiteboard's user_id) no longer exists is silently dropped
rather than left dangling -- config is JSON, not FK-checked, so rather than left dangling -- config is JSON, not FK-checked, so
nothing enforces that at the storage layer.""" nothing enforces that at the storage layer. Shared by api_layout_apply
user = require_user_api(request, db) (explicit user action) and global_actions.cycle_layout (a hold-
layout = _user_owned_layout(db, layout_id, user) triggered global action, see app/global_actions.py) -- caller is
cols, rows = grid.grid_dims(frame.orientation) responsible for checking the grid-size match first. Returns the
if (layout.cols, layout.rows) != (cols, rows): number of widgets applied."""
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
snapshots = db.scalars( snapshots = db.scalars(
select(SavedLayoutWidget) select(SavedLayoutWidget)
.where(SavedLayoutWidget.saved_layout_id == layout.id) .where(SavedLayoutWidget.saved_layout_id == layout.id)
@@ -317,4 +316,22 @@ def api_layout_apply(
sort_order=action.sort_order, created_at=time.time(), sort_order=action.sort_order, created_at=time.time(),
)) ))
db.commit() db.commit()
return {"status": "applied", "widget_count": len(snapshots)} return len(snapshots)
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
def api_layout_apply(
layout_id: int, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces this frame's entire widget arrangement with a saved
layout's -- see apply_layout_to_frame above for what that actually
does."""
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
cols, rows = grid.grid_dims(frame.orientation)
if (layout.cols, layout.rows) != (cols, rows):
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
widget_count = apply_layout_to_frame(db, frame, layout)
return {"status": "applied", "widget_count": widget_count}
+320 -7
View File
@@ -26,14 +26,18 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
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, widget_locked from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import ( from ..image_pipeline import (
BORDER_STYLES,
DEFAULT_DISPLAY_MODE, DEFAULT_DISPLAY_MODE,
DEFAULT_STATIC_DISPLAY_MODE, DEFAULT_STATIC_DISPLAY_MODE,
DISPLAY_MODES, DISPLAY_MODES,
hex_to_rgb, hex_to_rgb,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
render_preview_png, render_preview_png,
) )
@@ -41,18 +45,21 @@ from ..image_upload import decode_upload
from ..models import ( from ..models import (
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction,
FrameCalendar, FrameCalendar,
FrameTaskList, FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
StaticWidgetConfig, StaticWidgetConfig,
TaskWidgetConfig, TaskWidgetConfig,
TextWidgetConfig, TextWidgetConfig,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS, WIDGET_CONFIG_MODELS,
Widget, Widget,
) )
from ..text_content import has_text, parse_rich_text from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES, default_button_actions
from ..widgets import battery as battery_widget
from ..widgets import text as text_widget from ..widgets import text as text_widget
from .common import ( from .common import (
calendar_sources_for_widget, calendar_sources_for_widget,
@@ -60,6 +67,7 @@ from .common import (
get_or_refresh_calendar_events_for_widget, get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget, get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget, get_or_refresh_weather_for_widget,
get_or_refresh_weather_widget_data,
get_or_refresh_whiteboard_for_widget, get_or_refresh_whiteboard_for_widget,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
@@ -79,9 +87,11 @@ CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
def _widget_dict(w: Widget) -> dict: def _widget_dict(w: Widget, locked: bool = False) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order} "sort_order": w.sort_order, "border_style": w.border_style,
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
"locked": locked}
def require_widget_view( def require_widget_view(
@@ -133,12 +143,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view
widgets = db.scalars( 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()
# Layout canvas needs to know which photo widgets are locked (to draw
# the lock badge) -- a per-type config field, not on Widget itself,
# so it's a separate lookup rather than something _widget_dict can
# read straight off the row it's given.
photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"]
locked_by_widget_id = dict(db.execute(
select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked)
.where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids))
).all()) if photo_widget_ids else {}
return { return {
"orientation": frame.orientation, "orientation": frame.orientation,
"grid": {"cols": cols, "rows": rows}, "grid": {"cols": cols, "rows": rows},
"widget_types": list(WIDGET_TYPES.keys()), "widget_types": list(WIDGET_TYPES.keys()),
"min_footprint": grid.MIN_FOOTPRINT, "min_footprint": grid.MIN_FOOTPRINT,
"widgets": [_widget_dict(w) for w in widgets], "widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets],
"control": { "control": {
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None, "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id, "you": frame.controlled_by_user_id == user.id,
@@ -196,6 +215,7 @@ def api_widget_create(
db.add(widget) db.add(widget)
db.flush() db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id)) db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
db.add_all(default_button_actions(frame.id, widget.id, body.widget_type))
db.commit() db.commit()
return _widget_dict(widget) return _widget_dict(widget)
@@ -223,6 +243,38 @@ def api_widget_move(
return _widget_dict(widget) return _widget_dict(widget)
class WidgetBorderRequest(BaseModel):
border_style: str
border_thickness: int
border_color_index: int
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/border")
def api_widget_border(
body: WidgetBorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's optional border -- a shared Widget-level
property (see models.Widget), not a per-type config field, since
every widget type can have one regardless of widget_type. Its own
endpoint (not folded into api_widget_config_save) for the same
reason: that endpoint's per-type dispatch is keyed on a config row
via widget_locked, and border fields live on Widget itself, not any
per-type config table."""
frame, widget = frame_widget
if body.border_style not in BORDER_STYLES:
raise HTTPException(400, f"border_style must be one of {BORDER_STYLES}")
if not (0 <= body.border_color_index < len(PALETTE_LABELS)):
raise HTTPException(400, "border_color_index must be 0-5 (a panel palette color)")
thickness = max(MIN_BORDER_THICKNESS, min(MAX_BORDER_THICKNESS, body.border_thickness))
with frame_locked(db, frame.id):
widget.border_style = body.border_style
widget.border_thickness = thickness
widget.border_color_index = body.border_color_index
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}") @router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
def api_widget_delete( def api_widget_delete(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db) widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
@@ -283,6 +335,15 @@ def api_widget_config_save(
text_font_family: str | None = Form(None), text_font_family: 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_mode: str | None = Form(None),
weather_provider: str | None = Form(None),
weather_units: str | None = Form(None),
weather_hourly_interval_hours: int | None = Form(None),
weather_daily_days: int | None = Form(None),
weather_render_style: str | None = Form(None),
# battery
battery_mode: str | None = Form(None),
): ):
"""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
@@ -379,11 +440,95 @@ 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"
) )
elif widget.widget_type == "weather":
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
if weather_mode != wcfg.mode:
# A stale cache is a different shape under a
# different mode (a single-temp dict vs. an hourly
# list vs. a daily dict vs. a city list) -- clear it
# outright (not just force a refetch attempt) so a
# get_or_refresh_weather_widget_data call that happens
# to fail on the very first fetch under the new mode
# doesn't fall back to the old mode's incompatible
# cached shape.
wcfg.checked_at = 0.0
wcfg.cached = None
wcfg.mode = weather_mode
if weather_provider is not None and weather_provider in weather.PROVIDERS:
if weather_provider != wcfg.provider:
wcfg.checked_at = 0.0
wcfg.provider = weather_provider
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
if weather_units != wcfg.units:
# Cached temps are in the old unit -- force a refetch
# rather than showing stale numbers under a new unit
# label (same idiom as calendar_weather_units above).
wcfg.checked_at = 0.0
wcfg.units = weather_units
if weather_hourly_interval_hours is not None:
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
if weather_daily_days is not None:
wcfg.daily_days = max(1, min(14, weather_daily_days))
if weather_render_style is not None and weather_render_style in ("classic", "modern"):
wcfg.render_style = weather_render_style
elif widget.widget_type == "battery":
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
if battery_mode is not None:
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
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"}
class WidgetButtonActionsRequest(BaseModel):
next_button_action: str
back_button_action: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/button-actions")
def api_widget_button_actions(
body: WidgetButtonActionsRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's NEXT/BACK button bindings (models.FrameButtonAction)
-- its own endpoint, not folded into api_widget_config_save, same
reasoning as api_widget_border above: these rows live in their own
table, not this widget's per-type config table. Replaces the old
frame-level "Button assignments" card (routers/api_frames.py's
api_buttons_get/api_buttons_save, now removed) -- each widget's own
dialog edits its own binding directly, prefilled at widget-creation
time with a sane default (see widgets.default_button_actions).
An empty string clears the binding for that button. Unlike
api_widget_config_save's silent-ignore-unrecognized-value posture,
a value outside this widget type's own ACTIONS is a 400 -- this
request body is specifically about button actions, so a bad value
here is a real client bug worth surfacing, not a stray field to
shrug off."""
frame, widget = frame_widget
valid_actions = set(WIDGET_TYPES[widget.widget_type].ACTIONS)
for value in (body.next_button_action, body.back_button_action):
if value != "" and value not in valid_actions:
raise HTTPException(400, f"{widget.widget_type} widgets don't support the {value!r} action")
with frame_locked(db, frame.id):
for button, value in (("next", body.next_button_action), ("back", body.back_button_action)):
existing = db.scalars(
select(FrameButtonAction).where(
FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == button
)
).first()
if value == "":
if existing is not None:
db.delete(existing)
elif existing is not None:
existing.action = value
else:
db.add(FrameButtonAction(frame_id=frame.id, button=button, widget_id=widget.id, action=value))
db.commit()
return {"status": "saved"}
# --- Photos: queue/thumbnail/preview ------------------------------------ # --- Photos: queue/thumbnail/preview ------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue") @router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
@@ -404,6 +549,7 @@ def api_widget_queue(
photo_queue.sync_queue_length(locked_pcfg, assets) photo_queue.sync_queue_length(locked_pcfg, assets)
current_asset_id = locked_pcfg.current_asset_id current_asset_id = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue) queue = list(locked_pcfg.queue)
locked = locked_pcfg.locked
controller_id = locked_frame.controlled_by_user_id controller_id = locked_frame.controlled_by_user_id
controller = ( controller = (
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
@@ -416,6 +562,7 @@ def api_widget_queue(
return { return {
"current": entry(current_asset_id) if current_asset_id else None, "current": entry(current_asset_id) if current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in queue], "upcoming": [entry(asset_id) for asset_id in queue],
"locked": locked,
"control": {"controller": controller, "you": controller_id == user.id}, "control": {"controller": controller, "you": controller_id == user.id},
} }
@@ -485,6 +632,26 @@ def api_widget_queue_remove(
return {"status": "removed"} return {"status": "removed"}
class QueueLockRequest(BaseModel):
locked: bool
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock")
def api_widget_queue_lock(
body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked)
-- while locked, neither the timer-elapsed auto-advance
(photo_queue.get_current) nor the advance/back button actions
(app/widgets/photos.py) change which photo is showing."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.locked = body.locked
return {"status": "saved", "locked": body.locked}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}") @router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
def api_widget_thumbnail( def api_widget_thumbnail(
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
@@ -493,8 +660,8 @@ def api_widget_thumbnail(
"""Scoped to what this widget is actually showing/queuing -- a user """Scoped to what this widget is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails merely linked to view this frame shouldn't be able to pull thumbnails
for arbitrary asset ids in the owner's Immich library, only this for arbitrary asset ids in the owner's Immich library, only this
widget's own curated album. Same rule device.frame_share and widget's own curated album. Same rule manage.manage_thumbnail
manage.manage_thumbnail already enforce.""" already enforces."""
frame, widget = frame_widget frame, widget = frame_widget
_require_widget_type(widget, "photos") _require_widget_type(widget, "photos")
pcfg = db.get(PhotoWidgetConfig, widget.id) pcfg = db.get(PhotoWidgetConfig, widget.id)
@@ -833,6 +1000,133 @@ def api_widget_weather_city_remove(
return {"status": "saved"} return {"status": "saved"}
# --- Weather widget: location/cities/preview -------------------------------
#
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
# "weather-cities") specifically to avoid colliding with the calendar
# widget's own /weather-cities/add|remove route *patterns* above -- both
# are registered against the same {widget_id}-parameterized path shape,
# so a literal name clash there would silently shadow one of them
# regardless of each handler's own _require_widget_type check.
class WeatherLocationRequest(BaseModel):
name: str | None # None clears the location; else a free-text city name to geocode
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
def api_widget_weather_location(
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets (or clears) this weather widget's single configured location
-- the current/hourly/daily modes' one city. A widget-wide display
setting like calendar_view/weather_units, not personal data, hence
require_widget_control rather than the calendar/tasks owner-adds/
anyone-mutes split (there's only ever one location and no per-person
ownership of it)."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
if body.name is None:
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = None
cfg.city_latitude = None
cfg.city_longitude = None
cfg.cached = None
cfg.checked_at = 0.0
return {"status": "saved", "city": None}
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cfg.city_label = city["label"]
cfg.city_latitude = city["latitude"]
cfg.city_longitude = city["longitude"]
cfg.cached = None
cfg.checked_at = 0.0 # pick up the new location promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
def api_widget_weather_widget_city_add(
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""multi_city mode's city list -- same shape/gating as the calendar
widget's own weather-cities/add above, just scoped to this widget's
own WeatherWidgetConfig.cities."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = list(cfg.cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this widget's list")
cities.append(city)
cfg.cities = cities
cfg.checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherWidgetCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
def api_widget_weather_widget_city_remove(
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "weather")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
cfg.cities = cities
if cfg.cached:
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
return {"status": "saved"}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
def api_widget_preview_weather(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch cache a live device render would use, run
through the panel composition/quantization pipeline -- "how it will
look on the frame", same convention as the other preview endpoints.
force=True (the "Refresh now" button) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "weather")
wcfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
if data is None:
if wcfg.mode == "multi_city":
raise HTTPException(400, "No cities added to this widget yet")
raise HTTPException(400, "No location set on this widget yet")
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
# Same local-import reasoning as widgets/weather.py's render().
from .. import html_render
png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "",
)
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")
# --- Static image: upload/preview ----------------------------------------- # --- Static image: upload/preview -----------------------------------------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload") @router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
@@ -903,6 +1197,25 @@ def api_widget_preview_text(
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
# --- Battery: preview --------------------------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery")
def api_widget_preview_battery(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""Unlike every other preview endpoint, there's no "not configured
yet" 400 case -- the content is frame-level state (battery_percent)
that either exists or doesn't, and render() already degrades to a
"No reports yet" placeholder either way, same as a live device
render would."""
frame, widget = frame_widget
_require_widget_type(widget, "battery")
png = battery_widget.render_preview_png(
db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb
)
return Response(content=png, media_type="image/png")
# --- Whiteboard: source/preview ------------------------------------------ # --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel): class WhiteboardSourceRequest(BaseModel):
+139 -13
View File
@@ -31,6 +31,7 @@ from ..models import (
TaskWidgetConfig, TaskWidgetConfig,
User, User,
Widget, Widget,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
) )
@@ -58,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.
@@ -178,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
@@ -188,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
@@ -219,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)):
@@ -449,7 +504,15 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
exif = asset.get("exifInfo") or {} exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif) content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif) content["taken_at"] = _format_taken_at(exif)
content["share_url"] = f"{base}/frame/share/{primary_cfg.current_asset_id}"
# Unlike location/date-taken (a single fixed corner, so tied to the
# one "primary" widget above), the share link covers every photo
# widget's current photo (see manage.manage_share) -- so it only
# needs *some* photo widget to have a current photo, not specifically
# the primary one, and doesn't depend on the EXIF fetch above
# succeeding.
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
panel_w, panel_h = logical_render_size(frame.orientation) panel_w, panel_h = logical_render_size(frame.orientation)
face_labels: list[dict] = [] face_labels: list[dict] = []
@@ -566,6 +629,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
return result return result
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
weather widget (see app/widgets/weather.py) -- reads/writes
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
hourly/daily need a single configured location (city_latitude/
city_longitude); multi_city needs cfg.cities. None if not configured
yet, so render() falls back to a placeholder -- same convention as
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
button) bypasses the throttle entirely.
A single-location mode's fetch failure keeps the last-known cached
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
multi_city fetch fails per-city (like get_or_refresh_weather_for_
widget's calendar-strip counterpart) so one broken city doesn't blank
the others."""
cfg = db.get(WeatherWidgetConfig, widget.id)
if cfg.mode == "multi_city":
if not cfg.cities:
return None
elif cfg.city_latitude is None or cfg.city_longitude is None:
return None
now = time.time()
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
return cfg.cached
if cfg.mode == "multi_city":
previous = {c["label"]: c for c in (cfg.cached or [])}
result = []
for city in cfg.cities:
try:
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
d = next(iter(today.values())) if today else previous.get(city["label"], {})
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
d = previous.get(city["label"], {})
result.append({
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
"category": d.get("category", "cloudy"),
})
else:
try:
if cfg.mode == "current":
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
elif cfg.mode == "hourly":
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
hours=HOURLY_FETCH_HOURS)
else: # "daily"
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
cfg.daily_days)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
return cfg.cached
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
locked_cfg.cached = result
locked_cfg.checked_at = now
return result
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
+159 -63
View File
@@ -14,20 +14,21 @@ from __future__ import annotations
import logging import logging
import time import time
from concurrent.futures import ThreadPoolExecutor
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session 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 ..image_pipeline import logical_render_size, render_panel, render_placeholder from ..global_actions import GLOBAL_ACTIONS
from ..models import BatteryLog, Frame, FrameButtonAction, PhotoWidgetConfig, Widget from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES
from .common import ( from .common import (
BATTERY_HISTORY_MAX, BATTERY_HISTORY_MAX,
@@ -35,9 +36,6 @@ from .common import (
RECHARGE_JUMP_PCT, RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK, RECHARGE_LOOKBACK,
build_manage_content, build_manage_content,
immich_client_for,
immich_creds,
photo_widgets_for_frame,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,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
@@ -63,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(
@@ -71,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],
@@ -79,40 +79,94 @@ 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) and into its own region (see app/grid.py for grid-cell -> pixel math),
hands the results to image_pipeline.render_panel for the single draws that widget's own optional border directly onto its region
shared paste/enhance/overlay/quantize/pack pass. Replaces the old (models.Widget.border_style, a shared per-widget property no
per-mode RENDERERS dict -- a frame can now show several widgets at widget_type module needs to know about) and hands the results to
once instead of exactly one mode owning the whole panel.""" image_pipeline.render_panel for the single shared paste/enhance/
overlay/quantize/pack pass. Replaces the old per-mode RENDERERS
dict -- a frame can now show several widgets at once instead of
exactly one mode owning the whole panel.
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
regions.append(((px, py, pw, ph), img)) ]
for future in futures:
result = future.result()
if result is not None:
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
@@ -130,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:
@@ -181,6 +235,22 @@ def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
) )
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
"""The hold-triggered counterpart to _run_button_actions -- runs
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
Frame.next_hold_action/back_hold_action points to, if any (unset or
unrecognized is a silent no-op, same posture as an unbound short-
press button). See routers/device.py's frame_global_next/back."""
action = frame.next_hold_action if button == "next" else frame.back_hold_action
action_fn = GLOBAL_ACTIONS.get(action) if action else None
if action_fn is None:
return
try:
action_fn(db, frame)
except Exception:
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
@router.get("/frame/config") @router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its """Device-facing settings, polled by the frame alongside its
@@ -206,6 +276,11 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
response = { response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked), "refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None, "firmware_version": locked.firmware_available_version or None,
# Additive key -- old firmware's hand-rolled parser only ever
# extracts the keys it knows about, so this is safe for
# firmware that predates hold-for-global-action (see
# firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms,
} }
# Per-frame token push: only once the device has introduced itself # Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte- # by id (so the response to pure-legacy firmware stays byte-
@@ -220,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)
@@ -237,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")
@@ -252,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")
@@ -263,7 +357,40 @@ 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")
@router.post("/frame/global-next")
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
instead of a short press -- runs Frame.next_hold_action (see
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
returns the whole panel same as /frame/advance. A separate endpoint
from /frame/advance (not a query flag on it) so the frozen short-press
path's behavior never has to account for the long-press case -- see
firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content, 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")
@router.post("/frame/global-back")
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content, 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")
@@ -356,34 +483,3 @@ def frame_firmware(frame: Frame = Depends(require_device)):
if not path.exists(): if not path.exists():
raise HTTPException(404, "No firmware uploaded") raise HTTPException(404, "No firmware uploaded")
return FileResponse(path, media_type="application/octet-stream") return FileResponse(path, media_type="application/octet-stream")
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Creates a 30-minute public Immich share link for asset_id and
redirects to it -- what the manage overlay's bottom-left QR code
points to. The link is created lazily, when this actually gets hit
(i.e. when someone scans it), not when the manage button was
pressed, so the 30-minute window starts when it's actually used.
Also scoped to the photo currently showing or queued on one of THIS
frame's own photo widgets -- not any arbitrary Immich asset id -- as
a second layer even a leaked token wouldn't bypass."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
photo_widgets = photo_widgets_for_frame(db, frame)
showing_or_queued = any(
asset_id == cfg.current_asset_id or asset_id in cfg.queue
for cfg in (db.get(PhotoWidgetConfig, w.id) for w in photo_widgets)
)
if not showing_or_queued:
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_id, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
+65 -7
View File
@@ -17,19 +17,28 @@ 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 ..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
from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import ( from ..image_pipeline import (
BORDER_STYLES,
BORDER_STYLE_LABELS,
CALIBRATED_SPECTRA6_RGB,
DEFAULT_PALETTE_RGB, DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS, DISPLAY_MODE_LABELS,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS, PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
palette_to_hex, palette_to_hex,
) )
from ..models import ( from ..models import (
BatteryWidgetConfig,
CalendarWidgetConfig, CalendarWidgetConfig,
Frame, Frame,
FrameButtonAction,
FrameCalendar, FrameCalendar,
FrameTaskList, FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
@@ -38,10 +47,12 @@ from ..models import (
TextWidgetConfig, TextWidgetConfig,
User, User,
UserFrame, UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
Widget, Widget,
) )
from ..quiet_hours import ALL_TIMEZONES from ..quiet_hours import ALL_TIMEZONES
from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget from ..widgets import text as text_widget
from .common import shell_context, widget_of_type from .common import shell_context, widget_of_type
@@ -79,8 +90,10 @@ 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,
) )
@@ -211,11 +224,44 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
if widget is None or widget.frame_id != frame.id: if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget") raise HTTPException(404, "No such widget")
# Every dialog includes the shared "Border" card (_widget_border_fields.html,
# models.Widget.border_style/border_thickness/border_color_index) --
# a Widget-level property, not a per-type config field, so this
# context is the same regardless of widget_type.
border_ctx = {
"border_styles": BORDER_STYLES,
"border_style_labels": BORDER_STYLE_LABELS,
"border_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
"min_border_thickness": MIN_BORDER_THICKNESS,
"max_border_thickness": MAX_BORDER_THICKNESS,
}
# Every dialog also includes the shared "Button actions" card
# (_widget_button_fields.html) if this widget type supports any --
# empty for tasks/static/text/battery, so the card renders nothing
# for those. Bindings, not the type's own config, so this lives in
# FrameButtonAction (see models.py), same reasoning as border_ctx
# above for why it's a separate card/endpoint from the type-specific
# form.
bindings = {
row.button: row.action
for row in db.scalars(
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
)
}
button_ctx = {
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
"next_button_action": bindings.get("next", ""),
"back_button_action": bindings.get("back", ""),
}
if widget.widget_type == "photos": if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id) photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", { return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg, "request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
"display_mode_labels": DISPLAY_MODE_LABELS, "display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
}) })
if widget.widget_type == "calendar": if widget.widget_type == "calendar":
@@ -226,8 +272,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id), "calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS, "week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS, "calendar_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB, **border_ctx, **button_ctx,
"palette_to_hex": palette_to_hex,
}) })
if widget.widget_type == "tasks": if widget.widget_type == "tasks":
@@ -236,8 +281,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user, "request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id), "task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS, "task_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB, **border_ctx, **button_ctx,
"palette_to_hex": palette_to_hex,
}) })
if widget.widget_type == "static": if widget.widget_type == "static":
@@ -245,13 +289,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_static.html", { return templates.TemplateResponse("_widget_dialog_static.html", {
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg, "request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES}, "display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
**border_ctx, **button_ctx,
}) })
if widget.widget_type == "text": if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id) text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", { return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg, "request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
"text_font_families": text_widget.FONT_FAMILIES, "text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
}) })
if widget.widget_type == "whiteboard": if widget.widget_type == "whiteboard":
@@ -262,7 +307,20 @@ 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_source": _whiteboard_source_info(db, whiteboard_cfg), "whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, "viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
})
if widget.widget_type == "weather":
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_weather.html", {
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "battery":
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_battery.html", {
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
}) })
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}") raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+43 -3
View File
@@ -11,7 +11,7 @@ import logging
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@@ -19,8 +19,14 @@ from sqlalchemy.orm import Session
from .. import photo_queue, quiet_hours from .. import photo_queue, quiet_hours
from ..db import get_db, widget_locked from ..db import get_db, widget_locked
from ..models import Frame from ..models import Frame, PhotoWidgetConfig
from .common import immich_client_for, list_assets, photo_widget_config_or_404 from .common import (
immich_client_for,
immich_creds,
list_assets,
photo_widget_config_or_404,
photo_widgets_for_frame,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -120,3 +126,37 @@ def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage), db:
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type) return Response(content=content, media_type=content_type)
@router.get("/frame/share/{manage_token}")
def manage_share(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
"""Creates a 30-minute public Immich share link covering every photo
widget's currently-displayed asset on this frame, and redirects to it
-- what the manage overlay's bottom-left QR code points to. Lazily
created (only when someone actually scans it, not when the manage
button was pressed), so the 30-minute window starts at actual use.
Keyed on this frame's own manage_token, like the rest of this router,
rather than device credentials -- a phone scanning a QR code has no
way to supply the device's ?id=/?token=, which is why this used to
silently fall back to whichever frame happened to still carry the
legacy migration token instead of the frame that was actually
scanned."""
photo_widgets = photo_widgets_for_frame(db, frame)
asset_ids: list[str] = []
for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id)
if cfg.current_asset_id and cfg.current_asset_id not in asset_ids:
asset_ids.append(cfg.current_asset_id)
if not asset_ids:
raise HTTPException(404, "No photos currently showing on this frame")
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
client = immich_client_for(frame)
try:
share_url = client.create_share_link(asset_ids, expires_in_s=1800)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
+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,
+10 -1
View File
@@ -58,11 +58,20 @@
} }
})(); })();
// 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 = {
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
static: 'Static image', text: 'Text', static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery',
}; };
function showStatus(ok, message) { function showStatus(ok, message) {
+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) {
+45 -201
View File
@@ -59,6 +59,31 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
} }
}); });
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
// setting, not per-widget, so it shares api_config_save/the /config
// endpoint rather than getting its own -- just a separate card/form on
// this page for a distinct-enough concern.
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
const body = new URLSearchParams({
hold_duration_ms: String(seconds * 1000),
next_hold_action: document.getElementById('next_hold_action').value,
back_hold_action: document.getElementById('back_hold_action').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.');
} catch (err) {
showStatus(false, err.message);
}
});
async function takeControl() { async function takeControl() {
try { try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
@@ -190,6 +215,18 @@ document.getElementById('palette-reset').addEventListener('click', () => {
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' }); savePalette({ 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();
window.CALIBRATED_SPECTRA6_HEX.forEach((hex, i) => {
inputs[i].value = hex;
syncPaletteFromHex(i);
});
});
// ---- Preview: current photo vs. how it renders with saved settings ---- // ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API, // Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/ // set by the template) rather than window.FRAME_API -- palette/color/
@@ -314,8 +351,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';
@@ -363,203 +407,3 @@ loadFirmwareCheck();
// cheap either way. // cheap either way.
setInterval(loadFirmwareCheck, 60000); setInterval(loadFirmwareCheck, 60000);
// --- Button assignments -----------------------------------------------
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
// api_buttons_get. Each button's list is edited client-side (add/
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
// add/remove endpoints for what's normally a handful of entries, and
// this file already has the full list in hand after any edit.
let buttonsData = null;
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
const BUTTONS = ['next', 'back'];
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
// frame's grid dims -- the same rough position you'd read off the
// Layout canvas by eye, used to tell apart two widgets of the same type
// that would otherwise both just say "Photos".
function widgetPositionLabel(w, grid) {
const cx = w.x + w.w / 2;
const cy = w.y + w.h / 2;
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
if (!horiz && !vert) return 'center';
if (!vert) return horiz;
if (!horiz) return vert;
return `${vert}-${horiz}`;
}
// A single widget of a given type keeps the plain type name ("Photos")
// -- the common case, no need to clutter it. Only widgets sharing a
// type with another widget on the same frame get a number + position
// suffix, numbered in reading order (top-to-bottom, left-to-right).
function buildWidgetNames(widgets, grid) {
const byType = {};
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
const names = {};
Object.values(byType).forEach((group) => {
if (group.length === 1) {
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
return;
}
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
ordered.forEach((w, i) => {
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
});
});
return names;
}
function widgetActionLabel(widgetId, action) {
const w = buttonsData.widgets.find((w) => w.id === widgetId);
if (!w) return `(deleted widget): ${action}`;
const found = w.actions.find((a) => a.action === action);
const actionLabel = found ? found.label : action;
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
}
function renderButtonList(button) {
const list = document.getElementById(`button-actions-${button}`);
const rows = buttonsData[button];
list.innerHTML = '';
if (!rows.length) {
list.innerHTML = '<li class="sub">Nothing assigned -- this button wont do anything.</li>';
return;
}
rows.forEach((row, i) => {
const li = document.createElement('li');
li.className = 'button-action-row';
const span = document.createElement('span');
span.textContent = widgetActionLabel(row.widget_id, row.action);
const controls = document.createElement('span');
controls.className = 'button-action-controls';
const up = document.createElement('button');
up.type = 'button';
up.className = 'icon-btn';
up.textContent = '↑';
up.title = 'Move up';
up.disabled = i === 0;
up.addEventListener('click', () => moveButtonAction(button, i, -1));
const down = document.createElement('button');
down.type = 'button';
down.className = 'icon-btn';
down.textContent = '↓';
down.title = 'Move down';
down.disabled = i === rows.length - 1;
down.addEventListener('click', () => moveButtonAction(button, i, 1));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.textContent = '×';
remove.title = 'Remove';
remove.addEventListener('click', () => removeButtonAction(button, i));
controls.appendChild(up);
controls.appendChild(down);
controls.appendChild(remove);
li.appendChild(span);
li.appendChild(controls);
list.appendChild(li);
});
}
function populateActionSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
actionSel.innerHTML = '';
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
if (!w) return;
w.actions.forEach((a) => {
const opt = document.createElement('option');
opt.value = a.action;
opt.textContent = a.label;
actionSel.appendChild(opt);
});
}
function populateWidgetSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
widgetSel.innerHTML = '';
buttonsData.widgets.forEach((w) => {
const opt = document.createElement('option');
opt.value = w.id;
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
widgetSel.appendChild(opt);
});
populateActionSelect(button);
}
async function saveButtonActions(button) {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button assignments saved.');
} catch (e) {
showStatus(false, e.message);
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
}
}
function moveButtonAction(button, index, delta) {
const rows = buttonsData[button];
const target = index + delta;
if (target < 0 || target >= rows.length) return;
[rows[index], rows[target]] = [rows[target], rows[index]];
renderButtonList(button);
saveButtonActions(button);
}
function removeButtonAction(button, index) {
buttonsData[button].splice(index, 1);
renderButtonList(button);
saveButtonActions(button);
}
function addButtonAction(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
if (!widgetSel.value || !actionSel.value) return;
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
renderButtonList(button);
saveButtonActions(button);
}
async function loadButtons() {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
if (!resp.ok) throw new Error(await apiError(resp));
buttonsData = await resp.json();
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
document.getElementById('button-assign-groups').style.display =
buttonsData.widgets.length ? '' : 'none';
document.getElementById('button-assign-empty-hint').style.display =
buttonsData.widgets.length ? 'none' : '';
BUTTONS.forEach((button) => {
renderButtonList(button);
populateWidgetSelect(button);
});
} catch (e) {
showStatus(false, e.message);
}
}
BUTTONS.forEach((button) => {
document.getElementById(`button-add-widget-${button}`)
.addEventListener('change', () => populateActionSelect(button));
document.getElementById(`button-add-${button}`)
.addEventListener('click', () => addButtonAction(button));
});
loadButtons();
+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);
}
}
})(); })();
+13 -2
View File
@@ -210,6 +210,14 @@ function renderCanvas() {
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type; label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label); box.appendChild(label);
if (widget.locked) {
const lockBadge = document.createElement('span');
lockBadge.className = 'widget-box-lock-badge';
lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock
lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings';
box.appendChild(lockBadge);
}
const settingsBtn = document.createElement('button'); const settingsBtn = document.createElement('button');
settingsBtn.type = 'button'; settingsBtn.type = 'button';
settingsBtn.className = 'widget-box-settings'; settingsBtn.className = 'widget-box-settings';
@@ -286,11 +294,13 @@ window.addEventListener('resize', () => {
// icon was clicked). // icon was clicked).
const DIALOG_INIT = { const DIALOG_INIT = {
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
battery: initBatteryDialog,
}; };
const DIALOG_CLOSE = { const DIALOG_CLOSE = {
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
battery: closeBatteryDialog,
}; };
let openDialogWidgetType = null; let openDialogWidgetType = null;
@@ -341,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => {
openDialogWidgetType = null; openDialogWidgetType = null;
window.FRAME_API = window.FRAME_BASE_API; window.FRAME_API = window.FRAME_BASE_API;
document.getElementById('widget-dialog-body').innerHTML = ''; document.getElementById('widget-dialog-body').innerHTML = '';
loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge)
}); });
loadWidgets(); loadWidgets();
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)));
+82 -19
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; }
@@ -265,22 +313,6 @@ input:focus, select:focus {
box-shadow: 0 0 0 3px var(--focus-ring); box-shadow: 0 0 0 3px var(--focus-ring);
} }
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.button-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
}
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; } .saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; } .saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } .saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
@@ -308,8 +340,6 @@ input:focus, select:focus {
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; } .saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.saved-layout-controls .btn-inline { margin: 0; } .saved-layout-controls .btn-inline { margin: 0; }
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; } .saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.button-action-add select { width: auto; margin-top: 0; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; } .checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; } .checkbox-row input { width: auto; margin-top: 0; }
@@ -364,6 +394,7 @@ input:focus, select:focus {
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } .calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
.calendar-row { flex-wrap: wrap; } .calendar-row { flex-wrap: wrap; }
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; } .calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
.border-color-picker { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.color-swatch { .color-swatch {
width: 20px; height: 20px; padding: 0; margin: 0; width: 20px; height: 20px; padding: 0; margin: 0;
border: 2px solid var(--border); border-radius: 5px; border: 2px solid var(--border); border-radius: 5px;
@@ -476,6 +507,20 @@ button.secondary:hover { background: var(--surface-alt); }
.widget-box-remove { right: 4px; } .widget-box-remove { right: 4px; }
.widget-box-settings { right: 28px; } .widget-box-settings { right: 28px; }
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); } .widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
.widget-box-lock-badge {
position: absolute;
bottom: 4px;
left: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--overlay);
color: #fff;
font-size: 11px;
line-height: 20px;
text-align: center;
pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */
}
.widget-box-resize-handle { .widget-box-resize-handle {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -722,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);
@@ -736,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;
@@ -0,0 +1,39 @@
// Battery widget dialog: display-mode setting and the rendered preview.
// Not a page-load script -- frame_layout.js fetches this widget's dialog
// HTML fragment, injects it into the shared <dialog>, points
// window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initBatteryDialog().
function loadBatteryPreview() {
document.getElementById('battery-preview').src = `${window.FRAME_API}/preview/battery?_=${Date.now()}`;
}
function initBatteryDialog() {
document.getElementById('battery-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
battery_mode: document.getElementById('battery_mode').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.');
loadBatteryPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
loadBatteryPreview();
initBorderFields();
initButtonActionFields();
}
function closeBatteryDialog() {
// Nothing to tear down -- no poll interval, no upload state.
}
+48
View File
@@ -0,0 +1,48 @@
// Shared "Border" card (models.Widget.border_style/border_thickness/
// border_color_index, _widget_border_fields.html) -- present on every
// widget type's dialog regardless of widget_type, so this is one shared
// init function each widget_dialog_<type>.js's init<Type>Dialog() calls,
// rather than 8 copies of the same slider/swatch/save wiring. Not a
// page-load script by itself -- frame_layout.js loads it unconditionally
// (like every other widget_dialog_*.js) since which dialog is open, and
// therefore which init<Type>Dialog() calls initBorderFields(), varies.
function initBorderFields() {
const styleSelect = document.getElementById('border_style');
if (!styleSelect) return; // dialog fragment didn't render the border card -- shouldn't happen
const thickness = document.getElementById('border_thickness');
const thicknessValue = document.getElementById('border_thickness_value');
thickness.addEventListener('input', (e) => {
thicknessValue.textContent = `${e.target.value}px`;
});
const colorIndexInput = document.getElementById('border_color_index');
document.querySelectorAll('#border-color-picker .color-swatch').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('#border-color-picker .color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
colorIndexInput.value = btn.dataset.index;
});
});
document.getElementById('border-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = JSON.stringify({
border_style: styleSelect.value,
border_thickness: Number(thickness.value),
border_color_index: Number(colorIndexInput.value),
});
try {
const resp = await fetch(`${window.FRAME_API}/border`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Border saved.');
} catch (err) {
showStatus(false, err.message);
}
});
}
@@ -0,0 +1,33 @@
// Shared "Button actions" card (models.FrameButtonAction,
// _widget_button_fields.html) -- present on every widget type's dialog
// that supports any actions at all (the card renders nothing for
// tasks/static/text/battery, whose ACTIONS is empty), so this is one
// shared init function each widget_dialog_<type>.js's init<Type>Dialog()
// calls, rather than N copies of the same save wiring -- same pattern as
// widget_dialog_border.js. Not a page-load script by itself --
// frame_layout.js loads it unconditionally (like every other
// widget_dialog_*.js) since which dialog is open varies.
function initButtonActionFields() {
const form = document.getElementById('button-actions-form');
if (!form) return; // this widget type has no actions -- card didn't render
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = JSON.stringify({
next_button_action: document.getElementById('next_button_action').value,
back_button_action: document.getElementById('back_button_action').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/button-actions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button actions saved.');
} catch (err) {
showStatus(false, err.message);
}
});
}
@@ -197,6 +197,8 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview(); loadCalendarPreview();
initBorderFields();
initButtonActionFields();
} }
function closeCalendarDialog() { function closeCalendarDialog() {
+36
View File
@@ -8,6 +8,33 @@
// FRAME_API + a global loadQueue()" contract queue.js has always had. // FRAME_API + a global loadQueue()" contract queue.js has always had.
let photosPollTimer = null; let photosPollTimer = null;
let photoLocked = false;
let photoHasCurrent = false;
function renderLockButton() {
const btn = document.getElementById('lock-photo-btn');
if (!btn) return;
btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo';
btn.classList.toggle('active', photoLocked);
btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock
}
async function toggleLock() {
const next = !photoLocked;
try {
const resp = await fetch(`${window.FRAME_API}/lock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locked: next }),
});
if (!resp.ok) throw new Error(await apiError(resp));
photoLocked = next;
renderLockButton();
showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.');
} catch (e) {
showStatus(false, e.message);
}
}
async function loadQueue() { async function loadQueue() {
if (dragState) { if (dragState) {
@@ -21,9 +48,14 @@ async function loadQueue() {
currentEl.innerHTML = currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>'; '<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]); renderUpcoming([]);
photoHasCurrent = false;
renderLockButton();
return; return;
} }
const data = await resp.json(); const data = await resp.json();
photoLocked = !!data.locked;
photoHasCurrent = !!data.current;
renderLockButton();
currentEl.innerHTML = ''; currentEl.innerHTML = '';
if (data.current) { if (data.current) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@@ -109,10 +141,14 @@ function initPhotosDialog() {
} }
}); });
document.getElementById('lock-photo-btn').addEventListener('click', toggleLock);
loadQueue(); loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited // Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag. // from elsewhere) without a manual refresh. Skipped mid-drag.
photosPollTimer = setInterval(loadQueue, 10000); photosPollTimer = setInterval(loadQueue, 10000);
initBorderFields();
initButtonActionFields();
} }
function closePhotosDialog() { function closePhotosDialog() {
@@ -55,6 +55,8 @@ function initStaticDialog() {
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview); document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
loadStaticPreview(); loadStaticPreview();
initBorderFields();
initButtonActionFields();
} }
function closeStaticDialog() { function closeStaticDialog() {
+2
View File
@@ -88,6 +88,8 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview(); loadTasksPreview();
initBorderFields();
initButtonActionFields();
} }
function closeTasksDialog() { function closeTasksDialog() {
+2
View File
@@ -130,6 +130,8 @@ function initTextDialog() {
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview); document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview(); loadTextPreview();
initBorderFields();
initButtonActionFields();
} }
function closeTextDialog() { function closeTextDialog() {
+167
View File
@@ -0,0 +1,167 @@
// Weather widget dialog: mode/provider/units settings, location (single-
// city modes) or a city list (multi_city mode), and the rendered
// preview. Not a page-load script -- frame_layout.js fetches this
// widget's dialog HTML fragment, injects it into the shared <dialog>,
// points window.FRAME_API at this specific widget
// (/api/frames/{id}/widgets/{widget_id}), then calls initWeatherDialog().
// Only one of "Location" (current/hourly/daily -- one city) or "Cities"
// (multi_city -- a list) is ever relevant at a time; the interval/days
// rows are each specific to one mode too.
function updateWeatherFieldVisibility() {
const mode = document.getElementById('weather_mode').value;
document.getElementById('weather-hourly-interval-row').style.display = mode === 'hourly' ? '' : '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-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) {
const list = document.getElementById('weather-widget-city-list');
const empty = document.getElementById('weather-widget-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-widget-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherWidgetCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherWidgetCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-widget-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
}
function loadWeatherPreview(force) {
const suffix = force ? '&force=1' : '';
document.getElementById('weather-preview').src = `${window.FRAME_API}/preview/weather?_=${Date.now()}${suffix}`;
}
function initWeatherDialog() {
document.getElementById('weather_mode').addEventListener('change', updateWeatherFieldVisibility);
updateWeatherFieldVisibility();
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
weather_mode: document.getElementById('weather_mode').value,
weather_provider: document.getElementById('weather_provider').value,
weather_units: document.getElementById('weather_units').value,
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
weather_daily_days: document.getElementById('weather_daily_days').value,
weather_render_style: document.getElementById('weather_render_style').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('weather-location-set').addEventListener('click', async () => {
const input = document.getElementById('weather-location-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
document.getElementById('weather-location-current').textContent = `Currently: ${data.city.label}`;
input.value = '';
showStatus(true, `Location set to ${data.city.label}.`);
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('weather-location-clear').addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
document.getElementById('weather-location-current').textContent = 'No location set yet.';
showStatus(true, 'Location cleared.');
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.querySelectorAll('.weather-widget-city-remove').forEach((el) => el.addEventListener('click', removeWeatherWidgetCity));
document.getElementById('weather-widget-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-widget-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-widget-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherWidgetCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadWeatherPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
loadWeatherPreview(false);
initBorderFields();
initButtonActionFields();
}
function closeWeatherDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
@@ -96,6 +96,8 @@ function initWhiteboardDialog() {
// whiteboard's `force` param). // whiteboard's `force` param).
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true)); document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false); loadWhiteboardPreview(false);
initBorderFields();
initButtonActionFields();
// --- file picker (Browse...) --- // --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle'); const browseToggle = document.getElementById('whiteboard-browse-toggle');
+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>
@@ -0,0 +1,32 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Border</h2>
<p class="sub">An optional border drawn around this widget's own box --
"None" (the default) draws nothing. Shows on the frame's actual live
view, not in the standalone preview below.</p>
<form id="border-config-form">
<label>Style
<select id="border_style">
{% for style in border_styles %}
<option value="{{ style }}" {% if widget.border_style == style %}selected{% endif %}>{{ border_style_labels[style] }}</option>
{% endfor %}
</select>
</label>
<label>Thickness
<input type="range" id="border_thickness" min="{{ min_border_thickness }}" max="{{ max_border_thickness }}" step="1"
value="{{ widget.border_thickness }}">
<span class="slider-value" id="border_thickness_value">{{ widget.border_thickness }}px</span>
</label>
<label>Color</label>
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
<div class="border-color-picker" id="border-color-picker">
{% for label in border_color_labels %}
<button type="button" class="color-swatch {% if widget.border_color_index == loop.index0 %}selected{% endif %}"
data-index="{{ loop.index0 }}" title="{{ label }}"
style="background-color: {{ current_hex[loop.index0] }};"></button>
{% endfor %}
</div>
<input type="hidden" id="border_color_index" value="{{ widget.border_color_index }}">
<button type="submit">Save</button>
</form>
</section>
@@ -0,0 +1,27 @@
{% if button_action_labels %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Button actions</h2>
<p class="sub">What the frame's physical NEXT/BACK buttons do to this
widget. Every widget on the frame runs its own binding when a
button is pressed -- this only affects this one.</p>
<form id="button-actions-form">
<label>Next button
<select id="next_button_action">
<option value="" {% if not next_button_action %}selected{% endif %}>(none)</option>
{% for action, label in button_action_labels.items() %}
<option value="{{ action }}" {% if next_button_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Back button
<select id="back_button_action">
<option value="" {% if not back_button_action %}selected{% endif %}>(none)</option>
{% for action, label in button_action_labels.items() %}
<option value="{{ action }}" {% if back_button_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% endif %}
@@ -0,0 +1,27 @@
<h2 class="dialog-title">Battery widget</h2>
<section class="card">
<h2 class="card-title">Settings</h2>
<p class="sub">Shows this frame's own last-reported battery level --
nothing to configure beyond how much detail to show.</p>
<form id="battery-config-form">
<label>Display mode
<select id="battery_mode">
<option value="compact" {% if battery_cfg and battery_cfg.mode == 'compact' %}selected{% endif %}>Compact (icon + percent only)</option>
<option value="detailed" {% if not battery_cfg or battery_cfg.mode == 'detailed' %}selected{% endif %}>Detailed (+ estimated time left, last report)</option>
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="battery-preview" alt="Battery widget preview">
<button type="button" class="secondary" id="battery-preview-refresh">Refresh now</button>
</section>
@@ -127,6 +127,10 @@
</div> </div>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -41,9 +41,17 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2> <h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div> <div id="current-thumb"><p class="sub">Loading...</p></div>
<button type="button" class="secondary" id="lock-photo-btn" style="margin-top: 8px;">Lock this photo</button>
<p class="sub" style="margin-top: 4px;">While locked, this photo stays
on screen -- the refresh timer and the next/back buttons won't
change it until you unlock it.</p>
</section> </section>
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
@@ -36,6 +36,10 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -59,6 +59,10 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -47,6 +47,10 @@
</form> </form>
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
@@ -0,0 +1,93 @@
<h2 class="dialog-title">Weather widget</h2>
<section class="card">
<h2 class="card-title">Display</h2>
<form id="weather-config-form">
<label>Mode
<select id="weather_mode">
<option value="current" {% if weather_cfg.mode == "current" %}selected{% endif %}>Current conditions</option>
<option value="hourly" {% if weather_cfg.mode == "hourly" %}selected{% endif %}>Hourly forecast</option>
<option value="daily" {% if weather_cfg.mode == "daily" %}selected{% endif %}>Multi-day forecast</option>
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
</select>
</label>
<div id="weather-render-style-row">
<label>Render style
<select id="weather_render_style">
<option value="classic" {% if weather_cfg.render_style == "classic" %}selected{% endif %}>Classic (hand-drawn icons)</option>
<option value="modern" {% if weather_cfg.render_style == "modern" %}selected{% endif %}>Modern (experimental, current/daily only)</option>
</select>
</label>
</div>
<label>Weather source
<select id="weather_provider">
{% for value, label in weather_provider_labels.items() %}
<option value="{{ value }}" {% if weather_cfg.provider == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations; Environment Canada only covers Canadian locations.</p>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if weather_cfg.units == "celsius" %}selected{% endif %}>Celsius</option>
</select>
</label>
<div id="weather-hourly-interval-row">
<label>Hours between ticks
<select id="weather_hourly_interval_hours">
{% for hours in (3, 4, 6, 12) %}
<option value="{{ hours }}" {% if weather_cfg.hourly_interval_hours == hours %}selected{% endif %}>{{ hours }}</option>
{% endfor %}
</select>
</label>
</div>
<div id="weather-daily-days-row">
<label>Days to show
<input type="number" id="weather_daily_days" min="1" max="14" value="{{ weather_cfg.daily_days }}">
</label>
</div>
<button type="submit">Save</button>
</form>
</section>
<section class="card" id="weather-location-section" style="margin-top: 20px;">
<h2 class="card-title">Location</h2>
<p class="sub" id="weather-location-current">
{% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %}
</p>
<div class="checkbox-row" style="flex-wrap: wrap;">
<input type="text" id="weather-location-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1 1 160px;">
<button type="button" class="btn-inline" id="weather-location-set">Set</button>
<button type="button" class="btn-inline secondary" id="weather-location-clear">Clear</button>
</div>
</section>
<section class="card" id="weather-cities-section" style="margin-top: 20px;">
<h2 class="card-title">Cities</h2>
<ul class="calendar-user-list" id="weather-widget-city-list">
{% for c in weather_cfg.cities or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-widget-city-remove" data-label="{{ c.label }}">Remove</button>
</li>
{% else %}
<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>
{% endfor %}
</ul>
<div class="checkbox-row" style="margin-top: 10px;">
<input type="text" id="weather-widget-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
<button type="button" class="btn-inline" id="weather-widget-city-add">Add</button>
</div>
</section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="weather-preview" alt="Weather preview">
<button type="button" class="secondary" id="weather-preview-refresh">Refresh now</button>
</section>
@@ -50,6 +50,10 @@
{% endif %} {% endif %}
</section> </section>
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2> <h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p> <p class="sub">How this widget currently renders.</p>
+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>
+37 -30
View File
@@ -55,37 +55,37 @@
</section> </section>
<section class="card"> <section class="card">
<h2 class="card-title">Button assignments</h2> <h2 class="card-title">Hold actions</h2>
<p class="sub">What the frame's physical NEXT and BACK buttons do -- <p class="sub">Holding NEXT or BACK past this duration runs a
assign one or more widget actions to each, in the order they frame-wide action instead of each widget's normal short-press
should run. A button with several actions runs all of them, in binding (set per-widget in that widget's own config dialog).
order, then the panel redraws once.</p> Not scoped to any widget -- e.g. cycling through your saved
<p class="sub" id="button-assign-empty-hint" style="display: none;"> layouts.</p>
Add a widget on the Layout tab first -- there's nothing to assign <form id="hold-config-form">
a button to yet.</p> <label>Hold duration (seconds)
<input type="number" id="hold_duration_s" min="3" max="10"
<div id="button-assign-groups"> value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
<div class="button-assign-group"> </label>
<h3 class="button-assign-label">NEXT button</h3> <label>NEXT held action
<ul class="button-action-list" id="button-actions-next"></ul> <select id="next_hold_action">
<div class="button-action-add"> <option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
<select id="button-add-widget-next"></select> {% for action, label in global_action_labels.items() %}
<select id="button-add-action-next"></select> <option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button> {% endfor %}
</div> </select>
</div> </label>
<label>BACK held action
<div class="button-assign-group" style="margin-top: 20px;"> <select id="back_hold_action">
<h3 class="button-assign-label">BACK button</h3> <option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
<ul class="button-action-list" id="button-actions-back"></ul> {% for action, label in global_action_labels.items() %}
<div class="button-action-add"> <option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
<select id="button-add-widget-back"></select> {% endfor %}
<select id="button-add-action-back"></select> </select>
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button> </label>
</div> <button type="submit">Save</button>
</div> </form>
</div>
</section> </section>
</div> </div>
<div class="side-col"> <div class="side-col">
@@ -187,6 +187,12 @@
<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>
<section class="card"> <section class="card">
@@ -221,6 +227,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>
+4
View File
@@ -74,6 +74,10 @@
<script src="/static/widget_dialog_tasks.js"></script> <script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/widget_dialog_static.js"></script> <script src="/static/widget_dialog_static.js"></script>
<script src="/static/widget_dialog_text.js"></script> <script src="/static/widget_dialog_text.js"></script>
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script>
<script src="/static/widget_dialog_button_actions.js"></script>
<script src="/static/frame_layout.js"></script> <script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script> <script src="/static/saved_layouts.js"></script>
{% endblock %} {% endblock %}
@@ -0,0 +1,30 @@
<!doctype html>
<html><head><style>
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", 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: "Inter"; src: url("file://{{ font_dir }}/Inter-Regular.ttf"); font-weight: 400; }
@font-face { font-family: "Inter"; src: url("file://{{ font_dir }}/Inter-Bold.ttf"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "Inter", 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;
box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);
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>
+64
View File
@@ -0,0 +1,64 @@
"""Weather provider registry -- the app/widgets/ "dispatch registry over
pluggable implementations" pattern applied to weather data sources
instead of widget types. Three providers, all free/no API key:
Open-Meteo (app/weather/open_meteo.py, worldwide), NWS (app/weather/
nws.py, US-only), and Environment Canada (app/weather/ec.py, Canada-only,
its own bbox/nearest-site lookup shape rather than simple lat/lon REST --
see that module's own docstring).
geocode_city stays Open-Meteo-backed regardless of which provider is
chosen to actually fetch forecasts -- it's just free-text-name-to-lat/lon
resolution, done once when a location is added, and Open-Meteo's
geocoder covers the whole world where NWS's own data plainly doesn't.
Every provider module exposes the same four functions:
geocode_city(name) -> {"label", "latitude", "longitude"} (open_meteo only, see above)
fetch_current(lat, lon, units) -> {"temp", "category"}
fetch_hourly(lat, lon, units, hours) -> [{"time", "temp", "category"}, ...]
fetch_daily(lat, lon, units, days) -> {"YYYY-MM-DD": {"high", "low", "category"}, ...}
"category" is always one of the shared set (clear/partly_cloudy/cloudy/
fog/rain/snow/thunderstorm) that app/weather_render.py's icon-drawing
knows how to draw -- callers never need to know which provider supplied
an entry.
"""
from __future__ import annotations
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, an
unexpected response shape, or (NWS) a location outside its US
coverage. Raised loudly; callers (the dialog's location-set/preview
endpoints, get_or_refresh_*_for_widget) decide what to do."""
from . import ec, nws, open_meteo # noqa: E402 -- after WeatherFetchError, which all three submodules import
# Re-exported for existing call sites (routers/common.py, routers/
# api_widgets.py, calendar_render.py) -- all Open-Meteo-only and
# untouched by the provider abstraction below.
from .open_meteo import ( # noqa: E402,F401
CHECK_INTERVAL_S,
fetch_daily_forecast,
geocode_city,
weather_category,
)
PROVIDERS = {"open_meteo": open_meteo, "nws": nws, "ec": ec}
PROVIDER_LABELS = {
"open_meteo": "Open-Meteo",
"nws": "National Weather Service (US)",
"ec": "Environment Canada",
}
def fetch_current(provider: str, latitude: float, longitude: float, units: str) -> dict:
return PROVIDERS[provider].fetch_current(latitude, longitude, units)
def fetch_hourly(provider: str, latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
return PROVIDERS[provider].fetch_hourly(latitude, longitude, units, hours)
def fetch_daily(provider: str, latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
return PROVIDERS[provider].fetch_daily(latitude, longitude, units, days)
+227
View File
@@ -0,0 +1,227 @@
"""Environment Canada (ECCC MSC GeoMet OGC API, api.weather.gc.ca)
provider -- Canada-only, free, no API key. Unlike NWS's grid-point
lookup or Open-Meteo's plain lat/lon REST, EC's `citypageweather-realtime`
collection (an OGC API - Features collection, the modern replacement for
the old dd.weatheroffice.gc.ca XML feed -- that host no longer resolves)
is only queryable by bounding box, not a direct by-coordinate endpoint --
this widens the box progressively until it finds at least one site, then
picks the nearest by straight-line distance. This station/bbox-lookup
shape (not simple lat/lon REST) is exactly why EC was documented as a
follow-up rather than shipped alongside Open-Meteo/NWS in the first
pass -- see docs/widgets.md.
EC's numeric icon codes are its own set, distinct from WMO's (Open-
Meteo) or NWS's icon-URL condition codes. _category_from_code_and_text
below only maps the codes actually confirmed against live data (see
this module's own tests, captured from real api.weather.gc.ca
responses), falling back to the same keyword-match-on-condition-text
safety net app/weather/nws.py uses for anything unmapped -- correctness
comes from the text fallback, the numeric table is just a fast path.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py/nws.py.
"""
from __future__ import annotations
import math
from datetime import datetime, timedelta
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
BASE_URL = "https://api.weather.gc.ca"
COLLECTION = "citypageweather-realtime"
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
# Progressively wider bounding boxes (degrees) around the target point --
# EC's ~844 sites are dense near cities but sparse in the north, so a
# small box can come back empty even for a real Canadian location. 25
# degrees (~2000-2700 km depending on latitude) is already far beyond
# _MAX_DISTANCE_KM, so there's no point widening past it.
_BBOX_PADDINGS_DEG = (1.0, 3.0, 8.0, 25.0)
# See _nearest_site's own docstring for why this exists and how it was
# calibrated (a real Miami query matched 1824 km away without it).
_MAX_DISTANCE_KM = 300
_ICON_CODE_CATEGORIES = {
0: "clear", 1: "clear", 30: "clear",
2: "partly_cloudy", 5: "partly_cloudy", 31: "partly_cloudy", 32: "partly_cloudy",
3: "cloudy", 4: "cloudy", 10: "cloudy", 33: "cloudy",
6: "rain", 12: "rain", 28: "rain", 36: "rain",
9: "thunderstorm", 19: "thunderstorm", 39: "thunderstorm",
24: "fog",
}
_TEXT_CATEGORY_KEYWORDS = [
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"), ("tornado", "thunderstorm"),
("flurr", "snow"), ("snow", "snow"), ("sleet", "snow"), ("ice pellet", "snow"), ("hail", "snow"),
("freezing", "snow"),
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
("overcast", "cloudy"), ("cloudy", "cloudy"),
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
]
def _category_from_code_and_text(code: int | None, text: str) -> str:
if code is not None and code in _ICON_CODE_CATEGORIES:
return _ICON_CODE_CATEGORIES[code]
lowered = text.lower()
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
if keyword in lowered:
return category
return "cloudy" # same generic-icon fallback Open-Meteo/NWS both use
def _convert_temp(celsius: float, units: str) -> float:
"""EC's citypage feed reports temperatures in Celsius only (its
unitType is always "metric" in this feed) -- convert to the widget's
requested units, no-op if celsius was actually asked for."""
return celsius * 9 / 5 + 32 if units == "fahrenheit" else celsius
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _items(bbox: str) -> list[dict]:
try:
resp = httpx.get(
f"{BASE_URL}/collections/{COLLECTION}/items",
params={"f": "json", "bbox": bbox, "limit": 50},
headers=HEADERS, timeout=HTTP_TIMEOUT_S,
)
resp.raise_for_status()
return resp.json()["features"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def _nearest_site(latitude: float, longitude: float) -> dict:
"""This location's nearest Environment Canada citypage site's
`properties` dict -- widens the bounding box until it finds one
within _MAX_DISTANCE_KM, trying every padding rather than stopping
at the first non-empty box: since each padding's box is a superset
of the previous one's, a wider box's nearest match can only be the
same distance or closer, never farther, so an early non-empty box
whose nearest site is still too far away doesn't mean a closer one
isn't waiting just outside it.
Without the distance cutoff, an unconditional "just take whatever's
nearest" happily matches a US or overseas location to some real EC
site thousands of km away (confirmed live: Miami matched to
Leamington, Ontario, 1824 km off) instead of reporting that EC
simply doesn't cover this location. 300 km is generous enough for a
legitimate rural-Canada query against EC's sparse northern coverage
(~844 sites total) while still correctly rejecting a non-Canadian
one -- a US border city like Seattle, genuinely ~100 km from the
nearest EC site in Victoria, BC, still passes. Deliberately NOT a
single whole-country query instead of progressive widening: that
collection response is ~29 MB for cheap, in-city lookups fetching a
handful of nearby sites."""
best_distance_km = None
for pad in _BBOX_PADDINGS_DEG:
bbox = f"{longitude - pad},{latitude - pad},{longitude + pad},{latitude + pad}"
features = _items(bbox)
if not features:
continue
nearest = min(
features,
key=lambda f: _haversine_km(
latitude, longitude, f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0]
),
)
best_distance_km = _haversine_km(
latitude, longitude, nearest["geometry"]["coordinates"][1], nearest["geometry"]["coordinates"][0]
)
if best_distance_km <= _MAX_DISTANCE_KM:
return nearest["properties"]
raise WeatherFetchError("No Environment Canada site found near this location (is it in Canada?)")
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
props = _nearest_site(latitude, longitude)
cc = props["currentConditions"]
temp_c = cc["temperature"]["value"]["en"]
code = (cc.get("iconCode") or {}).get("value")
text = (cc.get("condition") or {}).get("en") or ""
return {"temp": _convert_temp(temp_c, units), "category": _category_from_code_and_text(code, text)}
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""EC's hourlyForecastGroup is a fixed 24-hour window (unlike Open-
Meteo/NWS's own longer hourly ranges) -- `hours` just caps how much
of it gets returned, same as the other providers."""
props = _nearest_site(latitude, longitude)
entries = props["hourlyForecastGroup"]["hourlyForecasts"]
result = []
for h in entries[:hours]:
temp_c = h["temperature"]["value"]["en"]
code = (h.get("iconCode") or {}).get("value")
text = (h.get("condition") or {}).get("en") or ""
result.append({
"time": h["timestamp"], "temp": _convert_temp(temp_c, units),
"category": _category_from_code_and_text(code, text),
})
return result
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""Pairs EC's named day/night periods ("Today"/"Tonight"/"Tuesday"/
"Tuesday night"/...) into calendar dates by walking them in issued
order -- unlike NWS's periods (which carry a real startTime), EC's
forecast periods are named relative to "today", not dated, so the
date is inferred: a "night" period shares its preceding day period's
date, any other period starts the calendar day after the previous
one (the forecastGroup's own issued-at timestamp anchors day 0)."""
props = _nearest_site(latitude, longitude)
group = props["forecastGroup"]
issued = datetime.fromisoformat(group["timestamp"]["en"].replace("Z", "+00:00")).date()
by_date: dict[str, dict] = {}
order: list[str] = []
current_day = None
for period in group["forecasts"]:
name = period["period"]["textForecastName"]["en"].strip().lower()
if "night" in name:
day_date = current_day or issued
else:
day_date = issued if current_day is None else current_day + timedelta(days=1)
current_day = day_date
key = day_date.isoformat()
entry = by_date.setdefault(key, {"category": None})
if key not in order:
order.append(key)
temps = period.get("temperatures", {}).get("temperature") or []
if temps:
temp = _convert_temp(temps[0]["value"]["en"], units)
if temps[0]["class"]["en"] == "high":
entry["high"] = temp
else:
entry["low"] = temp
if entry["category"] is None:
icon = (period.get("abbreviatedForecast") or {}).get("icon") or {}
text = (period.get("abbreviatedForecast") or {}).get("textSummary", {}).get("en") or ""
entry["category"] = _category_from_code_and_text(icon.get("value"), text)
result = {}
for key in order[:max(1, days)]:
entry = by_date[key]
if "high" not in entry and "low" not in entry:
continue
result[key] = {
"high": entry.get("high", entry.get("low")),
"low": entry.get("low", entry.get("high")),
"category": entry["category"] or "cloudy",
}
return result
+196
View File
@@ -0,0 +1,196 @@
"""NWS (National Weather Service, api.weather.gov) provider -- US
locations only, free, no API key, but requires a `User-Agent` header
identifying the calling app (NWS API policy: unidentified traffic gets
throttled/blocked). No geocoder of its own; app/weather/__init__.py's
geocode_city (Open-Meteo's) resolves a place name to lat/lon regardless
of which provider is then chosen to fetch with it.
Unlike Open-Meteo's numeric WMO codes, NWS periods carry a `shortForecast`
text description and an `icon` URL encoding a condition code (e.g.
".../icons/land/day/tsra,40?size=medium") -- _category_from_period below
normalizes either into the same shared category set
(clear/partly_cloudy/cloudy/fog/rain/snow/thunderstorm) Open-Meteo's
weather_category() already produces, so app/weather_render.py's build_*
functions never need to know which provider supplied an entry.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py.
"""
from __future__ import annotations
from datetime import datetime
import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0
BASE_URL = "https://api.weather.gov"
# NWS blocks/deprioritizes traffic with no identifying User-Agent -- a
# contact-ish string is their documented convention, not a real account.
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
# NWS's icon condition codes (https://api.weather.gov/icons), grouped into
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
# sky condition plus wind -- stripped before lookup, since this project's
# vendored icon set (app/weather_render.py, app/weather_icons/) doesn't
# have a separate windy glyph.
_ICON_CODE_CATEGORIES = {
"skc": "clear", "clear": "clear",
"few": "partly_cloudy", "sct": "partly_cloudy",
"bkn": "cloudy", "ovc": "cloudy",
"fog": "fog", "haze": "fog", "smoke": "fog", "dust": "fog",
"rain": "rain", "rain_showers": "rain", "rain_showers_hi": "rain",
"showers": "rain", "drizzle": "rain",
"snow": "snow", "rain_snow": "snow", "sleet": "snow",
"fzra": "snow", "rain_fzra": "snow", "snow_fzra": "snow",
"tsra": "thunderstorm", "tsra_sct": "thunderstorm", "tsra_hi": "thunderstorm",
}
# Fallback keyword match against shortForecast text, in priority order --
# used when the icon URL can't be parsed at all (unexpected shape) or its
# condition code isn't in the table above (NWS occasionally adds new
# icon variants).
_TEXT_CATEGORY_KEYWORDS = [
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"),
("snow", "snow"), ("sleet", "snow"), ("ice", "snow"),
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
("overcast", "cloudy"), ("cloudy", "cloudy"),
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
]
def _category_from_icon(icon_url: str) -> str | None:
"""The first condition code segment out of an icon URL path like
".../icons/land/day/tsra,40/skc,20?size=medium" -- only the first
(the dominant/nearest-term condition) is used, same "one glyph per
entry" simplicity as Open-Meteo's single-WMO-code-per-day shape."""
path = icon_url.split("?")[0]
segments = [s for s in path.split("/") if s]
for i, seg in enumerate(segments):
if seg in ("day", "night") and i + 1 < len(segments):
code = segments[i + 1].split(",")[0]
code = code.removeprefix("wind_")
return _ICON_CODE_CATEGORIES.get(code)
return None
def _category_from_text(text: str) -> str:
lowered = text.lower()
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
if keyword in lowered:
return category
return "cloudy" # same generic-icon fallback Open-Meteo's weather_category uses
def _category_from_period(period: dict) -> str:
icon = period.get("icon") or ""
category = _category_from_icon(icon) if icon else None
return category or _category_from_text(period.get("shortForecast") or "")
def _convert_temp(value: float, from_unit: str, to_units: str) -> float:
"""NWS periods report temperatureUnit per-period (almost always "F"
for US points) -- converts to the widget's own requested `units`
("fahrenheit"/"celsius") only if they actually differ, so this is a
no-op in the common case."""
to_unit = "F" if to_units == "fahrenheit" else "C"
if from_unit == to_unit:
return value
if from_unit == "F" and to_unit == "C":
return (value - 32) * 5 / 9
return value * 9 / 5 + 32
def _points(latitude: float, longitude: float) -> dict:
try:
resp = httpx.get(
f"{BASE_URL}/points/{latitude:.4f},{longitude:.4f}", headers=HEADERS, timeout=HTTP_TIMEOUT_S
)
resp.raise_for_status()
return resp.json()["properties"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(f"NWS points lookup failed (is this location in the US?): {e}") from e
def _periods(url: str) -> list[dict]:
try:
resp = httpx.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
return resp.json()["properties"]["periods"]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} -- NWS has no simple by-
coordinate "current conditions" endpoint (that needs a second
stations-list + latest-observation lookup); this approximates
"current" with the first hourly forecast period instead, which is
plenty for a frame that only refreshes every few hours."""
points = _points(latitude, longitude)
periods = _periods(points["forecastHourly"])
if not periods:
raise WeatherFetchError("NWS returned no hourly forecast periods")
period = periods[0]
temp = _convert_temp(period["temperature"], period.get("temperatureUnit", "F"), units)
return {"temp": temp, "category": _category_from_period(period)}
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour -- NWS's forecastHourly is already 1-hour
resolution, same as Open-Meteo's."""
points = _points(latitude, longitude)
periods = _periods(points["forecastHourly"])
return [
{
"time": p["startTime"],
"temp": _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units),
"category": _category_from_period(p),
}
for p in periods[:hours]
]
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- NWS's /forecast returns day/night period pairs (12h each, ~7 days
/ 14 periods), not one row per day; this pairs them by calendar date
(a daytime period's high, the following night's low) and clamps to
however many full dates actually came back once `days` is asked for
more than that -- no error, just fewer days, same graceful-
degradation idiom as app/weather_render.py's draw_weather_row."""
points = _points(latitude, longitude)
periods = _periods(points["forecast"])
by_date: dict[str, dict] = {}
order: list[str] = []
for p in periods:
day = datetime.fromisoformat(p["startTime"]).date().isoformat()
entry = by_date.setdefault(day, {"category": None})
if day not in order:
order.append(day)
temp = _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units)
if p["isDaytime"]:
entry["high"] = temp
entry["category"] = _category_from_period(p)
else:
entry["low"] = temp
if entry["category"] is None:
entry["category"] = _category_from_period(p)
result = {}
for day in order[:max(1, days)]:
entry = by_date[day]
if "high" not in entry and "low" not in entry:
continue
result[day] = {
"high": entry.get("high", entry.get("low")),
"low": entry.get("low", entry.get("high")),
"category": entry["category"] or "cloudy",
}
return result
@@ -1,10 +1,10 @@
"""Weather strip for calendar frame mode (agenda/today & tomorrow/week """Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
views only -- never month, there's no room, see calendar_render.py's -- free geocoding + forecast APIs, no API key, no signup, no per-request
_BUILDERS). A frame can list multiple cities; each is geocoded once via quota to manage. Backs both the calendar widget's embedded weather strip
Open-Meteo's free geocoding API (no API key, no signup, no per-request (fetch_daily_forecast/weather_category, its original shape, untouched)
quota to manage) when added from the Calendar tab, then its daily and the standalone weather widget's per-mode fetches below (fetch_current/
forecast is refreshed on its own throttle -- same shape idiom as fetch_hourly/fetch_daily, which return already-normalized {"category":
calendar_feed.py's merge-fetch cache. ...} entries from the shared category set instead of a raw WMO code).
Pure functions -- no ORM, no FastAPI Depends -- same testability Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py. philosophy as calendar_feed.py/caldav_client.py.
@@ -14,6 +14,8 @@ from __future__ import annotations
import httpx import httpx
from . import WeatherFetchError
HTTP_TIMEOUT_S = 15.0 HTTP_TIMEOUT_S = 15.0
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search" GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast" FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
@@ -36,12 +38,6 @@ _CODE_CATEGORIES = {
} }
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, or an
unexpected response shape. Raised loudly; callers (the Calendar
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
def weather_category(code: int) -> str: def weather_category(code: int) -> str:
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later """Falls back to "cloudy" for any WMO code Open-Meteo might add later
that isn't in the table above -- an unrecognized code shouldn't drop that isn't in the table above -- an unrecognized code shouldn't drop
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
} }
except (httpx.HTTPError, KeyError) as e: except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e raise WeatherFetchError(str(e)) from e
# --- Standalone weather widget fetches (app/widgets/weather.py) --------
#
# Unlike fetch_daily_forecast above (kept as-is for the calendar widget's
# embedded strip, which does its own weather_category(code) lookup),
# these return already-normalized {"category": ...} entries so
# app/weather_render.py's build_* functions never need to know which
# provider supplied the data (see app/weather/nws.py, which normalizes
# its own icon/text shape to the same category set).
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
"""{"temp": float, "category": str} for right now."""
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"current": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
current = resp.json()["current"]
return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])}
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
one entry per hour, for the next `hours` hours (Open-Meteo's hourly
data is always 1-hour resolution regardless of how far apart the
ticks a caller actually wants to display are -- see
app/weather_render.py's build_hourly, which samples every Nth
entry)."""
forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours`
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"hourly": "temperature_2m,weathercode",
"temperature_unit": units,
"timezone": "auto",
"forecast_days": forecast_days,
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
hourly = resp.json()["hourly"]
return [
{"time": t, "temp": temp, "category": weather_category(code)}
for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"])
][:hours]
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
-- same request as fetch_daily_forecast, just normalized to a
category instead of a raw WMO code, and clamped to `days` (Open-
Meteo's own real max is FORECAST_DAYS)."""
clamped = max(1, min(days, FORECAST_DAYS))
raw = fetch_daily_forecast(latitude, longitude, units)
return {
day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])}
for day, d in list(raw.items())[:clamped]
}
+433
View File
@@ -0,0 +1,433 @@
"""Weather icon-drawing primitives (draw_weather_icon/draw_weather_row --
extracted out of calendar_render.py, which still imports draw_weather_row
for its own embedded weather strip, unchanged) plus the standalone
weather widget's four per-mode renderers (build_current/build_hourly/
build_daily/build_multi_city, dispatched by build()) and its preview-PNG
wrapper -- the weather analogue of calendar_render.py's own
_build_tasks/render_tasks_preview_png relationship.
Every build_* function takes already-normalized data (see app/weather/'s
provider modules -- a `category` key from the shared clear/partly_cloudy/
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
returns an RGB Image exactly target_w x target_h, same contract every
other widget renderer in this project follows.
Icons are hand-drawn (no custom font/icon asset, same primitives-only
approach calendar_render.py uses elsewhere for e.g. month view's density
dots), styled after Environment Canada's own icon set (pointed sun rays,
a smooth puffy cloud, teardrop rain, dendrite snowflakes, a zigzag bolt)
but filled with this frame's *exact* panel ink RGB values rather than an
arbitrary bitmap's anti-aliased colors -- a flat fill that's already one
of the palette's 6 colors quantizes with zero dithering error to diffuse,
where a fetched/vendored bitmap's colors (almost never an exact palette
match) dither into a visible speckle. An early plain circle-with-4-ticks
"sun" also just didn't read as a sun at a glance -- pointed triangular
rays fixed that without giving up the clean-quantization property.
"""
from __future__ import annotations
import io
import math
from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned -- every column-width/icon-size calc below was measured
# against 20px). BG/FG are this module's own cloud-icon fill/outline and
# fog-line color (see draw_cloud/draw_weather_icon), not a text-emphasis
# concern -- those live in panel_style (font_bold/font_regular, no MUTED
# gray -- see its module docstring for why).
MARGIN = panel_style.CONTENT_MARGIN
BG = (255, 255, 255)
FG = (0, 0, 0)
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
already this module's BG/FG) -- thin wrapper over panel_style.ink
(which generalized this same resolution idiom), kept so every
draw_weather_icon call site below doesn't need touching."""
return panel_style.ink(palette_rgb, index)
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
with a clean outline -- drawn as one outline-color pass slightly
larger than the shapes, then the same shapes again in `fill` on top.
Overlapping ellipses each drawn with their own `outline=` would leave
visible seams where they cross; this double-draw trick sidesteps that
entirely regardless of how the lobes overlap."""
stroke = 2
lobes = [
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=fill)
draw.rectangle(base, fill=fill)
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
"""A filled disc + 8 pointed triangular rays -- styled after
Environment Canada's own sun glyph. Rays are solid triangles (base on
the disc's edge, tip pointing outward), not thin lines: at small icon
sizes thin lines read as a crosshair/asterisk, not sun rays, which is
exactly what an earlier attempt here looked like."""
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
base_r, tip_r, half_w = r * 0.6, r * 1.2, r * 0.16
for i in range(8):
angle = i * (math.pi / 4)
perp = angle + math.pi / 2
bx, by = cx + math.cos(angle) * base_r, cy + math.sin(angle) * base_r
tx, ty = cx + math.cos(angle) * tip_r, cy + math.sin(angle) * tip_r
p1 = (bx + math.cos(perp) * half_w, by + math.sin(perp) * half_w)
p2 = (bx - math.cos(perp) * half_w, by - math.sin(perp) * half_w)
draw.polygon([p1, p2, (tx, ty)], fill=color)
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
"""A rounded teardrop (point up, bulb down) -- the standard rain
glyph, not a bare diagonal tick."""
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
"""A 6-pointed dendrite -- three crossing lines plus a short
perpendicular tick near each of the 6 tips, closer to a real
snowflake glyph than a bare asterisk."""
for i in range(3):
angle = i * (math.pi / 3)
dx, dy = math.cos(angle) * r, math.sin(angle) * r
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.28)))
perp = angle + math.pi / 2
tick = r * 0.35
for sign in (1, -1):
tx, ty = x + dx * sign, y + dy * sign
ex, ey = tx * 0.75 + x * 0.25, ty * 0.75 + y * 0.25
draw.line([(ex - math.cos(perp) * tick, ey - math.sin(perp) * tick),
(ex + math.cos(perp) * tick, ey + math.sin(perp) * tick)], fill=color, width=2)
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
3-segment line."""
points = [
(cx + size * 0.15, cy - size * 0.7),
(cx - size * 0.35, cy + size * 0.05),
(cx - size * 0.05, cy + size * 0.05),
(cx - size * 0.2, cy + size * 0.7),
(cx + size * 0.4, cy - size * 0.1),
(cx + size * 0.05, cy - size * 0.1),
]
draw.polygon(points, fill=color)
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
palette_rgb: list | None = None) -> None:
"""A small glyph for one weather category, styled after Environment
Canada's own icon set but hand-drawn in this frame's exact panel ink
colors (yellow sun/bolt, blue rain/snow) -- see module docstring for
why that's better for this display than reusing an actual bitmap."""
yellow = _ink(palette_rgb, 2)
blue = _ink(palette_rgb, 4)
if category == "clear":
draw_sun(draw, cx, cy, r, yellow)
return
if category == "partly_cloudy":
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
return
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.55 + i * (r * 0.4)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.55, 0, 0.55):
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
elif category == "snow":
for dx in (-0.55, 0, 0.55):
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
elif category == "thunderstorm":
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True, palette_rgb: list | None = None) -> int:
"""Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities --
same graceful-degradation approach month view takes with density
dots). Returns the row height consumed (0 if there was nothing to
draw, so callers can skip reserving space entirely)."""
if not entries:
return 0
unit_suffix = "F" if units == "fahrenheit" else "C"
row_h = icon_r * 2 + 8
x = x0
drew_any = False
for entry in entries:
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
label = f"{entry['label']} {temps}" if show_labels else temps
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
if drew_any and x + entry_w > x0 + max_w:
break
cx, cy = x + icon_r, y0 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w
drew_any = True
return row_h + 6
# --- Standalone weather widget (app/widgets/weather.py) -----------------
def _format_hour_label(iso_time: str) -> str:
dt = datetime.fromisoformat(iso_time)
text = dt.strftime("%I %p").lstrip("0")
return text if text else "12 AM"
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
min_size: int = 9, font_loader=panel_style.font_bold) -> int:
"""Largest size <= max_size at which every string in `texts` fits
within max_width -- used to size a per-column label/temp font against
the actual column width instead of an icon-radius-derived guess,
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
next column at a large icon size on a narrow column. Measured against
`font_loader` (default Inter Bold -- the wider of the two weights a
column actually mixes, a label in Regular and a temp in Bold, so
fitting against Bold keeps both safely inside max_width)."""
for size in range(max_size, min_size - 1, -1):
font = font_loader(size)
if all(draw.textlength(t, font=font) <= max_width for t in texts):
return size
return min_size
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 = "") -> Image.Image:
"""One big icon + big temp number + (optional) city label, centered --
entry is {"temp", "category"} or None if nothing's been fetched yet
(callers normally catch that earlier and show a placeholder instead,
but this degrades to a blank canvas rather than erroring either
way)."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
if not entry:
return img
icon_r = max(20, min(cw, ch) // 4)
cx, cy = cx0 + cw // 2, cy0 + ch // 2 - icon_r // 2
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(cw, ch) // 3)
temp_font = panel_style.font_bold(temp_size)
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
temp_y = cy + icon_r + 12
draw_text(img, (cx0 + cw // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
if city_label:
label_size = max(12, temp_size // 3)
label_font = panel_style.font_regular(label_size)
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
draw_text(img, (cx0 + cw // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
city_label, label_font)
return img
def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", interval_hours: int = 4, city_label: str = "") -> Image.Image:
"""A row of ticks across the day, one every `interval_hours` hours
(entries is always 1-hour resolution -- see app/weather's provider
fetch_hourly), each showing an hour label, icon, and temp. Same
"draw however many fit" graceful degradation as draw_weather_row if
the box is too narrow for every tick."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
text_x0 = cx0 + MARGIN
text_w = cw - MARGIN * 2
y = cy0 + MARGIN
title_size = max(14, min(cw, ch) // 16)
if city_label:
# A filled header bar (this widget's chosen accent is black, not
# a color, so the hand-drawn icons below stay the star -- see
# panel_style module docstring) replaces the old plain title +
# thin rule line.
header_h = title_size + 20
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
panel_style.theme_color("weather", palette_rgb))
title_font = panel_style.font_bold(title_size)
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
y = cy0 + header_h + 12
# Capped to however many columns actually fit at a legible width
# (narrower widgets/smaller intervals just show fewer ticks) rather
# than cramming every sampled tick in regardless of how narrow that
# makes each one -- same graceful-degradation idiom as
# draw_weather_row's own per-pixel-width stopping point.
min_col_w = 46
max_ticks = max(1, text_w // min_col_w)
ticks = entries[::max(1, interval_hours)][:max_ticks]
if not ticks:
return img
col_w = max(1, text_w // len(ticks))
icon_r = max(10, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
time_labels = [_format_hour_label(e["time"]) for e in ticks]
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
time_font = panel_style.font_regular(label_size)
temp_font = panel_style.font_bold(label_size)
for i, entry in enumerate(ticks):
cx = text_x0 + i * col_w + col_w // 2
time_label = time_labels[i]
tbbox = draw.textbbox((0, 0), time_label, font=time_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, time_font)
cy = y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=temp_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, temp_font)
return img
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
"""A day-by-day strip (day label, icon, high/low), however many days
fit `target_w` (`daily` is already clamped to the widget's own
configured day count by app/weather's provider fetch_daily -- this
just draws whatever it's handed, same "stop once it doesn't fit"
graceful degradation as draw_weather_row)."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
text_x0 = cx0 + MARGIN
text_w = cw - MARGIN * 2
y = cy0 + MARGIN
title_size = max(14, min(cw, ch) // 16)
if city_label:
header_h = title_size + 20
panel_style.draw_header_bar(draw, (cx0, cy0, cw, header_h), header_h,
panel_style.theme_color("weather", palette_rgb))
title_font = panel_style.font_bold(title_size)
draw_text(img, (text_x0, cy0 + (header_h - title_size) // 2), city_label, title_font, BG)
y = cy0 + header_h + 12
days = list(daily.items())
if not days:
return img
col_w = max(1, text_w // len(days))
icon_r = max(12, min(col_w // 3, (cy0 + ch - y - MARGIN) // 4))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
label_font = panel_style.font_regular(label_size)
temp_font = panel_style.font_bold(label_size)
for i, (_, d) in enumerate(days):
x0 = text_x0 + i * col_w
label, temps = labels[i], temps_strs[i]
lbbox = draw.textbbox((0, 0), label, font=label_font)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font)
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
tbbox = draw.textbbox((0, 0), temps, font=temp_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, temp_font)
return img
def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit") -> Image.Image:
"""Several cities' current-day high/low/icon side by side -- directly
reuses draw_weather_row (the same layout calendar_render.py's
embedded strip uses), just as the whole widget's own content instead
of a strip above an agenda day."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
if not cities:
return img
# Just the city name on-panel ("Portland", not the full disambiguated
# "Portland, Oregon, United States") -- that fuller form matters for
# telling apart geocoder candidates when adding a city (see
# weather.geocode_city, and the dialog's own "Cities" management
# list), not for a compact display row. Same shortening
# calendar_render.py's _weather_for_day already does for its own
# embedded strip.
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
text_w = cw - MARGIN * 2
# Sized against how many entries actually need to fit side by side,
# not just the box's height -- an icon/font picked from target_h
# alone (as this used to do) drew each entry so wide that only the
# first city ever fit, and draw_weather_row's own "stop once it
# doesn't fit" degradation silently dropped every city after it,
# even in an ordinary-sized widget with plenty of cities configured.
col_w = max(1, text_w // len(cities))
icon_r = max(10, min(col_w // 6, ch // 6, 40))
unit_suffix = "F" if units == "fahrenheit" else "C"
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r,
font_loader=panel_style.font_regular)
font = panel_style.font_regular(font_size)
y = max(cy0 + MARGIN, cy0 + (ch - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, cx0 + MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
show_labels=True, palette_rgb=palette_rgb)
return img
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", interval_hours: int = 4) -> Image.Image:
"""Dispatches to the right build_* function for this widget's
configured mode -- shared by app/widgets/weather.py's render() and
render_weather_preview_png below, so the two never drift apart."""
if mode == "current":
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
if mode == "hourly":
return build_hourly(data, target_w, target_h, palette_rgb, units, interval_hours, city_label)
if mode == "daily":
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
if mode == "multi_city":
return build_multi_city(data, target_w, target_h, palette_rgb, units)
return Image.new("RGB", (target_w, target_h), BG) # unreachable via a valid config -- see WeatherWidgetConfig.mode
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", manage: dict | None = None,
city_label: str = "", interval_hours: int = 4) -> bytes:
"""Same pipeline as calendar_render.render_tasks_preview_png -- a
normal browser-viewable PNG in logical (upright) orientation."""
target_w, target_h = logical_render_size(orientation)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+32 -1
View File
@@ -36,7 +36,8 @@ Each module in this package exposes:
from __future__ import annotations from __future__ import annotations
from . import calendar, photos, static_image, tasks, text, whiteboard from ..models import FrameButtonAction
from . import battery, calendar, photos, static_image, tasks, text, weather, whiteboard
WIDGET_TYPES = { WIDGET_TYPES = {
"photos": photos, "photos": photos,
@@ -45,4 +46,34 @@ WIDGET_TYPES = {
"tasks": tasks, "tasks": tasks,
"static": static_image, "static": static_image,
"text": text, "text": text,
"weather": weather,
"battery": battery,
} }
def default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is.
Called both when backfilling pre-widget-system frames (see
app/migration.py) and when a widget is newly created (see
routers/api_widgets.py's api_widget_create) -- a widget is never left
without a sane starting binding, so the physical buttons always do
something reasonable for it until someone deliberately reassigns
them in that widget's own config dialog."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons mean "check now".
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
if widget_type == "weather":
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
if widget_type in ("photos", "calendar"):
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
return []
+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
+126
View File
@@ -0,0 +1,126 @@
"""Battery widget: shows the frame's own last-reported battery level --
no live upstream to poll, unlike almost every other widget type. The
content is frame-level state that already exists regardless of this
widget (frame.battery_percent/battery_as_of, set by routers/device.py's
frame_battery on every device wake-on-battery report) plus routers.
common.battery_estimate_s's existing recency-weighted "how much longer"
estimate (computed there for the Device panel's own history chart) --
this widget just draws them, it doesn't fetch or compute anything new.
BatteryWidgetConfig only holds a display mode (compact: icon + percent;
detailed: also the estimate + last-report age).
No button actions -- there's nothing to advance/back/force for a number
the device itself pushes on every wake."""
from __future__ import annotations
import io
import time
from PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from .. import panel_style
from ..image_pipeline import _quantize, draw_text, logical_render_size
from ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s
from ._shared import placeholder_image
ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {}
def _draw_icon(draw: ImageDraw.ImageDraw, cx: int, top: int, icon_w: int, icon_h: int, percent: int,
palette_rgb: list | None = None) -> None:
"""Centers panel_style.draw_battery_icon (top-left-anchored) under
`cx` -- this widget's own layout picks a center point, that helper's
shared implementation (also used by manage_overlay.py's battery
readout) just needs a top-left corner."""
nub_w = max(3, icon_w // 10)
x0 = cx - (icon_w + nub_w) // 2
panel_style.draw_battery_icon(draw, x0, top, icon_w, icon_h, percent, palette_rgb)
def _format_estimate(seconds: float) -> str:
days = seconds / 86400
if days >= 2:
return f"~{days:.0f}d left"
hours = seconds / 3600
if hours >= 20:
return "~1d left"
return f"~{max(1, round(hours))}h left"
def _format_age(as_of: float) -> str:
delta = max(0.0, time.time() - as_of)
if delta < 3600:
return f"{max(1, round(delta / 60))}m ago"
if delta < 86400:
return f"{round(delta / 3600)}h ago"
return f"{round(delta / 86400)}d ago"
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused -- see app/widgets/whiteboard.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
percent = frame.battery_percent
if percent < 0:
return placeholder_image(target_w, target_h, ["Battery", "No reports yet"])
cfg = db.get(BatteryWidgetConfig, widget.id)
mode = cfg.mode if cfg else "detailed"
palette_rgb = frame.palette_rgb
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
cx = cx0 + cw // 2
icon_h = max(20, min(cw, ch) // 3)
icon_w = int(icon_h * 1.8)
icon_top = max(4, cy0 + ch // 8)
_draw_icon(draw, cx, icon_top, icon_w, icon_h, percent, palette_rgb)
# The percent number picks up the icon's own charge-level color
# (red/yellow/green) instead of plain black -- ties the two into one
# visual statement rather than "colored icon, black number".
pct_font_size = max(18, min(cw, ch) // 3)
pct_font = panel_style.font_bold(pct_font_size)
pct_text = f"{percent}%"
bbox = draw.textbbox((0, 0), pct_text, font=pct_font)
pct_y = icon_top + icon_h + 10
draw_text(img, (cx - (bbox[2] - bbox[0]) // 2, pct_y), pct_text, pct_font,
panel_style.battery_fill_color(percent, palette_rgb))
if mode == "detailed":
lines = []
estimate_s = battery_estimate_s(frame, db)
if estimate_s is not None:
lines.append(_format_estimate(estimate_s))
if frame.battery_as_of:
lines.append(f"Reported {_format_age(frame.battery_as_of)}")
small_font_size = max(11, pct_font_size // 3)
small_font = panel_style.font_regular(small_font_size)
y = pct_y + pct_font_size + 12
for line in lines:
if y + small_font_size > cy0 + ch - 4:
break
lbbox = draw.textbbox((0, 0), line, font=small_font)
draw_text(img, (cx - (lbbox[2] - lbbox[0]) // 2, y), line, small_font)
y += small_font_size + 6
return img
def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: str,
palette_rgb: list | None) -> bytes:
"""A normal browser-viewable PNG at full logical panel size -- same
"dialog preview always renders at the frame's full size, not the
widget's actual grid box" convention as text.py's render_preview_png."""
target_w, target_h = logical_render_size(orientation)
img = render(db, frame, widget, target_w, target_h)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+2 -2
View File
@@ -55,7 +55,7 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
def _advance(db: Session, frame: Frame, widget: Widget) -> None: def _advance(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id: if not cfg.album_id or cfg.locked:
return return
try: try:
client = immich_client_for(frame) client = immich_client_for(frame)
@@ -68,7 +68,7 @@ def _advance(db: Session, frame: Frame, widget: Widget) -> None:
def _back(db: Session, frame: Frame, widget: Widget) -> None: def _back(db: Session, frame: Frame, widget: Widget) -> None:
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
if not cfg.album_id: if not cfg.album_id or cfg.locked:
return return
try: try:
client = immich_client_for(frame) client = immich_client_for(frame)
+55
View File
@@ -0,0 +1,55 @@
"""Weather widget: one of four display modes (see models.
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
py's build() dispatch -- or, for "current"/"daily" modes with
render_style="modern", by app/html_render.py's Jinja2/headless-Chromium
renderer instead (experimental; hourly/multi_city always render classic
regardless of render_style, see html_render's module docstring). No real
"next"/"back" concept (same as whiteboard) -- a single "check now"
action forces a re-fetch bypassing the normal throttle."""
from __future__ import annotations
from PIL import Image
from sqlalchemy.orm import Session
from .. import weather_render
from ..models import Frame, WeatherWidgetConfig, Widget
from ..routers.common import get_or_refresh_weather_widget_data
from ._shared import placeholder_image
ACTION_LABELS = {"check_now": "Check for updates"}
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
is_normal_wake: bool = True) -> Image.Image:
"""is_normal_wake is unused here -- see app/widgets/photos.py's
identical note; every widget type's render() shares one call
signature regardless of which ones actually care."""
cfg = db.get(WeatherWidgetConfig, widget.id)
data = get_or_refresh_weather_widget_data(db, frame, widget)
if data is None:
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
if cfg.render_style == "modern" and cfg.mode in ("current", "daily"):
# Local import: html_render pulls in Playwright, a real headless-
# Chromium dependency -- every other widget type, and this one's
# own classic/hourly/multi_city paths, should never pay for it
# (same reasoning as image_pipeline.render_placeholder's local
# `import qrcode`).
from .. import html_render
return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "")
return weather_render.build(
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
)
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
get_or_refresh_weather_widget_data(db, frame, widget, force=True)
ACTIONS = {"check_now": _check_now}
+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
+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

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