12 Commits
Author SHA1 Message Date
tfaour 6c2afeba78 Bump firmware to 1.5.0
Firmware build check / build-check (push) Successful in 4m4s
Build and release firmware / build-and-release (push) Successful in 4m3s
2026-08-04 23:22:51 +00:00
tfaour c0fefc19f1 Fix ee02 button-wakeup build: ext1 fallback for ESP32-S3
Firmware build check / build-check (push) Successful in 2m44s
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown() only exists on
ESP32-C6 (SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP), so the ee02
(ESP32-S3) build failed with implicit-declaration errors in
{back,next,combo}_button.c once the epd13in3e driver's #error stopped
masking it.

Each button file now branches on that capability macro: the C6 path
(devkit/xiao) is untouched, and ESP32-S3 uses
esp_sleep_enable_ext1_wakeup_io() instead. The earlier ext1 attempt was
rejected on C6 hardware because its pull resistor didn't hold across
RTC_PERIPH power-down -- tracing the same path in ESP-IDF source shows
gpio_config()'s pull_up_en already delegates to rtc_gpio_pullup_en()
for RTC-capable pins on every non-original-ESP32 target, so the pull-up
should already survive the same power-down on S3. The _io() variant is
additive, so the three button files don't need cross-file mask
coordination. Also widens the button GPIO Kconfig range for
IDF_TARGET_ESP32S3 (0-21, matching its RTC-IO set) instead of the
C6-shaped 0-7.

Verified: ee02, devkit, and xiao all build clean end-to-end locally
(native ESP-IDF v6.0, no Docker in this sandbox). NOT verified: whether
this actually avoids the spurious-instant-wakeup bug on real EE02
hardware -- that failure mode was only ever confirmed empirically, not
root-caused in a way a compile can check. continue-on-error stays on
in CI's ee02 build step until that's confirmed.
2026-08-04 22:46:13 +00:00
tfaour 454c03586e Port real epd13in3e driver from vendor code; fix wire-raster stride bug
Build and push server image / test (push) Successful in 43s
Firmware build check / build-check (push) Successful in 2m47s
Build and push server image / build-and-push (push) Successful in 4m34s
Build and push server image / deploy (push) Failing after 1m27s
Vendored the panel's init/LUT/refresh register sequence from three
independent Waveshare reference drivers for this exact panel+controller
(RaspberryPi/c, ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example),
which all agree byte-for-byte. The epd13in3e.c #error is gone; it
compiles clean and links (verified via /build-firmware ee02).

That vendor code also revealed the panel's SPI wire raster is a native
1200x1600 (portrait), not 1600x1200 as previously assumed -- rotated 90
degrees from the panel's landscape mount/marketing size. The old
assumption wasn't just a rotation bug: 1600x1200 and 1200x1600 don't
share a row stride, so packing at the wrong one would have shredded
images into a repeating diagonal garble on real hardware, not just
displayed them sideways. Fixed with a new PANEL_WIRE_TRANSPOSE in
image_pipeline.py, applied after the existing per-frame
ORIENTATION_TRANSPOSE, with a direction-agnostic regression test that
catches the stride bug specifically (a byte-count check alone can't,
since both orientations pack to the same total size).

A full ee02 build still fails, but no longer because of this driver --
main/{back,next,combo}_button.c call an ESP32-C6-only deep-sleep
GPIO-wakeup API with no ESP32-S3 fallback, a separate pre-existing gap
that was simply hidden behind the panel driver's old #error. See
docs/hardware.md for details; CI's continue-on-error on this board
stays in place until that's fixed too.
2026-08-04 22:00:18 +00:00
tfaour fe5a2df074 Update remaining docs for the second panel/board (missed in the previous commit)
Build and push server image / test (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 3m32s
Build and push server image / deploy (push) Failing after 1m28s
docs/hardware.md and firmware/README.md were updated already; this
catches the root README, docs/architecture.md, docs/widgets.md, and
server/README.md -- all still described the project as single-panel/
single-chip (800x480, ESP32-C6 only) even after image_pipeline.py
stopped hardcoding that.
2026-08-04 20:50:29 +00:00
tfaour 474b92a282 Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s
Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/
xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO
module -- "xiao" alone stopped disambiguating hardware. The server
keeps accepting the legacy bare names indefinitely for already-flashed
devices.

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
2026-08-04 20:08:22 +00:00
tfaour 1d39e439ff Drop the last legacy widget-system and shared-token auth scaffolding
Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns
(mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc)
docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup,
with a raw-SQL backfill safety net for any frame that still somehow
lacks a Widget. Also drops legacy_token_enabled and the shared
MANAGEMENT_TOKEN fallback it gated in require_device/require_browser --
the per-frame manage_token/device_token flow (and the /m/ page) fully
supersede it now; MANAGEMENT_TOKEN's only remaining role is the
optional pre-setup claim gate. Confirmed with the maintainer that the
deployed frame is already off the shared token before removing the
server-side fallback.

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

Direction was picked from three divergent mockups reviewed with the
maintainer, then verified against the real render pipeline (actual
Chromium render, actual ordered dithering, actual theme system) rather
than just eyeballed -- that caught a day-section/month-grid divider
color (#e2e6ec) that's nowhere near this panel's 6-color palette and was
dithering to invisible white; fixed with a real black hairline in the
one place (month view) that still needed one.
2026-08-01 11:53:42 +00:00
tfaour 466efdb873 Deploy: docker compose down before pull/up, not just up -d
Build and push server image / test (push) Successful in 40s
Build and push server image / build-and-push (push) Successful in 3m29s
Build and push server image / deploy (push) Failing after 1m21s
up -d alone assumes the previous container releases port 8420 cleanly
before the new one binds -- it doesn't force that. The last three
deploys all failed with "port is already allocated" at exactly that
step; running compose down first (confirmed working when done manually
over SSH) guarantees the old container is fully stopped and removed
before the new one starts, closing the race.
2026-07-31 12:35:14 +00:00
tfaour e363db0e4e Clarify what a theme actually changes beyond the header accent
A theme's font_family applies to every text element in a modern-style
widget, not just the header title, and radius/shadow change the card's
whole look -- worth spelling out since the accent color alone
undersells how much a theme actually does, especially on text-heavy
widgets, and on the two widget kinds (battery, static/whiteboard) that
have no header at all.
2026-07-31 12:35:07 +00:00
87 changed files with 3246 additions and 1132 deletions
+25 -21
View File
@@ -1,10 +1,11 @@
--- ---
name: build-firmware 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. description: Compile the espresso_frame firmware (firmware/) for all three board variants (ESP32-C6 devkit/xiao, ESP32-S3 ee02) 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 the devkit/xiao/ee02 board targets.
--- ---
Compiles `firmware/` (ESP-IDF, targeting ESP32-C6) locally, without Compiles `firmware/` (ESP-IDF, targeting ESP32-C6 for devkit/xiao and
Docker -- CI's `firmware-build-check.yml`/`firmware-release-build.yml` ESP32-S3 for ee02) locally, without Docker -- CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
build inside the `espressif/idf:release-v6.0` container image, but build inside the `espressif/idf:release-v6.0` container image, but
**this sandbox cannot run containers at all**: `docker.io` installs and **this sandbox cannot run containers at all**: `docker.io` installs and
`dockerd` starts fine even as root, but the sandbox strips `dockerd` starts fine even as root, but the sandbox strips
@@ -39,13 +40,13 @@ had neither):
of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches
the IDF version CI's Docker image pins. Only clones once; re-running the IDF version CI's Docker image pins. Only clones once; re-running
`setup.sh` never touches an existing checkout. `setup.sh` never touches an existing checkout.
- The esp32c6 toolchain + Python venv, via ESP-IDF's own - The esp32c6+esp32s3 toolchains + Python venv, via ESP-IDF's own
`./install.sh esp32c6` -- scoped to just this project's one target `./install.sh esp32c6,esp32s3` -- scoped to just this project's two
(see `firmware/README.md`'s board table), not every chip ESP-IDF chip targets (see `firmware/README.md`'s board table: devkit/xiao are
supports, to keep the download/disk footprint down. `install.sh` is esp32c6, ee02 is esp32s3), not every chip ESP-IDF supports, to keep
already idempotent on its own, so `setup.sh` always calls it rather the download/disk footprint down. `install.sh` is already idempotent
than duplicating that check -- a re-run costs a few seconds once on its own, so `setup.sh` always calls it rather than duplicating
everything's cached. 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 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 downloads), well under a minute on a re-run. Needs real root (`apt-get
@@ -63,15 +64,17 @@ checkout itself). Confirmed working with as little as ~7GB free.
```bash ```bash
bash .claude/skills/build-firmware/build.sh # devkit (default) bash .claude/skills/build-firmware/build.sh # devkit (default)
bash .claude/skills/build-firmware/build.sh xiao bash .claude/skills/build-firmware/build.sh xiao
bash .claude/skills/build-firmware/build.sh both # both variants bash .claude/skills/build-firmware/build.sh ee02
bash .claude/skills/build-firmware/build.sh both # devkit + xiao
bash .claude/skills/build-firmware/build.sh all # devkit + xiao + ee02
``` ```
Each board gets its own build directory and generated sdkconfig (see Each board gets its own build directory and generated sdkconfig (see
`firmware/build_for_board.sh`'s own comment) -- building one never `firmware/build_for_board.sh`'s own comment) -- building one never
disturbs the other. `build.sh` auto-runs `set-target esp32c6` the very disturbs the other. `build.sh` auto-runs `set-target` (esp32c6 for
first time a board is built (no generated sdkconfig yet); later builds devkit/xiao, esp32s3 for ee02) the very first time a board is built (no
skip straight to `idf.py build`. Extra arguments pass straight through generated sdkconfig yet); later builds skip straight to `idf.py build`.
to `idf.py`, e.g.: Extra arguments pass straight through to `idf.py`, e.g.:
```bash ```bash
bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0 bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0
@@ -83,15 +86,16 @@ somewhere hardware is actually plugged in (a real dev machine, or a
differently-configured environment with device passthrough). differently-configured environment with device passthrough).
A clean build of one board takes ~30s once the target's already been 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 configured (~1,000 build steps total split across the boards, most of
it ESP-IDF's own components -- this project's own `firmware/main/*.c` it ESP-IDF's own components -- this project's own `firmware/main/*.c`
and `firmware/components/*` sources are a small fraction of that and and `firmware/components/*` sources are a small fraction of that and
compile in a few seconds). Output lands at compile in a few seconds). Output lands at
`firmware/build/espresso_frame.bin` (devkit) or `firmware/build/espresso_frame.bin` (devkit),
`firmware/build_xiao/espresso_frame.bin` (xiao) -- both paths are `firmware/build_xiao/espresso_frame.bin` (xiao), or
gitignored (`firmware/.gitignore`... actually the repo root `firmware/build_ee02/espresso_frame.bin` (ee02, once its driver actually
`.gitignore`'s "ESP-IDF firmware build output" section), so nothing compiles -- see the note above) -- all three paths are gitignored (the
here needs cleaning up before a commit. repo root `.gitignore`'s "ESP-IDF firmware build output" section), so
nothing here needs cleaning up before a commit.
## Verified ## Verified
+32 -14
View File
@@ -2,16 +2,25 @@
# Builds (or flashes/monitors, if a serial port is actually attached) # Builds (or flashes/monitors, if a serial port is actually attached)
# the espresso_frame firmware for one board variant, via the project's # the espresso_frame firmware for one board variant, via the project's
# own firmware/build_for_board.sh -- this script just sources the # own firmware/build_for_board.sh -- this script just sources the
# ESP-IDF environment first and auto-runs `set-target esp32c6` on a # ESP-IDF environment first and auto-runs `set-target` (esp32c6 for
# board's very first build (a fresh clone has no generated sdkconfig # devkit/xiao, esp32s3 for ee02) on a board's very first build (a fresh
# yet, same reasoning as CI's own build steps -- see firmware/README.md's # clone has no generated sdkconfig yet, same reasoning as CI's own build
# "Building for the Seeed XIAO ESP32-C6" section). # steps -- see firmware/README.md's "Building for the Seeed XIAO
# ESP32-C6" section).
#
# NOTE: ee02 builds will fail to compile -- deliberately -- until
# firmware/components/epd13in3e's panel driver is ported from vendor
# demo code (see that component's own top-of-file comment). The build
# plumbing itself (target selection, partition table, sdkconfig
# layering) is exercised regardless; only the final compile step fails.
# #
# Usage: # Usage:
# build.sh # build devkit (default) # build.sh # build devkit (default)
# build.sh devkit # build.sh devkit
# build.sh xiao # build.sh xiao
# build.sh both # build both board variants # build.sh ee02
# build.sh both # build devkit + xiao (unchanged meaning)
# build.sh all # build devkit + xiao + ee02
# build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached # build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached
set -euo pipefail set -euo pipefail
@@ -33,16 +42,17 @@ cd "$firmware_dir"
build_one() { build_one() {
local board="$1" local board="$1"
shift shift
local sdkconfig local sdkconfig target
case "$board" in case "$board" in
devkit) sdkconfig="sdkconfig" ;; devkit) sdkconfig="sdkconfig"; target="esp32c6" ;;
xiao) sdkconfig="sdkconfig.xiao_local" ;; xiao) sdkconfig="sdkconfig.xiao_local"; target="esp32c6" ;;
*) echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2; exit 1 ;; ee02) sdkconfig="sdkconfig.ee02_local"; target="esp32s3" ;;
*) echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2; exit 1 ;;
esac esac
if [ ! -f "$sdkconfig" ]; then if [ ! -f "$sdkconfig" ]; then
echo "==> $board: no generated sdkconfig yet, setting target esp32c6" echo "==> $board: no generated sdkconfig yet, setting target $target"
./build_for_board.sh "$board" set-target esp32c6 ./build_for_board.sh "$board" set-target "$target"
fi fi
local args=("$@") local args=("$@")
@@ -55,9 +65,17 @@ build_one() {
board="${1:-devkit}" board="${1:-devkit}"
shift || true shift || true
if [ "$board" = "both" ]; then case "$board" in
both)
build_one devkit "$@" build_one devkit "$@"
build_one xiao "$@" build_one xiao "$@"
else ;;
all)
build_one devkit "$@"
build_one xiao "$@"
build_one ee02 "$@"
;;
*)
build_one "$board" "$@" build_one "$board" "$@"
fi ;;
esac
+7 -6
View File
@@ -45,14 +45,15 @@ else
echo "esp-idf already cloned at $IDF_DIR" echo "esp-idf already cloned at $IDF_DIR"
fi fi
# 3. Toolchain + Python virtualenv, scoped to esp32c6 only -- this # 3. Toolchain + Python virtualenv, scoped to esp32c6+esp32s3 only --
# project's one target (see firmware/README.md's board table). Scoping # this project's two chip targets (see firmware/README.md's board
# avoids downloading toolchains for every chip ESP-IDF supports, which # table: devkit/xiao are esp32c6, ee02 is esp32s3). Scoping avoids
# matters given this container's disk headroom. install.sh is already # 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 # idempotent on its own (checks what's present and skips it), so this
# always calls it rather than trying to duplicate that check here -- # always calls it rather than trying to duplicate that check here --
# a re-run only costs a few seconds once everything's cached. # a re-run only costs a few seconds once everything's cached.
echo "running esp-idf install.sh esp32c6 (fast if already installed) ..." echo "running esp-idf install.sh esp32c6,esp32s3 (fast if already installed) ..."
(cd "$IDF_DIR" && ./install.sh esp32c6) (cd "$IDF_DIR" && ./install.sh esp32c6,esp32s3)
echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)" echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)"
+28 -3
View File
@@ -3,9 +3,11 @@ name: Firmware build check
# Fires on every push touching firmware source, unlike # Fires on every push touching firmware source, unlike
# firmware-release-build.yml (which only builds+publishes when # firmware-release-build.yml (which only builds+publishes when
# firmware/version.txt itself is bumped -- the "cut a release" signal). # firmware/version.txt itself is bumped -- the "cut a release" signal).
# This just verifies both board variants still compile; nothing else in # This just verifies every board variant still compiles (or, for ee02,
# CI catches a firmware/** push that breaks the build until someone # that everything up to its known/tracked #error still compiles --
# happens to bump the version next. # see that step's own comment); nothing else in CI catches a
# firmware/** push that breaks the build until someone happens to bump
# the version next.
on: on:
push: push:
branches: [main] branches: [main]
@@ -49,3 +51,26 @@ jobs:
docker cp "$PWD/." "$cid:/workspace" docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid" docker start -a "$cid"
docker rm "$cid" docker rm "$cid"
# Expected to fail until firmware/components/epd13in3e's panel
# driver is ported from vendor demo code (deliberate #error, see
# that file's own top comment) -- continue-on-error so this known
# gap doesn't block every other firmware/** push. Still worth
# running: catches a regression in the surrounding scaffolding
# (Kconfig, main/CMakeLists.txt's component selection, sdkconfig
# layering) up to the point of that #error, same value a build
# check normally provides. Remove continue-on-error once
# epd13in3e's driver is real, so a build failure here goes back to
# being a genuine regression signal.
- name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
continue-on-error: true
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh ee02 set-target esp32s3 &&
./build_for_board.sh ee02 build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
+60 -8
View File
@@ -18,7 +18,7 @@ jobs:
# actions) is a Node action that gets exec'd *inside* whatever container # actions) is a Node action that gets exec'd *inside* whatever container
# the job specifies, so checkout fails immediately with "node: not # the job specifies, so checkout fails immediately with "node: not
# found" (hit this on the first real run). Checkout instead runs on the # found" (hit this on the first real run). Checkout instead runs on the
# plain runner (which has Node), and only the two build steps below # plain runner (which has Node), and only the three build steps below
# spin up the ESP-IDF image themselves (docker create/cp/start, see the # spin up the ESP-IDF image themselves (docker create/cp/start, see the
# comment on those steps for why not a plain `docker run -v`) -- the # comment on those steps for why not a plain `docker run -v`) -- the
# runner already bind-mounts the host's docker socket, so docker-in- # runner already bind-mounts the host's docker socket, so docker-in-
@@ -33,11 +33,13 @@ jobs:
id: version id: version
run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT" run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"
# Two board variants, two partition tables/flash sizes (see # Three board variants: devkit/xiao (ESP32-C6, different partition
# firmware/README.md's "Building for the Seeed XIAO ESP32-C6" # tables/flash sizes -- see firmware/README.md's "Building for the
# section) -- build_for_board.sh gives each its own build dir/ # Seeed XIAO ESP32-C6" section) and ee02 (ESP32-S3 + 13.3" panel,
# generated sdkconfig so this never fights over shared state. # a genuinely different chip target, not just a Kconfig variant).
# set-target first since a fresh checkout has no cached sdkconfig # build_for_board.sh gives each its own build dir/generated
# sdkconfig so this never fights over shared state. set-target
# first since a fresh checkout has no cached sdkconfig
# (firmware/sdkconfig* is gitignored, see firmware/.gitignore). # (firmware/sdkconfig* is gitignored, see firmware/.gitignore).
# safe.directory guards against git's "dubious ownership" check, # safe.directory guards against git's "dubious ownership" check,
# since the container runs as root over content owned by a # since the container runs as root over content owned by a
@@ -64,8 +66,15 @@ jobs:
') ')
docker cp "$PWD/." "$cid:/workspace" docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid" docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit.bin docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit_esp32c6.bin
docker rm "$cid" docker rm "$cid"
# Rename bridge: fielded devices flashed before this rename still
# report the bare "devkit" board name and look up "firmware-
# devkit.bin" for their OTA check -- publish a duplicate under
# the old name too so they can update at all. Safe to drop this
# duplicate in a later release once no fielded device reports
# the bare name anymore.
cp /tmp/release-assets/firmware-devkit_esp32c6.bin /tmp/release-assets/firmware-devkit.bin
- name: Build (xiao -- Seeed XIAO ESP32-C6) - name: Build (xiao -- Seeed XIAO ESP32-C6)
run: | run: |
@@ -77,7 +86,35 @@ jobs:
') ')
docker cp "$PWD/." "$cid:/workspace" docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid" docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao.bin docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao_esp32c6.bin
docker rm "$cid"
# Same rename-bridge reasoning as the devkit step above.
cp /tmp/release-assets/firmware-xiao_esp32c6.bin /tmp/release-assets/firmware-xiao.bin
# NOTE: this build is expected to FAIL until
# firmware/components/epd13in3e's panel driver is ported from
# vendor demo code (see that component's own top-of-file comment
# -- a deliberate #error, not a bug here). `continue-on-error` so
# this known, tracked gap doesn't block publishing the devkit/xiao
# release (those boards work today and shouldn't wait on ee02) --
# this step's own status still shows failed/red individually in
# the run's step list, it just doesn't fail the overall job. Once
# epd13in3e's driver is real, a build failure here becomes a
# genuine regression again -- remove `continue-on-error` at that
# point so it goes back to failing the job like the other two
# builds do.
- name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
continue-on-error: true
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh ee02 set-target esp32s3 &&
./build_for_board.sh ee02 build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build_ee02/espresso_frame.bin" /tmp/release-assets/firmware-ee02.bin
docker rm "$cid" docker rm "$cid"
# Plain stdlib urllib rather than `requests` -- not guaranteed to be # Plain stdlib urllib rather than `requests` -- not guaranteed to be
@@ -149,10 +186,25 @@ jobs:
existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])} existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])}
assets = [ assets = [
("firmware-devkit_esp32c6.bin", "/tmp/release-assets/firmware-devkit_esp32c6.bin"),
("firmware-xiao_esp32c6.bin", "/tmp/release-assets/firmware-xiao_esp32c6.bin"),
("firmware-ee02.bin", "/tmp/release-assets/firmware-ee02.bin"),
# Rename-bridge duplicates for devices still on old firmware
# reporting the bare "devkit"/"xiao" board names -- see the
# build steps above. Safe to remove once no fielded device
# reports the bare name anymore.
("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"), ("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"),
("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"), ("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
] ]
for name, path in assets: for name, path in assets:
if not os.path.exists(path):
# Expected for firmware-ee02.bin while that build is
# still allowed to fail (continue-on-error, see the
# build step's own comment) -- publish whatever boards
# did build rather than crashing the whole release over
# a known, tracked gap.
print(f"Skipping {name}: build did not produce {path}")
continue
if name in existing_assets: if name in existing_assets:
del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}") del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
print(f"Removed existing asset {name} (status {del_status})") print(f"Removed existing asset {name} (status {del_status})")
+22 -2
View File
@@ -68,5 +68,25 @@ jobs:
chmod 600 ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \ ssh -i ~/.ssh/deploy_key -p "$DEPLOY_PORT" -o StrictHostKeyChecking=yes \
espressoframe_deployer@"$DEPLOY_HOST" \ espressoframe_deployer@"$DEPLOY_HOST" bash -s <<'REMOTE'
'cd ~/espresso-frame && docker compose pull && docker compose up -d' set -e
cd ~/espresso-frame
docker compose down --remove-orphans
docker compose pull
# "down" returning doesn't guarantee the OS/docker-proxy has
# actually released port 8420 yet -- an immediate "up -d" right
# after (especially with "pull" a no-op because the image was
# already cached) can lose that race and fail with "port is
# already allocated", even though the exact same "up -d" run a
# few seconds later succeeds every time. Retry instead of
# guessing at a fixed sleep long enough to always cover it.
for i in $(seq 1 10); do
if docker compose up -d; then
exit 0
fi
echo "docker compose up -d failed (attempt $i/10) -- retrying in 3s"
sleep 3
done
echo "docker compose up -d did not succeed after 10 attempts"
exit 1
REMOTE
+3
View File
@@ -10,6 +10,9 @@ firmware/dependencies.lock
firmware/build_xiao/ firmware/build_xiao/
firmware/sdkconfig.xiao_local firmware/sdkconfig.xiao_local
firmware/sdkconfig.xiao_local.old firmware/sdkconfig.xiao_local.old
firmware/build_ee02/
firmware/sdkconfig.ee02_local
firmware/sdkconfig.ee02_local.old
# Python server # Python server
server/__pycache__/ server/__pycache__/
+1 -2
View File
@@ -15,8 +15,7 @@ Start here, don't re-derive from scratch:
- [`docs/architecture.md`](docs/architecture.md) -- how firmware and - [`docs/architecture.md`](docs/architecture.md) -- how firmware and
server talk (sequence diagram, boot flow). server talk (sequence diagram, boot flow).
- [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system - [`docs/widgets.md`](docs/widgets.md) -- the server-side widget system
(data model, grid placement, compositor, button-action dispatch). Notes (data model, grid placement, compositor, button-action dispatch).
a known gap at the bottom (legacy `Frame` columns not yet dropped).
- [`docs/hardware.md`](docs/hardware.md) -- wiring. - [`docs/hardware.md`](docs/hardware.md) -- wiring.
- [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md) - [`server/README.md`](server/README.md), [`firmware/README.md`](firmware/README.md)
-- per-component setup, config, and a lot of accumulated gotchas -- per-component setup, config, and a lot of accumulated gotchas
+18 -8
View File
@@ -1,9 +1,12 @@
# ESPresso Frame # ESPresso Frame
A DIY e-ink photo frame: an ESP32-C6 pulls photos from your A DIY e-ink photo frame: an ESP32 board pulls photos from your
[Immich](https://immich.app) library and displays them on a 7.3" full-color [Immich](https://immich.app) library and displays them on a full-color
e-paper panel, waking on a timer to refresh and spending the rest of its e-paper panel, waking on a timer to refresh and spending the rest of its
time in deep sleep. time in deep sleep. The original build is a 7.3" panel on an ESP32-C6;
a larger 13.3" panel on Seeed's EE02 (ESP32-S3) is supported
server-side, but its firmware driver isn't working yet -- see
[`docs/hardware.md`](docs/hardware.md).
- **No cables to a computer, no SD card shuffling.** Provisioning is a - **No cables to a computer, no SD card shuffling.** Provisioning is a
captive portal with a QR code drawn on the panel itself -- scan, join, captive portal with a QR code drawn on the panel itself -- scan, join,
@@ -12,7 +15,9 @@ time in deep sleep.
all the work (pulling from Immich, cropping, dithering, packing into all the work (pulling from Immich, cropping, dithering, packing into
the panel's exact pixel format) and hands the device a stream it can the panel's exact pixel format) and hands the device a stream it can
write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to
spare -- keeping it a dumb display client is what makes that workable. spare -- keeping it a dumb display client is what makes that workable
(the same design carries over to the ESP32-S3 board even though it
does have PSRAM, for consistency).
- **Crops toward faces, not just the center**, using face bounding boxes - **Crops toward faces, not just the center**, using face bounding boxes
Immich already computed for its own People feature -- no bundled face Immich already computed for its own People feature -- no bundled face
detector. detector.
@@ -21,8 +26,13 @@ time in deep sleep.
## Hardware ## Hardware
- ESP32-C6 dev board (8MB flash) - ESP32-C6 dev board (8MB flash), or Seeed's XIAO ESP32-C6 (production
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI board) -- both drive the panel below.
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI.
- Experimental, not yet working: [Waveshare 13.3" E Ink Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm)
(1600x1200) on [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html)
(ESP32-S3) -- server-side support exists, but the firmware driver's
panel init sequence isn't ported from vendor code yet.
See [`docs/hardware.md`](docs/hardware.md) for wiring, See [`docs/hardware.md`](docs/hardware.md) for wiring,
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk [`docs/architecture.md`](docs/architecture.md) for how the two halves talk
@@ -34,14 +44,14 @@ placeable photos/calendar/whiteboard widget system.
1. **[`server/`](server/)** -- run the FastAPI server first (Docker 1. **[`server/`](server/)** -- run the FastAPI server first (Docker
Compose, points at your Immich instance). See Compose, points at your Immich instance). See
[`server/README.md`](server/README.md). [`server/README.md`](server/README.md).
2. **[`firmware/`](firmware/)** -- build and flash the ESP32-C6, then 2. **[`firmware/`](firmware/)** -- build and flash the board, then
scan the QR codes it draws on first boot to provision it. See scan the QR codes it draws on first boot to provision it. See
[`firmware/README.md`](firmware/README.md). [`firmware/README.md`](firmware/README.md).
## Repo layout ## Repo layout
``` ```
firmware/ ESP-IDF project for the ESP32-C6 firmware/ ESP-IDF project (ESP32-C6 devkit/xiao boards, ESP32-S3 ee02)
server/ FastAPI server: Immich -> crop/dither/pack -> the frame server/ FastAPI server: Immich -> crop/dither/pack -> the frame
docs/ Wiring and architecture notes docs/ Wiring and architecture notes
``` ```
+11 -7
View File
@@ -3,14 +3,15 @@
Two independent pieces talk over HTTP or HTTPS (the server itself always Two independent pieces talk over HTTP or HTTPS (the server itself always
speaks plain HTTP; HTTPS means a reverse proxy in front of it, see speaks plain HTTP; HTTPS means a reverse proxy in front of it, see
[`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local [`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local
network: the ESP32-C6 firmware, and a small FastAPI server that sits network: the ESP32 firmware (ESP32-C6 for the devkit/xiao boards,
between it and Immich. ESP32-S3 for ee02 -- see [`docs/hardware.md`](hardware.md)), and a small
FastAPI server that sits between it and Immich.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Immich participant Immich
participant Server as ESPresso Frame Server participant Server as ESPresso Frame Server
participant Frame as ESP32-C6 Frame participant Frame as ESP32 Frame
Note over Frame: First boot / never provisioned Note over Frame: First boot / never provisioned
Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel
@@ -33,7 +34,7 @@ sequenceDiagram
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel) Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
Immich-->>Server: JPEG + face bounding boxes Immich-->>Server: JPEG + face bounding boxes
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
Server-->>Frame: 192,000 raw bytes, streamed Server-->>Frame: packed 4bpp bytes, streamed<br/>(192,000 for the 7.3" panel; sized to whichever<br/>panel this frame's device reports, see Frame.panel_type)
Frame->>Frame: Write to panel SPI buffer, compute CRC32 Frame->>Frame: Write to panel SPI buffer, compute CRC32
alt CRC unchanged since last physical refresh alt CRC unchanged since last physical refresh
Frame->>Frame: Skip refresh (nothing visually changed) Frame->>Frame: Skip refresh (nothing visually changed)
@@ -83,8 +84,9 @@ placement grid, and button-action dispatch.
once). once).
- Fetch the frame and write it into the panel's SPI buffer - Fetch the frame and write it into the panel's SPI buffer
(`epd_write_frame()`), computing a CRC32 as it streams -- never (`epd_write_frame()`), computing a CRC32 as it streams -- never
buffering the full ~192KB frame in RAM. The panel driver refuses to buffering the full packed frame in RAM (~192KB for the 7.3" panel;
write a short/wrong-size response into the buffer at all, so a proportionally more for the 13.3" panel). The panel driver refuses
to write a short/wrong-size response into the buffer at all, so a
truncated fetch can't corrupt what's already there. truncated fetch can't corrupt what's already there.
- Compare the new CRC32 against the last one that was actually - Compare the new CRC32 against the last one that was actually
refreshed onto the panel (persisted in NVS). If it matches -- the refreshed onto the panel (persisted in NVS). If it matches -- the
@@ -117,7 +119,9 @@ git history). Decoding a JPEG, then resizing/dithering/quantizing it to
the panel's 6-color palette, would be expensive on-device in both memory the panel's 6-color palette, would be expensive on-device in both memory
and battery. Instead, the server does all of that with Pillow and hands and battery. Instead, the server does all of that with Pillow and hands
the frame a pre-packed, ready-to-stream buffer -- the device never the frame a pre-packed, ready-to-stream buffer -- the device never
decodes an image at all. decodes an image at all. The ee02 board's ESP32-S3 does have PSRAM, but
the same server-side design applies there too, for consistency and
battery reasons rather than because the C6's memory limit forces it.
## Why face detection isn't run on-device (or even on the server) ## Why face detection isn't run on-device (or even on the server)
+120
View File
@@ -152,3 +152,123 @@ photo. A full-color refresh on this panel takes 15-30+ seconds and draws
more current than deep sleep by a wide margin -- expect battery life (if more current than deep sleep by a wide margin -- expect battery life (if
not running from USB power) to be dominated by refresh frequency, not not running from USB power) to be dominated by refresh frequency, not
sleep current. sleep current.
## Board identifiers
Each board reports a name to the server (`X-Frame-Board`,
`CONFIG_FRAME_BOARD_NAME`) that's chip-qualified rather than the plain
`devkit`/`xiao` older firmware used -- `devkit_esp32c6`, `xiao_esp32c6`,
`ee02` (see below). This changed once a second XIAO-based board (EE02,
an ESP32-S3) existed and "xiao" alone stopped disambiguating hardware.
The server keeps accepting the old bare names indefinitely, since
already-flashed devices can't be retroactively renamed.
## 13.3" Spectra 6 panel on Seeed's EE02 board (panel driver ported, `ee02` builds end-to-end; unverified on real hardware)
A second panel size is supported server-side (the web UI shows a
read-only "Panel: 13.3\" Spectra 6" once a frame's device reports
itself as `ee02`), and **the panel driver itself is now real and
compiles clean** -- `firmware/components/epd13in3e`'s init/LUT/refresh
register sequence is a line-for-line port of Waveshare's own reference
drivers for this exact panel+controller, confirmed identically across
three independent vendor sources (Waveshare's RaspberryPi/c and ESP32
drivers for this panel, plus Waveshare's own ESP-IDF example for their
ESP32-S3-ePaper-13.3E6 driver board -- a different carrier than EE02,
but the same panel/controller, hence the same command bytes). See that
component's own top comment for details, and
`server/app/image_pipeline.py`'s `PANEL_WIRE_TRANSPOSE` for a load-bearing
correction that came with it: the panel's SPI wire raster is a *native
1200x1600 (portrait)* raster, rotated 90 degrees from the panel's
1600x1200 landscape mount/marketing size -- getting that backwards
doesn't just rotate the image, it shreds it (1600x1200 and 1200x1600
don't share a row stride).
**A full `ee02` build now succeeds** (verified locally with a native,
non-Docker ESP-IDF v6.0 install -- see
`.claude/skills/build-firmware/SKILL.md`); `firmware/main/{back,next,combo}_button.c`
used to call `esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown()`, an
ESP32-C6-only deep-sleep GPIO-wakeup API (gated by
`SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`, which ESP32-S3's
`soc_caps.h` doesn't define) with no ESP32-S3 fallback path. Each of the
three button files now branches on that same capability macro: the
ESP32-C6 path (devkit/xiao) is untouched, and a new ESP32-S3 path uses
`esp_sleep_enable_ext1_wakeup_io()` (not the non-`_io()`
`esp_sleep_enable_ext1_wakeup()`, which resets any previously-registered
mask -- the `_io()` variant is additive, confirmed by reading
`esp_hw_support/sleep_modes.c`, so the three button files can each keep
registering their own GPIO independently, no combined-mask coordination
needed) plus `esp_sleep_get_ext1_wakeup_status()` for the wake-cause
check. The original C6 EXT1 attempt was rejected on hardware because
its pull resistor didn't hold across the RTC_PERIPH power-down (see
`firmware/main/next_button.c`'s `next_button_init()` comment) -- tracing
the same code path for ESP32-S3 shows `gpio_config()`'s `pull_up_en`
(already used by all three button files) delegates to
`rtc_gpio_pullup_en()` for RTC-capable pins on every non-original-ESP32
target (confirmed in `esp_driver_gpio/gpio.c`: `GPIO_RTCIO_ARE_INDEPENDENT`
is 1 for both C6 and S3, meaning the digital and RTC pull registers are
independent hardware and `gpio_config()` already sets the RTC one), so
the pull-up should already survive the same power-down on ESP32-S3
without any extra `rtc_gpio_*` calls. That reasoning is verified against
IDF source, **not against real EE02 hardware** -- a clean compile
confirms the code builds and links, not that it's actually
spurious-wakeup-free on a real board. CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
`continue-on-error` on this board's step is intentionally still in place
until that hardware verification happens.
Confirmed so far:
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
1600x1200 mount size, 270.40x202.80mm, same 6-ink Spectra family as
the 7.3" panel (and, now vendor-confirmed, the identical 4-bit nibble
color codes). Full refresh ~19s. SPI wire raster is 1200x1600 (see
above).
- Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) --
a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated
driver PCB, one reset + three user buttons, JST 2.0mm battery
connector with built-in charging IC.
- Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic --
treat as a starting point, confirm before relying on it; Waveshare's
own ESP32-S3-ePaper-13.3E6 example uses different GPIO numbers, but
that's for Waveshare's own driver board, a different carrier than
EE02, so it doesn't apply here). Unlike epd7in3e's single chip-select,
this panel is driven as two halves sharing one CLK/MOSI/DC/RST/BUSY bus
with independent chip-selects -- now confirmed by the real driver code
too (master = left half, slave = right half of each row).
| Signal | GPIO | Kconfig option |
| --- | --- | --- |
| CLK | 7 | `EPD_PIN_CLK` |
| MOSI | 9 | `EPD_PIN_MOSI` |
| CS (master half) | 44 | `EPD_PIN_CS_MASTER` |
| CS (slave half) | 41 | `EPD_PIN_CS_SLAVE` |
| DC | 10 | `EPD_PIN_DC` |
| RST | 38 | `EPD_PIN_RST` |
| BUSY | 4 | `EPD_PIN_BUSY` |
| Panel power-enable | 43 | `EPD_PIN_POWER_EN` |
User buttons are reportedly at GPIO 2/3/5, but which physical button
maps to which logical role (next/back/menu) still isn't confirmed. The
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
`firmware/main/Kconfig.projbuild`) now range to GPIO -1 to 21 under
`IDF_TARGET_ESP32S3` (the ESP32-S3's own ext1-wakeup-capable RTC-IO
range) instead of the ESP32-C6-shaped -1 to 7, so GPIO 2/3/5 fit
regardless -- but `firmware/sdkconfig.ee02` still deliberately doesn't
override the defaults inherited from the C6 boards (GPIO 2/0/1) until
the role mapping above is confirmed.
SPI clock is reportedly reliable only up to 2MHz on this
panel/board per the community ESPHome integration (vs. epd7in3e's 4MHz
default) -- see `firmware/sdkconfig.ee02`. Waveshare's own
ESP32-S3-ePaper-13.3E6 example defaults to 10MHz, but that's a
different carrier board, so it's a data point to try once real EE02
hardware exists, not a reason to bump the current conservative default
blind.
Remaining unknowns before trusting this on real hardware: whether the
ESP32-S3 button-wakeup path above actually avoids a spurious-instant-wakeup
on a real board (not just compiles), the button-to-role mapping, the
wiring table (community-sourced, not official), and
`PANEL_WIRE_TRANSPOSE`'s rotation *direction* (`ROTATE_90` vs
`ROTATE_270` -- a physical-assembly fact no vendor driver encodes, see
that dict's own comment in `image_pipeline.py`).
+118 -26
View File
@@ -6,10 +6,10 @@ weather/battery), like arranging icons on an Android home screen. A frame
can hold several widgets of the same type (e.g. two photo widgets pointed can hold several widgets of the same type (e.g. two photo widgets pointed
at different Immich albums side by side). at different Immich albums side by side).
This replaced an earlier design where `Frame.mode` picked exactly one This replaced an earlier design where `Frame.mode` picked exactly one
full-panel renderer; that column (and the other now-dead per-mode `Frame` full-panel renderer; that column and the other per-mode `Frame` columns
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.) it left behind (`album_id`, `calendar_*`, `whiteboard_*`, etc.) were
is still physically present but unused, pending a final cleanup migration dropped in migration 41, once every phase of the rollout had shipped
(see "Known gaps" below). (see "Known gaps" below for what's still open).
The device-facing contract is unchanged by any of this: `GET /frame/image`, The device-facing contract is unchanged by any of this: `GET /frame/image`,
`POST /frame/advance`, `POST /frame/back` are the same frozen paths `POST /frame/advance`, `POST /frame/back` are the same frozen paths
@@ -42,6 +42,20 @@ a button press does.
`api_widget_config_save`) since that endpoint's per-type dispatch is `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 keyed on a config row via `widget_locked`, and border fields live on
`Widget` itself, not any per-type config table. `Widget` itself, not any per-type config table.
Also carries `font_scale` (one of `panel_style.FONT_SCALE_CHOICES` --
`1.0`/`1.25`/`1.5`, labeled Normal/Large/X-Large), a per-widget
legibility control: calendar and tasks widgets pack in the most body
text at the smallest default sizes, so their gear-icon dialogs get a
"Text size" card (`_widget_font_scale_fields.html`) the other types
don't. Same Widget-level-property-not-config-field reasoning as
border, and its own `POST .../widgets/{id}/font-scale` endpoint for
the same reason. `panel_style.scaled_size(value, font_scale)` is the
one shared multiply-and-round point every classic (`calendar_render.py`)
and modern (`html_render.py`/`calendar_html_render.py`) size calc
routes through immediately after its own tier lookup/floor, so row
heights and per-view row caps (already derived from the font size, not
a fixed constant) automatically re-fit around the bigger text instead
of overflowing their box.
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`, - Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`, `CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, `StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`,
@@ -146,7 +160,13 @@ Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
`_SIZE_TIERS`) for font size/margins/row heights based on their actual `_SIZE_TIERS`) for font size/margins/row heights based on their actual
grid footprint, rather than continuously scaling constants tuned for a grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible. for month view to stay legible. These tiers are pixel-size constants
tuned against the 7.3" panel specifically; they aren't re-tuned or
verified yet for the 13.3" panel's larger native resolution (see
`docs/hardware.md`'s EE02 section) -- a widget's *grid footprint* (cell
count) works the same on either panel, but its rendered legibility at
that footprint's actual pixel size hasn't been checked on the bigger
panel.
### "Modern" render style (experimental) ### "Modern" render style (experimental)
@@ -210,7 +230,70 @@ Per-widget-type notes:
either widget type has ever had (classic draws the image with zero either widget type has ever had (classic draws the image with zero
frame/card at all) -- a rounded-corner, shadowed card frame/card at all) -- a rounded-corner, shadowed card
(`framed_image.html.jinja`, shared between the two) wrapping the (`framed_image.html.jinja`, shared between the two) wrapping the
already-composed image. already-composed image. Left alone by the "bold minimal" pass below --
it never had the reskinned-classic problem the other widgets did.
### "Bold minimal": a real redesign, not just a reskin
The initial modern-style rollout (above) mostly translated each widget's
*existing* classic layout into HTML/CSS -- same gradient header banner,
same rounded-shadowed white card, prettier chrome around an unchanged
composition. A second pass reworked weather (`current`/`daily`),
calendar (all four views), tasks, and battery into an actual different
visual language, picked from several divergent directions rendered
through the real pipeline and reviewed with the maintainer (not chosen
unilaterally -- see the "Reverted e-ink quantization attempt"-style
caution about visual changes needing more than one look). Text and
static/whiteboard were deliberately left as they were (see their notes
just above) -- text already had zero chrome and its styling is
user-authored content, not this system's to redesign; the framed-image
card was already minimal.
What changed, as a consistent language across every redesigned widget:
- **No card.** No rounded-corner white box, no drop shadow, no outer
border -- content sits directly on the shared white canvas. `theme
["radius"]`/`theme["shadow"]` are now unused by every redesigned
widget's builder (still resolved, for signature uniformity with
`resolve_theme`, but nothing reads them) -- a theme's radius/shadow
fields now only affect the *un*-redesigned modern widgets (static
image/whiteboard's `framed_image.html.jinja`).
- **A slim accent rule instead of a gradient banner.** Every widget that
used to have a colored header bar with white text on it (weather's
`build_daily`, tasks, calendar's four views) now has a thin (~4-8px)
accent-colored rounded rule, with the header text as plain ink below
it instead of white text on top of it -- only that thin rule dithers
at the theme's richer `accent_amplitude` via `ordered_dither_regions`
now, not the header text sitting on it, which reads as a legibility
improvement, not just a visual one (see "Rich accent hues" below).
- **A dominant hero value, not a centered icon+number of equal weight.**
Weather's `build_current` and battery's icon+percent used to be drawn
at roughly the same size, centered as a unit; both now put the numeric
value (temperature / battery percent) at a clearly dominant size, with
the icon small and secondary above it -- closer to a phone home-screen
widget than a dashboard tile.
- **Padding/type sizes as a proportion of widget size, clamped to a
floor/ceiling, not a fixed pixel value.** So a 1-2 grid-cell widget
doesn't get comically large padding relative to its content, and a
near-full-panel widget doesn't get comically small padding either --
see `html_render._clamp` and every redesigned `build_*`'s own
`pad`/size calculations (`base = min(target_w, target_h)`, then a
fraction of `base` clamped to tuned floor/ceiling values).
**A hairline color this palette can't actually render.** Auditing the
month view's grid during this pass turned up a real, pre-existing bug
carried forward unnoticed since the very first modern-style rollout:
`.day-cell`/`.day-section`/`.col` divider borders used a pale gray
(`#e2e6ec`) -- but `DEFAULT_PALETTE_RGB` has no gray in it at all (black/
white/yellow/red/blue/green only), so a color that close to white always
nearest-matches to pure white regardless of Bayer bias, at any amplitude
-- confirmed by sampling actual rendered pixels, not just eyeballing a
screenshot. The month grid's week-row dividers now use real solid black
(`RULE`-equivalent, matching how the *classic* PIL renderer always drew
them -- see `calendar_render.RULE`); the day-section/week-column dividers
were simply dropped instead, since the accent rule + spacing at the
start of the next section/column already read as a clear boundary
without a line at all once you could actually render one.
### Themes for modern-style widgets ### Themes for modern-style widgets
@@ -227,6 +310,18 @@ three-layer CSS custom-property theme system -- this is an original
reimplementation of that *architecture*, not a copy of its token file reimplementation of that *architecture*, not a copy of its token file
(see this repo's `CLAUDE.md` on copyleft dependencies). (see this repo's `CLAUDE.md` on copyleft dependencies).
**What a theme actually changes, in practice**: the accent color (now a
slim rule rather than a full header band -- see "Bold minimal" above) is
still the most visible change on widgets that have one, but `font_family`
applies to *every* text element in the widget, not just the header title
-- day labels, temperatures, task rows, event times, day numbers all
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
more noticeable than the accent color on text-heavy widgets. `radius`/
`shadow` only affect static image/whiteboard's card now (every other
modern-style widget dropped its card in the "bold minimal" pass); text
never used them (no card from the start) and weather/battery/tasks/
calendar no longer have a card for them to apply to either.
**A theme is purely stylistic, never functional color-coding.** Battery's **A theme is purely stylistic, never functional color-coding.** Battery's
charge-level red/yellow/green, calendar/tasks' per-owner event color charge-level red/yellow/green, calendar/tasks' per-owner event color
chips, and text's user-authored inline run colors are status/identity chips, and text's user-authored inline run colors are status/identity
@@ -268,13 +363,14 @@ fall back to black, though none of their templates currently have an
accent-colored surface for it to visibly affect. accent-colored surface for it to visibly affect.
Which widgets get the richer accent-region treatment: weather's Which widgets get the richer accent-region treatment: weather's
`build_daily` (the header bar, when `city_label` is set), tasks, and `build_daily` (the slim rule, when `city_label` is set), tasks, and
calendar's four view builders (each already computed a `header_h` in calendar's four view builders -- each computes its own small accent-rule
Python for layout, reused as the accent rect). Weather's `build_current`, pixel rect (a fixed-height band, not the old full header_h) and passes
battery, and static/whiteboard's shared `build_framed_image` are just that to `ordered_dither_regions`. Weather's `build_current` and
theme-aware for font/radius/shadow only -- no header/accent region to battery have no accent surface at all (no header of any kind -- see
dither richer, so they call plain `ordered_dither` exactly as before "Bold minimal" above) and static/whiteboard's shared `build_framed_image`
themes existed. is unchanged from the original rollout; all three call plain
`ordered_dither` with no accent region.
## Button actions ## Button actions
@@ -473,20 +569,16 @@ piece of code with its own fixed small size, not shared with this
widget, but drawing from the same thresholds/colors so a battery glyph widget, but drawing from the same thresholds/colors so a battery glyph
reads the same wherever one shows up on a panel. reads the same wherever one shows up on a panel.
## Known gaps (Phase 6, not yet done) ## Known gaps
The original 8-phase rollout plan's last phase is still open: The original 8-phase rollout plan's last phase is done: migration 41
dropped the legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, `queue`, etc.)
-- see its own docstring in `app/migration.py` for the raw-SQL backfill
safety net that ran first, and `server/README.md` no longer describes
photos/calendar/whiteboard as per-frame "modes".
Still open:
- Legacy per-mode `Frame` columns (`mode`, `album_id`,
`current_asset_id`, all `calendar_*`, all `whiteboard_*`, etc.) are
still physically present in the schema but no longer read or written
anywhere -- they need a dedicated final migration to drop them. Left in
place deliberately through the widget-system rollout (a much larger
blast radius cutover than this project's usual same-migration-drop
convention) but there's no reason to keep carrying them now that every
phase has shipped.
- `server/README.md` still describes photos/calendar/whiteboard as
per-frame "modes" in several places rather than widgets -- needs a pass
once the column drop above is safely deployed.
- Whiteboard rendering is tagged **(alpha)** in the UI -- not fully - Whiteboard rendering is tagged **(alpha)** in the UI -- not fully
reliable yet, treat it as experimental if extending it. reliable yet, treat it as experimental if extending it.
+41 -17
View File
@@ -1,6 +1,10 @@
# ESPresso Frame Firmware # ESPresso Frame Firmware
ESP-IDF firmware for the ESP32-C6. On first boot it provisions itself over ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or
ESP32-S3 (ee02 board, 13.3" panel -- see
[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
below; it builds end-to-end now, but is still unverified on real EE02
hardware). On first boot it provisions itself over
a WiFi captive portal; after that it wakes on a timer, fetches an a WiFi captive portal; after that it wakes on a timer, fetches an
already-processed frame from the [server](../server/), streams it straight already-processed frame from the [server](../server/), streams it straight
to the panel over SPI, and goes back to deep sleep. to the panel over SPI, and goes back to deep sleep.
@@ -48,6 +52,40 @@ never clobbers the other:
(`./build_for_board.sh devkit ...` does the same for the dev board -- (`./build_for_board.sh devkit ...` does the same for the dev board --
equivalent to a plain `idf.py`, just consistent with the XIAO invocation.) equivalent to a plain `idf.py`, just consistent with the XIAO invocation.)
### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver ported; `ee02` builds end-to-end, unverified on real hardware)
EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target
esp32s3` instead of `esp32c6`, and its own partition table/flash-size
Kconfig sized for its 16MB flash
([`partitions_ee02.csv`](partitions_ee02.csv)):
```
./build_for_board.sh ee02 set-target esp32s3
./build_for_board.sh ee02 build
```
**This now compiles and links clean end-to-end**, verified locally with
a native, non-Docker ESP-IDF v6.0 install (see
`.claude/skills/build-firmware/SKILL.md`).
`firmware/components/epd13in3e`'s panel init/LUT/refresh register
sequence is a real, vendor-confirmed port (see that component's own top
comment and
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
for the vendor sources and the load-bearing native-raster-orientation
correction that came with it). `main/{back,next,combo}_button.c` used to
call an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3
fallback; each now branches on `SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`
to keep the ESP32-C6 path (devkit/xiao) untouched while using
`esp_sleep_enable_ext1_wakeup_io()` on ESP32-S3 -- see `docs/hardware.md`'s
same section for why the additive `_io()` variant needs no combined-mask
coordination across the three button files, and why the pull-resistor
concern that ruled out EXT1 wakeup on ESP32-C6 doesn't apply the same
way here. That reasoning is confirmed against ESP-IDF source, **not
against real EE02 hardware** -- CI's
(`.gitea/workflows/firmware-build-check.yml`/`firmware-release-build.yml`)
`continue-on-error` on this board's step is intentionally still in place
until it is.
## Configuration (`idf.py menuconfig`) ## Configuration (`idf.py menuconfig`)
Under **ESPresso Frame Configuration**: Under **ESPresso Frame Configuration**:
@@ -147,10 +185,9 @@ two-step setup screen:
portal's config page (`http://192.168.4.1/` by default), for a portal's config page (`http://192.168.4.1/` by default), for a
one-scan shortcut once you've joined the AP. one-scan shortcut once you've joined the AP.
The config page asks for your home WiFi SSID/password, the "Tools The config page asks for your home WiFi SSID/password and the "Tools
Server" address (`host:port` of the [server](../server/) -- **not** your Server" address (`host:port` of the [server](../server/) -- **not** your
Immich server; see below for the `https://` form), and an optional Immich server; see below for the `https://` form). Saving hands your browser
"Access Token" (see below -- usually blank). Saving hands your browser
off to the server's claim page (after ~7 seconds, giving your phone off to the server's claim page (after ~7 seconds, giving your phone
time to rejoin its normal WiFi while the device reboots) so the frame time to rejoin its normal WiFi while the device reboots) so the frame
gets linked to your account; the device meanwhile connects to your home gets linked to your account; the device meanwhile connects to your home
@@ -212,19 +249,6 @@ certificate was actually issued for -- a bare LAN IP address
(`https://192.168.1.50`) will fail the handshake even against a (`https://192.168.1.50`) will fail the handshake even against a
perfectly valid cert for a different name. perfectly valid cert for a different name.
## Access token
Usually blank. Current servers issue each frame its own private token
automatically on first contact (delivered via `GET /frame/config`,
persisted in NVS, preferred by `build_url()` from then on -- and baked
into the manage-menu/share QR codes so scanning them just works). The
captive portal's "Access Token" field only matters when pointing this
firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN`
is set: paste that shared value and the device sends it (`?token=...`)
until a newer server replaces it with a per-frame one. Re-provisioning
clears any stored per-frame token -- a fresh identity handshake with
whatever server you point it at next.
## Skipping to the next photo ## Skipping to the next photo
Wire a momentary push button between GPIO2 and GND (internal pull-up, Wire a momentary push button between GPIO2 and GND (internal pull-up,
+33 -14
View File
@@ -1,28 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds/flashes for a specific board variant. This project targets two: # Builds/flashes for a specific board variant. This project targets three:
# #
# devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is # devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is
# also the plain `idf.py` default (sdkconfig/build/), so this # also the plain `idf.py` default (sdkconfig/build/), so this
# script's devkit mode is mostly for symmetry -- normal # script's devkit mode is mostly for symmetry -- normal
# `idf.py build`/`flash` work fine too. # `idf.py build`/`flash` work fine too. Reports itself as
# "devkit_esp32c6" (see main/Kconfig.projbuild).
# xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board. # xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board.
# Reports itself as "xiao_esp32c6".
# ee02 Seeed EE02 (XIAO ESP32-S3 Plus, 16MB flash) + 13.3" Spectra 6
# panel -- a genuinely different chip target (esp32s3, not
# esp32c6), unlike xiao's same-chip Kconfig-only variant.
# Reports itself as "ee02". NOTE: the epd13in3e driver this
# board links (firmware/components/epd13in3e) doesn't actually
# work yet -- its panel init/LUT/refresh register sequence is
# still unported from vendor demo code (see that component's
# own top-of-file comment); building for ee02 will fail to
# compile until that lands, by design (a deliberate #error, not
# a bug in this script).
# #
# The two need different partition tables (the XIAO's 4MB doesn't fit # The three need different partition tables (each flash size needs its
# the dev board's two 2MB OTA app slots -- see partitions_xiao.csv, # own OTA app-slot sizing -- see partitions_xiao.csv/partitions_ee02.csv)
# 1.875MB slots instead) and a different flash-size Kconfig. Rather # and different flash-size Kconfig. Rather than hand-editing the shared
# than hand-editing the shared sdkconfig back and forth (fragile, easy # sdkconfig back and forth (fragile, easy to leave it in the wrong state
# to leave it in the wrong state for whichever board you flash next), # for whichever board you flash next), each board gets its own build
# each board gets its own build directory and its own generated # directory and its own generated sdkconfig, seeded from
# sdkconfig, seeded from sdkconfig.defaults (shared) with the board's # sdkconfig.defaults (shared) with the board's override file layered on
# override file layered on top via ESP-IDF's own SDKCONFIG_DEFAULTS # top via ESP-IDF's own SDKCONFIG_DEFAULTS mechanism. Switching boards is
# mechanism. Switching boards is just switching which one you invoke -- # just switching which one you invoke -- none ever touches another's
# neither ever touches the other's config or build output. # config or build output.
# #
# Usage: # Usage:
# ./build_for_board.sh xiao build # ./build_for_board.sh xiao build
# ./build_for_board.sh xiao flash -p /dev/ttyUSB0 # ./build_for_board.sh xiao flash -p /dev/ttyUSB0
# ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0 # ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0
# ./build_for_board.sh devkit build # ./build_for_board.sh devkit build
# ./build_for_board.sh ee02 set-target esp32s3 # first build only, see below
# ./build_for_board.sh ee02 build
# #
# Defaults to "build" if no idf.py subcommand is given. # Defaults to "build" if no idf.py subcommand is given.
@@ -32,7 +46,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$script_dir" cd "$script_dir"
if [ $# -lt 1 ]; then if [ $# -lt 1 ]; then
echo "Usage: $0 <devkit|xiao> [idf.py args...]" >&2 echo "Usage: $0 <devkit|xiao|ee02> [idf.py args...]" >&2
exit 1 exit 1
fi fi
board="$1" board="$1"
@@ -49,8 +63,13 @@ case "$board" in
sdkconfig_path="$script_dir/sdkconfig" sdkconfig_path="$script_dir/sdkconfig"
defaults="$script_dir/sdkconfig.defaults" defaults="$script_dir/sdkconfig.defaults"
;; ;;
ee02)
build_dir="$script_dir/build_ee02"
sdkconfig_path="$script_dir/sdkconfig.ee02_local"
defaults="$script_dir/sdkconfig.defaults;$script_dir/sdkconfig.ee02"
;;
*) *)
echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2 echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2
exit 1 exit 1
;; ;;
esac esac
@@ -0,0 +1,13 @@
# SRCS is conditional on which board's panel this build targets -- see
# epd7in3e/CMakeLists.txt's identical comment (the two components mirror
# each other: exactly one contributes actual object files/symbols to any
# given build, the other is required but empty).
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "epd13in3e.c")
else()
set(srcs "")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+65
View File
@@ -0,0 +1,65 @@
menu "E-Paper Display (epd13in3e) Configuration"
config EPD_PIN_CLK
int "SPI CLK (SCLK) GPIO"
default 7
help
Defaults sourced from a community-verified ESPHome
integration for this exact board
(github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
official Waveshare/Seeed reference driver (see
firmware/components/epd13in3e/epd13in3e.c's top comment,
which is about the still-unknown panel init/LUT/refresh
register sequence, a separate and larger unknown than this
pinout). Override if your own board wiring differs.
config EPD_PIN_MOSI
int "SPI MOSI (DIN) GPIO"
default 9
config EPD_PIN_CS_MASTER
int "SPI CS (master half) GPIO"
default 44
help
Unlike epd7in3e's single-CS interface, this panel is driven
as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
two independent chip-selects (master/slave) -- confirmed by
the same community ESPHome integration, not yet by this
component's own driver code (still unimplemented, see
epd13in3e.c).
config EPD_PIN_CS_SLAVE
int "SPI CS (slave half) GPIO"
default 41
config EPD_PIN_DC
int "Data/Command GPIO"
default 10
config EPD_PIN_RST
int "Reset GPIO"
default 38
config EPD_PIN_BUSY
int "Busy GPIO"
default 4
config EPD_PIN_POWER_EN
int "Panel power-enable GPIO"
default 43
help
No equivalent pin on epd7in3e's board -- the EE02 apparently
gates the panel's own power rail separately from the ESP32-S3
module's. Source: same community integration as the other
pins above.
config EPD_SPI_CLOCK_HZ
int "SPI clock speed (Hz)"
default 2000000
help
2MHz, not epd7in3e's 4MHz default -- the same community
integration notes higher rates were unreliable on this
panel/board combo. Revisit once wiring is confirmed on real
hardware.
endmenu
+438
View File
@@ -0,0 +1,438 @@
#include <string.h>
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_rom_crc.h"
#include "epd13in3e.h"
/* Command bytes/register values below are a line-for-line transcription of
* Waveshare's official reference drivers for this exact panel+controller --
* confirmed identical across three independent sources (RaspberryPi/c,
* ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example; see this
* component's header for repo paths). Same "don't clean these up" rule as
* epd7in3e.c: this class of panel controller has no public datasheet, so
* the vendor driver is the source of truth for every byte.
*
* Unlike epd7in3e's single chip-select, this panel is driven as two
* independent controllers sharing one CLK/MOSI/DC/RST/BUSY bus but with
* separate chip-selects (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE) -- most init
* commands broadcast to both (CS_ALL), a handful of power/boost commands
* go only to the master (which owns the shared analog rails), and actual
* frame data is split per-row into a left half (master) and right half
* (slave), 300 bytes each out of each 600-byte row. That split is why
* epd_write_frame below buffers the whole frame in PSRAM before sending
* anything (every row needs slicing in half before either half can go out),
* unlike epd7in3e.c's straight single-CS passthrough streaming.
*
* Pin numbers themselves are NOT from this vendor code -- Waveshare's
* ESP32-S3-ePaper-13.3E6 example targets Waveshare's own driver board, a
* different carrier than Seeed's EE02 this project actually uses, so its
* GPIO numbers don't apply here. EE02's pins remain sourced from a
* community-verified ESPHome integration (see this component's Kconfig),
* not an official reference. */
#define EPD_SPI_HOST SPI2_HOST
#define EPD_SPI_CHUNK_SIZE 4096
static const char *TAG = "epd13in3e";
#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
/* --- panel command opcodes --- */
#define PSR 0x00
#define PWR 0x01
#define POF 0x02
#define PON 0x04
#define BTST_N 0x05
#define BTST_P 0x06
#define DTM 0x10 /* data transfer (frame data) */
#define DRF 0x12 /* display refresh */
#define CDI 0x50
#define TCON 0x60
#define TRES 0x61
#define AN_TM 0x74
#define AGID 0x86
#define BUCK_BOOST_VDDN 0xB0
#define TFT_VCOM_POWER 0xB1
#define EN_BUF 0xB6
#define BOOST_VDDP_EN 0xB7
#define CCSET 0xE0
#define PWS 0xE3
#define CMD66 0xF0
#define DEEP_SLEEP 0x07
/* --- canned init parameter blobs (do NOT edit -- see top comment) --- */
static const uint8_t PSR_V[] = {0xDF, 0x69};
static const uint8_t PWR_V[] = {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38};
static const uint8_t POF_V[] = {0x00};
static const uint8_t DRF_V[] = {0x00};
static const uint8_t CDI_V[] = {0xF7};
static const uint8_t TCON_V[] = {0x03, 0x03};
static const uint8_t TRES_V[] = {0x04, 0xB0, 0x03, 0x20};
static const uint8_t CMD66_V[] = {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10};
static const uint8_t EN_BUF_V[] = {0x07};
static const uint8_t CCSET_V[] = {0x01};
static const uint8_t PWS_V[] = {0x22};
static const uint8_t AN_TM_V[] = {0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55};
static const uint8_t AGID_V[] = {0x10};
static const uint8_t BTST_P_V[] = {0xE8, 0x28};
static const uint8_t BOOST_VDDP_EN_V[] = {0x01};
static const uint8_t BTST_N_V[] = {0xE8, 0x28};
static const uint8_t BUCK_BOOST_VDDN_V[] = {0x01};
static const uint8_t TFT_VCOM_POWER_V[] = {0x02};
static spi_device_handle_t s_spi;
static void epd_delay_ms(uint32_t ms)
{
vTaskDelay(pdMS_TO_TICKS(ms));
}
/* BUSY: LOW = busy, HIGH = idle -- same polarity/poll-interval reasoning
* as epd7in3e.c's identical comment (a tight 1ms-rounds-to-0-ticks poll
* starves the idle task badly enough to trip the watchdog). */
static void epd_wait_busy(void)
{
while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
epd_delay_ms(20);
}
}
static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
{
while (len > 0) {
size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
spi_transaction_t t = {
.length = n * 8,
.tx_buffer = data,
};
EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
data += n;
len -= n;
}
return ESP_OK;
}
/* Unlike epd7in3e.c's send_command/send_data, these do NOT touch CS --
* this panel's two independent chip-selects (and the "broadcast to both"
* vs "master only" split the init sequence needs) mean CS bracketing has
* to be the caller's decision, not baked into the byte-send primitive. */
static esp_err_t epd_send_command(uint8_t cmd)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
return epd_spi_write(&cmd, 1);
}
static esp_err_t epd_send_data(const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
return epd_spi_write(data, len);
}
static esp_err_t epd_send_data_byte(uint8_t data)
{
return epd_send_data(&data, 1);
}
static void epd_cs_both(int level)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, level);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, level);
}
/* Sends `cmd` + its data blob to both controllers at once (most of the
* init sequence -- shared display-timing/power registers). */
static esp_err_t epd_cmd_both(uint8_t cmd, const uint8_t *data, size_t len)
{
epd_cs_both(0);
esp_err_t err = epd_send_command(cmd);
if (err == ESP_OK && data != NULL) {
err = epd_send_data(data, len);
}
epd_cs_both(1);
return err;
}
/* Sends `cmd` + its data blob to the master controller only -- the boost/
* VCOM power registers the master alone owns. */
static esp_err_t epd_cmd_master(uint8_t cmd, const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_send_command(cmd);
if (err == ESP_OK && data != NULL) {
err = epd_send_data(data, len);
}
epd_cs_both(1);
return err;
}
/* 5-edge reset sequence (30ms each) -- per-vendor-source exact, more edges
* than epd7in3e.c's 3-edge/20ms reset. */
static void epd_reset(void)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
}
/* Power on, refresh, power off -- mirrors EPD_TurnOnDisplay()/
* EPD_13IN3E_TurnOnDisplay() in the reference drivers. */
esp_err_t epd_turn_on_display(void)
{
EPD_CHECK(epd_cmd_both(PON, NULL, 0));
epd_wait_busy();
epd_delay_ms(50);
EPD_CHECK(epd_cmd_both(DRF, DRF_V, sizeof(DRF_V)));
epd_wait_busy();
epd_delay_ms(50);
EPD_CHECK(epd_cmd_both(POF, POF_V, sizeof(POF_V)));
/* No busy-wait after POF -- matches every reference driver. */
return ESP_OK;
}
esp_err_t epd_init(void)
{
gpio_config_t out_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
(1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
(1ULL << CONFIG_EPD_PIN_POWER_EN),
.mode = GPIO_MODE_OUTPUT,
};
EPD_CHECK(gpio_config(&out_cfg));
gpio_config_t busy_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
.mode = GPIO_MODE_INPUT,
};
EPD_CHECK(gpio_config(&busy_cfg));
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_cs_both(1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
spi_bus_config_t bus_cfg = {
.mosi_io_num = CONFIG_EPD_PIN_MOSI,
.miso_io_num = -1,
.sclk_io_num = CONFIG_EPD_PIN_CLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = EPD_SPI_CHUNK_SIZE,
};
EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
.mode = 0,
.spics_io_num = -1, /* both chip-selects are bit-banged by hand above */
.queue_size = 1,
};
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
/* Panel power-enable rail (no equivalent on epd7in3e's board -- EE02
* gates it separately from the ESP32-S3 module's own supply). */
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 1);
epd_delay_ms(10);
epd_reset();
epd_wait_busy();
/* Master-only: shared analog-timing register. */
EPD_CHECK(epd_cmd_master(AN_TM, AN_TM_V, sizeof(AN_TM_V)));
/* Broadcast: display-timing/power-sequencing registers both
* controllers need identically. */
EPD_CHECK(epd_cmd_both(CMD66, CMD66_V, sizeof(CMD66_V)));
EPD_CHECK(epd_cmd_both(PSR, PSR_V, sizeof(PSR_V)));
EPD_CHECK(epd_cmd_both(CDI, CDI_V, sizeof(CDI_V)));
EPD_CHECK(epd_cmd_both(TCON, TCON_V, sizeof(TCON_V)));
EPD_CHECK(epd_cmd_both(AGID, AGID_V, sizeof(AGID_V)));
EPD_CHECK(epd_cmd_both(PWS, PWS_V, sizeof(PWS_V)));
EPD_CHECK(epd_cmd_both(CCSET, CCSET_V, sizeof(CCSET_V)));
EPD_CHECK(epd_cmd_both(TRES, TRES_V, sizeof(TRES_V)));
/* Master-only: boost/VCOM power programming. */
EPD_CHECK(epd_cmd_master(PWR, PWR_V, sizeof(PWR_V)));
EPD_CHECK(epd_cmd_master(EN_BUF, EN_BUF_V, sizeof(EN_BUF_V)));
EPD_CHECK(epd_cmd_master(BTST_P, BTST_P_V, sizeof(BTST_P_V)));
EPD_CHECK(epd_cmd_master(BOOST_VDDP_EN, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V)));
EPD_CHECK(epd_cmd_master(BTST_N, BTST_N_V, sizeof(BTST_N_V)));
EPD_CHECK(epd_cmd_master(BUCK_BOOST_VDDN, BUCK_BOOST_VDDN_V, sizeof(BUCK_BOOST_VDDN_V)));
EPD_CHECK(epd_cmd_master(TFT_VCOM_POWER, TFT_VCOM_POWER_V, sizeof(TFT_VCOM_POWER_V)));
ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS_M=%d CS_S=%d DC=%d RST=%d BUSY=%d PWR_EN=%d)",
CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS_MASTER,
CONFIG_EPD_PIN_CS_SLAVE, CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST,
CONFIG_EPD_PIN_BUSY, CONFIG_EPD_PIN_POWER_EN);
return ESP_OK;
}
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
{
ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
/* Every row has to be sliced into a left (master) and right (slave)
* half before either half can go out over SPI, so -- unlike
* epd7in3e.c's single-CS passthrough -- bytes can't be forwarded to
* the wire as they arrive. Buffer the whole ~938KB frame in PSRAM
* first (EE02's XIAO ESP32-S3 Plus has 8MB of it). */
uint8_t *frame = heap_caps_malloc(EPD_FRAME_BYTES, MALLOC_CAP_SPIRAM);
if (frame == NULL) {
ESP_LOGE(TAG, "OOM allocating %u-byte frame buffer", (unsigned)EPD_FRAME_BYTES);
return ESP_ERR_NO_MEM;
}
size_t total = 0;
uint32_t crc = 0;
size_t n;
while (total < EPD_FRAME_BYTES &&
(n = read_fn(frame + total, EPD_FRAME_BYTES - total, ctx)) > 0) {
crc = esp_rom_crc32_le(crc, frame + total, n);
total += n;
}
if (total != EPD_FRAME_BYTES) {
/* Same invariant as epd7in3e.c: never touch the panel on a
* short/wrong-size stream -- the visible screen is left exactly
* as it was. */
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
free(frame);
return ESP_ERR_INVALID_SIZE;
}
/* De-interleave into one half-buffer at a time and DMA it out as a
* single contiguous transfer (chunked internally by epd_spi_write) --
* far fewer, far larger SPI transactions than sending 1600 separate
* 300-byte rows per side. */
const size_t HALF_ROW = EPD_BYTES_PER_ROW / 2; /* 300 */
const size_t HALF_BUF = HALF_ROW * EPD_HEIGHT; /* 480000 */
uint8_t *half = heap_caps_malloc(HALF_BUF, MALLOC_CAP_SPIRAM);
if (half == NULL) {
ESP_LOGE(TAG, "OOM allocating %u-byte half-frame scratch buffer", (unsigned)HALF_BUF);
free(frame);
return ESP_ERR_NO_MEM;
}
for (size_t r = 0; r < EPD_HEIGHT; r++) {
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW, HALF_ROW);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_send_command(DTM);
if (err == ESP_OK) {
err = epd_send_data(half, HALF_BUF);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
if (err == ESP_OK) {
for (size_t r = 0; r < EPD_HEIGHT; r++) {
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW + HALF_ROW, HALF_ROW);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 0);
err = epd_send_command(DTM);
if (err == ESP_OK) {
err = epd_send_data(half, HALF_BUF);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
}
free(half);
free(frame);
EPD_CHECK(err);
if (out_crc32 != NULL) {
*out_crc32 = crc;
}
return ESP_OK;
}
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
{
esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
if (err != ESP_OK) {
return err;
}
return epd_turn_on_display();
}
typedef struct {
const uint8_t *data;
size_t len;
size_t pos;
} epd_buf_ctx_t;
static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
size_t remaining = c->len - c->pos;
size_t n = remaining < chunk_size ? remaining : chunk_size;
if (n == 0) {
return 0;
}
memcpy(chunk, c->data + c->pos, n);
c->pos += n;
return n;
}
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
{
epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
return epd_display_stream(epd_buf_read, &buf_ctx);
}
typedef struct {
uint8_t fill_byte;
size_t remaining;
} epd_fill_ctx_t;
static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
if (n == 0) {
return 0;
}
memset(chunk, c->fill_byte, n);
c->remaining -= n;
return n;
}
esp_err_t epd_clear(epd_color_t color)
{
epd_fill_ctx_t fill_ctx = {
.fill_byte = (uint8_t)((color << 4) | color),
.remaining = EPD_FRAME_BYTES,
};
return epd_display_stream(epd_fill_read, &fill_ctx);
}
esp_err_t epd_sleep(void)
{
epd_cs_both(0);
EPD_CHECK(epd_send_command(DEEP_SLEEP));
EPD_CHECK(epd_send_data_byte(0xA5)); /* magic deep-sleep arg per every reference driver */
epd_cs_both(1);
epd_delay_ms(100);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
return ESP_OK;
}
@@ -0,0 +1,100 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
* board (XIAO ESP32-S3 Plus). The panel is marketed/mounted as a
* 1600x1200 landscape rectangle (270.40x202.80mm), but its SPI
* controller addresses a native raster of 1200 columns x 1600 rows --
* i.e. the wire format is portrait, rotated 90 degrees from how the
* panel physically hangs. Confirmed identically across three independent
* vendor sources: Waveshare's RaspberryPi/c and ESP32 reference drivers
* for this exact panel (E-paper_Separate_Program/13.3inch_e-Paper_E in
* waveshare/e-Paper), and Waveshare's own ESP-IDF example for their
* ESP32-S3-ePaper-13.3E6 driver board (a *different* carrier board than
* Seeed's EE02, but the same panel+controller, hence the same command
* bytes/geometry -- only the GPIO numbers differ, and those come from
* EE02-specific sources, see this component's Kconfig). All three define
* EPD_WIDTH=1200/EPD_HEIGHT=1600 and split each row into two 600-byte
* (300px) halves sent to independent chip-selects: EPD_PIN_CS_MASTER
* gets the left half, EPD_PIN_CS_SLAVE the right -- see epd13in3e.c.
*
* Getting this backwards (assuming the wire raster matches the
* 1600x1200 mount/marketing size) doesn't just rotate the image -- 1600
* and 1200 don't share a row stride with 1200 and 1600 the other way
* (800 bytes/row x 1200 rows vs 600 bytes/row x 1600 rows), so a mismatch
* here slices real image rows at the wrong byte offsets and shreds the
* picture into a repeating diagonal garble, not a clean rotation.
* server/app/image_pipeline.py's PANEL_WIRE_TRANSPOSE handles the
* corresponding rotation server-side before packing bytes for this
* panel_type -- this header and that dict must agree on which axis is
* native. */
#define EPD_WIDTH 1200
#define EPD_HEIGHT 1600
#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
/* Same 6-ink Spectra family as the 7.3" panel, and (now confirmed by the
* same three vendor sources as the geometry above) the same 4-bit nibble
* codes as epd7in3e.h's epd_color_t -- matches
* server/app/image_pipeline.py's PANEL_CODES unconditionally, no
* panel-specific table needed there. */
typedef enum {
EPD_COLOR_BLACK = 0x0,
EPD_COLOR_WHITE = 0x1,
EPD_COLOR_YELLOW = 0x2,
EPD_COLOR_RED = 0x3,
EPD_COLOR_BLUE = 0x5,
EPD_COLOR_GREEN = 0x6,
} epd_color_t;
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
esp_err_t epd_init(void);
/** Fills the whole panel with a single color and refreshes. */
esp_err_t epd_clear(epd_color_t color);
/**
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
* into chunk. Must return the number of bytes written, or 0 once exhausted.
*/
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
/**
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
* panel via read_fn and refreshes. Pulling from a caller-supplied source
* instead of a single buffer lets callers feed the panel directly from an
* HTTP response without holding the whole ~960KB frame in RAM.
*/
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
/**
* Like epd_display_stream(), but writes the frame into the panel's
* internal buffer over SPI WITHOUT triggering the physical refresh (the
* visible flash/flicker) -- call epd_turn_on_display() separately to make
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
* nothing is refreshed, so the visible screen is left untouched on
* error.
*
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
* lets a caller compare against the last-displayed frame's CRC and skip
* the refresh entirely when nothing actually changed (e.g. redisplaying
* the same photo after a reboot).
*/
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
/**
* Triggers the panel's physical refresh cycle (power on, refresh, power
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
* to make the written buffer visible.
*/
esp_err_t epd_turn_on_display(void);
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
esp_err_t epd_sleep(void);
+13 -1
View File
@@ -1,3 +1,15 @@
idf_component_register(SRCS "epd7in3e.c" # SRCS is conditional on which board's panel this build targets (see
# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
# can't be) -- an ee02 build still always requires this component (so
# its Kconfig menu/include dir exist), but contributes zero object
# files/symbols to it, since epd13in3e.c provides the real epd_init()
# etc. for that board instead.
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "")
else()
set(srcs "epd7in3e.c")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio) PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+13 -1
View File
@@ -1,3 +1,15 @@
# Both EPD driver components are always REQUIRED (REQUIRES/PRIV_REQUIRES
# can't itself depend on a Kconfig value -- ESP-IDF resolves the
# component dependency graph in an early pass that runs BEFORE Kconfig
# is generated, so a CONFIG_* check here would silently see an empty
# value every time; confirmed the hard way, see git history if this
# comment ever seems suspicious). Which one actually compiles anything
# is decided inside each component's own CMakeLists.txt (conditional
# SRCS, evaluated in the later, Kconfig-aware pass -- that's fine, it's
# only REQUIRES itself that has the early-pass restriction), keyed off
# the same CONFIG_FRAME_PANEL_EE02_13IN3 that main/epd_board.h uses to
# pick which header every source file sees -- exactly one of the two
# ever contributes actual object files/symbols to a given build.
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e epd13in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html) EMBED_FILES root.html)
+43 -15
View File
@@ -2,18 +2,29 @@ menu "ESPresso Frame Configuration"
config FRAME_BOARD_NAME config FRAME_BOARD_NAME
string "Board variant name, reported to the server" string "Board variant name, reported to the server"
default "devkit" default "devkit_esp32c6"
help help
Sent as the X-Frame-Board request header on every Sent as the X-Frame-Board request header on every
GET /frame/config poll, so the server can learn which board GET /frame/config poll, so the server can learn which board
this device is and automatically fetch the right OTA build this device is and automatically fetch the right OTA build
from a configured Gitea repo's releases -- no manual "which from a configured Gitea repo's releases, and (see
board" picker in the web UI. Must match one of the asset routers/device.py's BOARD_PANEL_MAP) which EPD panel it
names .gitea/workflows/firmware-release-build.yml publishes drives -- no manual "which board/panel" picker in the web
(firmware-<name>.bin): "devkit" (this default, for the UI. Must match one of the asset names
plain ESP32-C6-DevKitC-1 build) or "xiao" (set via .gitea/workflows/firmware-release-build.yml publishes
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see (firmware-<name>.bin): "devkit_esp32c6" (this default, for
build_for_board.sh). the plain ESP32-C6-DevKitC-1 build), "xiao_esp32c6" (set via
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build), or "ee02"
(set via sdkconfig.ee02 for the Seeed EE02/XIAO ESP32-S3
Plus + 13.3" panel build) -- see build_for_board.sh.
Chip-qualified rather than plain "devkit"/"xiao": the EE02
board also sockets a XIAO module (an ESP32-S3 one), so
"xiao" alone stopped disambiguating hardware once EE02
existed. The server keeps accepting the old bare
"devkit"/"xiao" names indefinitely too, since already-
flashed devices report whatever name their current firmware
was built with and can't be retroactively renamed.
config FRAME_XIAO_ANTENNA_INIT config FRAME_XIAO_ANTENNA_INIT
bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)" bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)"
@@ -33,6 +44,18 @@ menu "ESPresso Frame Configuration"
by default in sdkconfig.xiao; leave off for the DevKitC-1 by default in sdkconfig.xiao; leave off for the DevKitC-1
dev board, which has no such switch. dev board, which has no such switch.
config FRAME_PANEL_EE02_13IN3
bool "Build for the EE02 board + 13.3in Spectra 6 panel (ESP32-S3), not the 7.3in panel"
default n
help
Selects the epd13in3e driver component (13.3", 1600x1200)
instead of epd7in3e (7.3", 800x480) as main/epd_board.h's
target -- see firmware/components/epd13in3e. Firmware only
ever links one EPD driver at a time, same as the
devkit/xiao split links exactly one board's pin config.
Enabled by default in sdkconfig.ee02; leave off for the
ESP32-C6 boards (devkit/xiao), which drive the 7.3" panel.
config ESP_AP_SSID config ESP_AP_SSID
string "Provisioning softAP SSID prefix" string "Provisioning softAP SSID prefix"
default "ESPRESSO" default "ESPRESSO"
@@ -119,6 +142,7 @@ menu "ESPresso Frame Configuration"
config FRAME_NEXT_BUTTON_GPIO config FRAME_NEXT_BUTTON_GPIO
int "Next-photo button GPIO (-1 to disable)" int "Next-photo button GPIO (-1 to disable)"
default 2 default 2
range -1 21 if IDF_TARGET_ESP32S3
range -1 7 range -1 7
help help
Button wired between this GPIO and GND (active-low, internal Button wired between this GPIO and GND (active-low, internal
@@ -126,14 +150,17 @@ menu "ESPresso Frame Configuration"
Pressing it wakes the device (if asleep), forces the server to Pressing it wakes the device (if asleep), forces the server to
advance to the next photo immediately (POST /frame/advance) advance to the next photo immediately (POST /frame/advance)
regardless of the configured refresh interval, and displays regardless of the configured refresh interval, and displays
it. Must be GPIO 0-7 -- the only pins the ESP32-C6 can use as it. Must be a deep-sleep-wakeup-capable GPIO: 0-7 on the
a deep-sleep GPIO wakeup source, which is what lets a press ESP32-C6, 0-21 on the ESP32-S3 (RTC-IO pins reachable by
wake the device promptly instead of only being noticed during esp_sleep_enable_ext1_wakeup_io()) -- required so a press
its brief awake windows. Set to -1 to disable the feature. wakes the device promptly instead of only being noticed
during its brief awake windows. Set to -1 to disable the
feature.
config FRAME_BACK_BUTTON_GPIO config FRAME_BACK_BUTTON_GPIO
int "Back-photo button GPIO (-1 to disable)" int "Back-photo button GPIO (-1 to disable)"
default 0 default 0
range -1 21 if IDF_TARGET_ESP32S3
range -1 7 range -1 7
help help
Button wired between this GPIO and GND (active-low, internal Button wired between this GPIO and GND (active-low, internal
@@ -142,13 +169,14 @@ menu "ESPresso Frame Configuration"
to return to the previously-current photo immediately to return to the previously-current photo immediately
(POST /frame/back), and displays it. Pressing next (POST /frame/back), and displays it. Pressing next
afterwards returns to where you were before pressing back. afterwards returns to where you were before pressing back.
Must be GPIO 0-7 for the same deep-sleep-wakeup reason as Must be a deep-sleep-wakeup-capable GPIO, same range as
FRAME_NEXT_BUTTON_GPIO above; defaults to a different pin FRAME_NEXT_BUTTON_GPIO above; defaults to a different pin
than the other buttons. Set to -1 to disable the feature. than the other buttons. Set to -1 to disable the feature.
config FRAME_COMBO_BUTTON_GPIO config FRAME_COMBO_BUTTON_GPIO
int "Menu/reset button GPIO (-1 to disable)" int "Menu/reset button GPIO (-1 to disable)"
default 1 default 1
range -1 21 if IDF_TARGET_ESP32S3
range -1 7 range -1 7
help help
Button wired between this GPIO and GND (active-low, internal Button wired between this GPIO and GND (active-low, internal
@@ -159,8 +187,8 @@ menu "ESPresso Frame Configuration"
then releasing shows the management menu; holding it all the then releasing shows the management menu; holding it all the
way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
config and restarts into provisioning, regardless of whether config and restarts into provisioning, regardless of whether
it's released yet. Must be GPIO 0-7 for the same deep-sleep- it's released yet. Must be a deep-sleep-wakeup-capable GPIO,
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to same range 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
menu, both reset tiers, and factory-reset-via-button -- menu, both reset tiers, and factory-reset-via-button --
+12
View File
@@ -3,6 +3,7 @@
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
@@ -35,10 +36,17 @@ void back_button_init(void)
}; };
gpio_config(&io_conf); gpio_config(&io_conf);
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* See next_button.c for why this API (not ext1) -- it manages the /* See next_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin * pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */ * doesn't float and wake the device spuriously. */
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);
#else
/* See next_button.c for why ext1 is safe here on targets without the
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
* coordination. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << BACK_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
} }
back_button_result_t back_button_check(void) back_button_result_t back_button_check(void)
@@ -49,7 +57,11 @@ back_button_result_t 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 SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO); bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
#endif
if (!caused_wake) { if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
+12
View File
@@ -1,6 +1,7 @@
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
@@ -31,10 +32,17 @@ void combo_button_init(void)
}; };
gpio_config(&io_conf); gpio_config(&io_conf);
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* See next_button.c for why this API (not ext1) -- it manages the /* See next_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin * pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */ * doesn't float and wake the device spuriously. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << COMBO_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW); esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << COMBO_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* See next_button.c for why ext1 is safe here on targets without the
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
* coordination. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << COMBO_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
} }
bool combo_button_check(void) bool combo_button_check(void)
@@ -48,7 +56,11 @@ bool combo_button_check(void)
* caused the wake even if it's since been released -- in which case * caused the wake even if it's since been released -- in which case
* the poll loop below simply measures 0ms held, correctly resolving * the poll loop below simply measures 0ms held, correctly resolving
* to a quick press rather than "not pressed at all." */ * to a quick press rather than "not pressed at all." */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO); bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
#endif
if (!caused_wake && gpio_get_level(COMBO_BUTTON_GPIO) != 0) { if (!caused_wake && gpio_get_level(COMBO_BUTTON_GPIO) != 0) {
return false; /* not pressed, and didn't cause this wake either */ return false; /* not pressed, and didn't cause this wake either */
} }
+17
View File
@@ -0,0 +1,17 @@
#pragma once
/* Which EPD driver component this binary is built against -- exactly one,
* selected at compile time by CONFIG_FRAME_PANEL_EE02_13IN3 (see
* main/Kconfig.projbuild and main/CMakeLists.txt's matching PRIV_REQUIRES
* selection). Every file that used to `#include "epd7in3e.h"` directly
* includes this instead, so a build for the other board picks up the
* right EPD_WIDTH/EPD_HEIGHT/EPD_FRAME_BYTES/epd_color_t/epd_init() etc.
* with no other source change -- both driver components expose the same
* function/macro names (see epd13in3e.h), just sized for their own
* panel. */
#if CONFIG_FRAME_PANEL_EE02_13IN3
#include "epd13in3e.h"
#else
#include "epd7in3e.h"
#endif
+2 -2
View File
@@ -3,10 +3,10 @@
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include "epd7in3e.h" #include "epd_board.h"
#include "fonts.h" #include "fonts.h"
/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd7in3e.h). */ /** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd_board.h). */
void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color); void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color);
/** /**
+20 -23
View File
@@ -14,7 +14,7 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h" #include "freertos/event_groups.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "status_screen.h" #include "status_screen.h"
#include "combo_button.h" #include "combo_button.h"
#include "ota_update.h" #include "ota_update.h"
@@ -95,18 +95,15 @@ static void save_wifi_cache(esp_netif_t *netif)
} }
/* Builds a full URL from cfg->toolsserver + a path (no leading slash), /* Builds a full URL from cfg->toolsserver + a path (no leading slash),
* appending cfg->access_token as ?token= if one's set. toolsserver is * appending cfg->device_token as &token= once one's been delivered.
* normally a bare "host:port", defaulting to plain http; it may instead * toolsserver is normally a bare "host:port", defaulting to plain http;
* carry an explicit "http://" or "https://" prefix to pick the scheme, * it may instead carry an explicit "http://" or "https://" prefix to
* e.g. "https://frame.example.com" if a reverse proxy is terminating * pick the scheme, e.g. "https://frame.example.com" if a reverse proxy
* TLS in front of the tools server. Every URL carries ?id= (the device's * is terminating TLS in front of the tools server. Every URL carries
* MAC-derived identity -- how a multi-frame server tells frames apart * ?id= (the device's MAC-derived identity -- how a multi-frame server
* and how an unknown frame self-registers) plus &token=: the server- * tells frames apart and how an unknown frame self-registers) plus
* issued per-frame device token once one has been delivered via * &token=. This is the one chokepoint all requests go through, so every
* /frame/config, else the provisioned access token (the legacy shared * caller gets both for free instead of needing to remember to add them. */
* secret, also what a pre-multi-frame server still expects). This is
* the one chokepoint all requests go through, so every caller gets both
* for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path) static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{ {
const char *toolsserver = cfg->toolsserver; const char *toolsserver = cfg->toolsserver;
@@ -123,9 +120,8 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id); len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
} }
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token; if (cfg->device_token[0] != '\0' && len < out_size) {
if (token[0] != '\0' && len < out_size) { snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
snprintf(out + len, out_size - len, "&token=%s", token);
} }
} }
@@ -464,9 +460,9 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
* to bake its overlay into this same response instead of returning the * to bake its overlay into this same response instead of returning the
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK * 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 the active EPD driver component, main/epd_board.h) refuses to
* wrong-size stream, so a failure here always leaves the visible screen * trigger a physical refresh on a short/wrong-size stream, so a failure
* exactly as it was. */ * here always leaves the visible screen exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage) static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{ {
const char *path = "frame/image"; const char *path = "frame/image";
@@ -691,10 +687,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
image_ok = (fetch_err == ESP_OK); image_ok = (fetch_err == ESP_OK);
if (!image_ok) { if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a /* epd_display_stream() never triggers a physical refresh on a
* failed/short/wrong-size stream (see epd7in3e.c), so the * failed/short/wrong-size stream (see the active EPD driver
* visible screen is guaranteed untouched here -- always safe * component, main/epd_board.h), so the visible screen is
* to show what went wrong instead of leaving stale content * guaranteed untouched here -- always safe to show what went
* with no indication anything failed. */ * wrong instead of leaving stale content with no indication
* anything failed. */
ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err)); ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err));
/* Covers the fast-connect cache's blind spot: WiFi can report /* Covers the fast-connect cache's blind spot: WiFi can report
* a successful connection (cached static IP "worked" at the * a successful connection (cached static IP "worked" at the
+42 -6
View File
@@ -3,6 +3,7 @@
#include "driver/gpio.h" #include "driver/gpio.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_sleep.h" #include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
@@ -36,13 +37,44 @@ void next_button_init(void)
}; };
gpio_config(&io_conf); gpio_config(&io_conf);
/* Not esp_sleep_enable_ext1_wakeup_io(): its internal pull resistors #if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
* don't hold once the RTC_PERIPH domain powers down for deep sleep, so /* Not esp_sleep_enable_ext1_wakeup_io() on its own: on a target
* the pin floats and reads spuriously low, waking the device instantly * without RTC-independent digital pull registers, ext1's internal
* on every sleep entry (confirmed on hardware). This GPIO-wakeup * pull resistors don't hold once the RTC_PERIPH domain powers down
* variant manages the pull resistor itself across the sleep * for deep sleep, so the pin floats and reads spuriously low, waking
* transition. */ * the device instantly on every sleep entry (confirmed on hardware,
* ESP32-C6). This GPIO-wakeup variant manages the pull resistor
* itself across the sleep transition, sidestepping the issue
* entirely -- but it only exists on chips with this capability
* (currently just ESP32-C6; see the #else below for other targets,
* e.g. ESP32-S3). */
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);
#else
/* No HP-periph-powerdown wakeup API here (e.g. ESP32-S3) -- fall
* back to ext1, but NOT naively: on every non-original-ESP32 target
* (ESP32-S3 included), gpio_pullup_en() -- which the gpio_config()
* call above invokes via pull_up_en -- delegates to
* rtc_gpio_pullup_en() for RTC-capable pins (confirmed in
* esp_driver_gpio's gpio.c: GPIO_RTCIO_ARE_INDEPENDENT is 1 for
* every target except the original ESP32, meaning digital and RTC
* pull registers are independent hardware and gpio_config() already
* routes the pull-up through the RTC pad's own register for these
* pins, not just the digital one). That's exactly what was missing
* in the ext1 attempt that failed on hardware above -- so on this
* target the pull-up already survives the RTC_PERIPH power-down
* ext1 wakeup requires, without needing a separate rtc_gpio_*_en()
* call here. _io() (not the bare esp_sleep_enable_ext1_wakeup(),
* which resets any previously-configured mask) is additive across
* this file's, back_button.c's, and combo_button.c's independent
* init calls -- confirmed in esp_hw_support's sleep_modes.c -- so no
* shared-mask coordination between the three button files is
* needed. Still unconfirmed on real EE02 hardware: this avoids the
* *documented* failure mode of the earlier ext1 attempt, but that
* attempt was never root-caused beyond "confirmed spurious wakeup on
* hardware" -- treat this as untested until it's actually run on an
* EE02 board. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << NEXT_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
} }
next_button_result_t next_button_check(void) next_button_result_t next_button_check(void)
@@ -53,7 +85,11 @@ next_button_result_t 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 SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO); bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
#endif
if (!caused_wake) { if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
+2 -3
View File
@@ -36,9 +36,8 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id); len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
} }
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token; if (cfg->device_token[0] != '\0' && len < out_size) {
if (token[0] != '\0' && len < out_size) { snprintf(out + len, out_size - len, "&token=%s", cfg->device_token);
snprintf(out + len, out_size - len, "&token=%s", token);
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@
#include "esp_check.h" #include "esp_check.h"
#include "esp_log.h" #include "esp_log.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "epd_draw.h" #include "epd_draw.h"
#include "fonts.h" #include "fonts.h"
#include "qrcodegen.h" #include "qrcodegen.h"
-5
View File
@@ -97,11 +97,6 @@
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required> <input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080 or https://frame.example.com" maxlength="128" required>
</div> </div>
<div class="input-group">
<label for="access_token">Access Token (optional &mdash; only for older servers)</label>
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
</div>
<p style="font-size: 13px; color: #555;">After saving, this page will <p style="font-size: 13px; color: #555;">After saving, this page will
take you to the server to claim your frame &mdash; reconnect to take you to the server to claim your frame &mdash; reconnect to
your normal WiFi if it doesn't happen automatically.</p> your normal WiFi if it doesn't happen automatically.</p>
+1 -1
View File
@@ -3,7 +3,7 @@
#include "esp_check.h" #include "esp_check.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "epd_draw.h" #include "epd_draw.h"
#include "fonts.h" #include "fonts.h"
#include "wifi_provisioning.h" #include "wifi_provisioning.h"
+3 -19
View File
@@ -19,7 +19,7 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "qr_onboarding.h" #include "qr_onboarding.h"
#include "wifi_provisioning.h" #include "wifi_provisioning.h"
#include "board_antenna.h" #include "board_antenna.h"
@@ -73,20 +73,10 @@ esp_err_t frame_config_load(frame_config_t *out)
return pass_err; return pass_err;
} }
/* Also optional -- most deployments won't set a server-side
* MANAGEMENT_TOKEN at all, in which case this stays empty and the
* manage-menu QR just links to the page with no ?token=. */
len = sizeof(out->access_token);
esp_err_t token_err = nvs_get_str(handle, "access_token", out->access_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle);
return token_err;
}
/* Optional: absent until the server has pushed a per-frame token /* Optional: absent until the server has pushed a per-frame token
* (see frame_config_set_device_token). */ * (see frame_config_set_device_token). */
len = sizeof(out->device_token); len = sizeof(out->device_token);
token_err = nvs_get_str(handle, "device_token", out->device_token, &len); esp_err_t token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) { if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
nvs_close(handle); nvs_close(handle);
return token_err; return token_err;
@@ -131,9 +121,6 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
if (err == ESP_OK) { if (err == ESP_OK) {
err = nvs_set_str(handle, "toolsserver", cfg->toolsserver); err = nvs_set_str(handle, "toolsserver", cfg->toolsserver);
} }
if (err == ESP_OK) {
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) { if (err == ESP_OK) {
/* Re-provisioning restarts the identity handshake: the server /* Re-provisioning restarts the identity handshake: the server
* (possibly a different one now) re-issues a device token when * (possibly a different one now) re-issues a device token when
@@ -191,7 +178,6 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_ssid"); nvs_erase_key(handle, "sta_ssid");
nvs_erase_key(handle, "sta_pass"); nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver"); nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
nvs_erase_key(handle, "device_token"); nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once"); nvs_erase_key(handle, "connected_once");
nvs_commit(handle); nvs_commit(handle);
@@ -452,7 +438,6 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid)); extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid));
extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password)); extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password));
extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver)); extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver));
extract_form_value(body, "access_token", cfg.access_token, sizeof(cfg.access_token));
if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) { if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required"); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required");
@@ -466,8 +451,7 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
return ESP_FAIL; return ESP_FAIL;
} }
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver, ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver);
strlen(cfg.access_token) ? "set" : "none");
/* The success page hands the browser off to the server's claim page, /* The success page hands the browser off to the server's claim page,
* carrying this device's id -- how a frame gets linked to a user * carrying this device's id -- how a frame gets linked to a user
+3 -4
View File
@@ -17,11 +17,10 @@ typedef struct {
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1]; char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1]; char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1]; char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
/* Per-frame token issued by the server via GET /frame/config after /* Per-frame token issued by the server via GET /frame/config after
* this device first introduces itself by id -- preferred over * this device first introduces itself by id (see frame_client.c's
* access_token once present (see frame_client.c's build_url). Not * build_url). Not set at the captive portal; empty until the server
* set at the captive portal; empty until the server pushes one. */ * pushes one. */
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_config_t; } frame_config_t;
+13
View File
@@ -0,0 +1,13 @@
# Name, Type, SubType, Offset, Size, Flags
# Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
# the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
# 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
# own sizing note) without needing to trim anything the way the 4MB xiao
# table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
# revisit sizing once a real build's actual footprint and any EE02-
# specific storage needs (if ever) are known.
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
ota_0, app, ota_0, 0x10000, 0x200000,
otadata, data, ota, 0x210000, 0x2000,
ota_1, app, ota_1, 0x220000, 0x200000,
1 # Name, Type, SubType, Offset, Size, Flags
2 # Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
3 # the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
4 # 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
5 # own sizing note) without needing to trim anything the way the 4MB xiao
6 # table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
7 # revisit sizing once a real build's actual footprint and any EE02-
8 # specific storage needs (if ever) are known.
9 nvs, data, nvs, 0x9000, 0x6000,
10 phy_init, data, phy, 0xf000, 0x1000,
11 ota_0, app, ota_0, 0x10000, 0x200000,
12 otadata, data, ota, 0x210000, 0x2000,
13 ota_1, app, ota_1, 0x220000, 0x200000,
+50
View File
@@ -0,0 +1,50 @@
# Board-specific overrides for Seeed's EE02 (XIAO ESP32-S3 Plus + 13.3"
# Spectra 6 panel), layered on top of sdkconfig.defaults via
# SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the supported
# way to build with this file. Don't set this via a plain `idf.py
# menuconfig` on the default build; that writes straight into the shared
# sdkconfig, not this file.
#
# Unlike xiao (a same-chip Kconfig-only variant of the ESP32-C6 dev
# board), EE02 is a genuinely different chip target (ESP32-S3) --
# build_for_board.sh runs `set-target esp32s3` for this board before
# building, same as it runs `set-target esp32c6` for devkit/xiao.
CONFIG_FRAME_BOARD_NAME="ee02"
# Selects the epd13in3e driver component (13.3", 1600x1200) instead of
# epd7in3e -- see main/Kconfig.projbuild and main/CMakeLists.txt.
CONFIG_FRAME_PANEL_EE02_13IN3=y
# XIAO ESP32-S3 Plus: 16MB flash, 8MB PSRAM (vs. the plain XIAO ESP32-S3's
# 8MB/8MB) -- see partitions_ee02.csv, sized generously against this,
# not yet trimmed/tuned against a real build's actual footprint.
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ee02.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_ee02.csv"
# EE02's e-paper interface pin defaults live in
# firmware/components/epd13in3e/Kconfig instead of being overridden here
# (mirrors how epd7in3e's Kconfig defaults are devkit-shaped and
# sdkconfig.xiao only overrides the ones that actually differ) -- EE02's
# pins are a different Kconfig menu entirely (EPD_PIN_CS_MASTER/CS_SLAVE/
# POWER_EN don't exist on epd7in3e's board at all), not a same-menu
# override, so there's nothing to set here beyond selecting the component
# above.
#
# Deliberately NOT overriding FRAME_NEXT_BUTTON_GPIO/FRAME_BACK_BUTTON_
# GPIO/FRAME_COMBO_BUTTON_GPIO/FRAME_BATTERY_ADC_GPIO here, even though
# the same community source that gave the epd13in3e pinout also reports
# EE02 has 3 user buttons at GPIO2/3/5: which physical button maps to
# which logical role (next/back/combo) still isn't confirmed. The
# button GPIOs' `range -1 7` constraint (main/Kconfig.projbuild) -- which
# used to be hardcoded to the ESP32-C6's deep-sleep-wakeup-capable GPIO
# set -- now widens to `range -1 21` under IDF_TARGET_ESP32S3 (the
# ESP32-S3's own ext1-wakeup-capable RTC-IO range), so GPIO2/3/5 fit
# either way and nothing here needs adjusting on that front. What's
# still unconfirmed: (1) the button-to-role mapping above, and (2)
# whether the S3 button-wakeup path itself (ext1 + RTC pull-up, see
# main/next_button.c) actually avoids the spurious-instant-wakeup bug
# that ruled out ext1 on the ESP32-C6 -- that needs real EE02 hardware,
# not just a clean compile. FRAME_BATTERY_ADC_GPIO's `range -1 6` is a
# separate, still-unwidened concern -- it's the ESP32-C6's ADC-capable
# pin set, not a deep-sleep-wakeup range, and out of scope here.
+1 -1
View File
@@ -8,7 +8,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv" CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv" CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv"
CONFIG_FRAME_BOARD_NAME="xiao" CONFIG_FRAME_BOARD_NAME="xiao_esp32c6"
# Powers the XIAO's RF switch and selects its onboard antenna -- without # Powers the XIAO's RF switch and selects its onboard antenna -- without
# this the softAP/STA radio doesn't reliably reach the antenna at all. # this the softAP/STA radio doesn't reliably reach the antenna at all.
+1 -1
View File
@@ -1 +1 @@
1.4.1 1.5.0
+32 -27
View File
@@ -37,26 +37,27 @@ algorithm itself -- it just streams the response straight to the panel.
instead (see `firmware/README.md`'s HTTPS section). instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server 5. **Each frame gets its own device token automatically** -- the server
issues it on the frame's first check-in, so there's nothing to issues it on the frame's first check-in, so there's nothing to
configure. The captive portal's **Access Token** field only matters configure. `MANAGEMENT_TOKEN` in `docker-compose.yml` is optional and
when pointing new firmware at an old (pre-multi-frame) server. only matters pre-setup: if set, it's the credential that gates who
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the gets to be the one to run first-run setup on a freshly deployed
*migration* credential: a frame flashed with pre-multi-frame server, before any admin account exists.
firmware authenticates with it until it's updated and bound (the
Admin page shows the migration state per frame and a "Close legacy
window" button for when it's done).
6. **Optional: auto-update firmware from Gitea releases.** If you're 6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml` pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets builds every supported board and publishes them as release assets
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt` (`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/
changes on `main`. In a frame's **Configuration** tab, set the `firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin`
**Gitea repo URL**; if the repo is private, also set duplicates for devices still on pre-rename firmware) whenever
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`. `firmware/version.txt` changes on `main`. In a frame's
Which board's build to fetch is learned from the frame itself (its **Configuration** tab, set the **Gitea repo URL**; if the repo is
`X-Frame-Board` header) -- nothing to pick by hand. The server then private, also set `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in
periodically checks for a newer release and either shows an "Update `docker-compose.yml`. Which board's build to fetch is learned from the
frame" button or, with **Automatically apply updates** checked, frame itself (its `X-Frame-Board` header) -- nothing to pick by hand.
stages it itself -- either way the frame only actually updates on The same header also determines which EPD panel the frame renders for
its own next wake. (`Frame.panel_type`, see `docs/hardware.md`'s board identifiers
section) -- also never a manual setting. The server then periodically
checks for a newer release and either shows an "Update frame" button
or, with **Automatically apply updates** checked, stages it itself --
either way the frame only actually updates on its own next wake.
## Users, frames, and control ## Users, frames, and control
@@ -109,9 +110,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`) ### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
- `GET /frame/image` -- the frame's current image, pre-processed into - `GET /frame/image` -- the frame's current image, pre-processed into
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format the panel's raw 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** (`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the
by default: it only actually advances once `refresh_interval_s` has original 7.3" panel, 1600x1200/exactly 960,000 bytes for the 13.3"
panel (see `Frame.panel_type`/`image_pipeline.PANEL_SPECS`; a given
device's byte count is fixed by which firmware/panel it actually is).
**Side-effect-free** by default: it only actually advances once `refresh_interval_s` has
elapsed since the current photo was set, so an unexpected reboot just elapsed since the current photo was set, so an unexpected reboot just
redisplays the same photo. An unclaimed or not-yet-configured frame redisplays the same photo. An unclaimed or not-yet-configured frame
gets a rendered instruction placeholder (with a claim QR) instead of gets a rendered instruction placeholder (with a claim QR) instead of
@@ -134,8 +138,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
*this* frame and 302s to it. Authenticated by the frame's own *this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a `manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`. 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 positions in the
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's frame's own panel space (800x480 for the 7.3" panel, 1600x1200 for the
13.3"), flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser. flat-scalar parser.
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle - `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
history (feeds the runtime estimate) plus a permanent per-frame history (feeds the runtime estimate) plus a permanent per-frame
@@ -214,9 +219,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
scan-to-download QR both use the frame's own `manage_token` (device scan-to-download QR both use the frame's own `manage_token` (device
tokens don't work for either -- neither is ever called by firmware, tokens don't work for either -- neither is ever called by firmware,
both are opened by a phone that has no way to supply `?id=`/`?token=`), both are opened by a phone that has no way to supply `?id=`/`?token=`),
and `MANAGEMENT_TOKEN` survives only as the migration credential for and `MANAGEMENT_TOKEN` is only ever the pre-setup claim gate (see
pre-multi-frame firmware. step 5 above).
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events - The calendar widget (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/), (RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import, dependency here. It's used as an ordinary `pip install` runtime import,
@@ -234,7 +239,7 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so. re-evaluate that tradeoff for their own situation before doing so.
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`) - The whiteboard widget (`app/webdav_client.py`, `app/whiteboard.py`)
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard` fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
file -- which turns out to be Excalidraw scene JSON (elements/appState/ file -- which turns out to be Excalidraw scene JSON (elements/appState/
files), not an image -- and renders it via `render-service/`, a small files), not an image -- and renders it via `render-service/`, a small
+36 -68
View File
@@ -1,14 +1,18 @@
"""Authentication: password hashing, user sessions + CSRF, the legacy """Authentication: password hashing, user sessions + CSRF, the pre-setup
shared-token gate, and device resolution. claim gate, and device resolution.
Three independent credential classes: Three independent credential classes:
- User sessions (cookie "session", server-side sessions table, per- - User sessions (cookie "session", server-side sessions table, per-
session CSRF token required on mutating requests) -- humans. session CSRF token required on mutating requests) -- humans.
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on - MANAGEMENT_TOKEN (env-only, optional). Only meaningful before any user
browser routes so the deployed frame's on-panel manage QR (which account exists yet (fresh install, or freshly migrated, before
embeds ?token=) keeps working until Phase C replaces it with the /setup has been run): if set, it gates who gets to be the one to run
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request /setup and claim the first admin account; once a user exists, sessions
credential, not an ambient cookie a cross-site request could ride). are the only way in. Not a standing bearer credential -- the on-panel
manage QR now embeds a frame's own per-frame manage_token (/m/, see
routers/manage.py) rather than this shared one; CSRF doesn't apply to
it either way (it's an explicit per-request credential, not an ambient
cookie a cross-site request could ride).
- Device credentials (?id= + ?token=, see require_device below). - Device credentials (?id= + ?token=, see require_device below).
""" """
@@ -250,17 +254,16 @@ def require_frame_control(
def management_token() -> str: def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old """The pre-setup claim-gate secret. Env-only, never stored -- same as
server, where the env var overrode anything on disk on every load.""" the old server, where the env var overrode anything on disk on every
load."""
return os.environ.get("MANAGEMENT_TOKEN", "") return os.environ.get("MANAGEMENT_TOKEN", "")
def browser_token_valid(request: Request) -> bool: def browser_token_valid(request: Request) -> bool:
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means """Whether the request carries the current MANAGEMENT_TOKEN, via
token-holders don't exist -- but unlike Phase A this no longer means query param or cookie. Only meaningful pre-setup (see require_browser
"open": once users exist, sessions are the primary gate and this is below) -- empty configured token => not valid (nothing to match)."""
only the compatibility path for the deployed frame's manage QR
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
token = management_token() token = management_token()
if not token: if not token:
return False return False
@@ -270,12 +273,12 @@ def browser_token_valid(request: Request) -> bool:
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None: def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
"""Dependency for the web UI's /api/* routes: a real user session """Dependency for the web UI's /api/* routes: a real user session
(CSRF-checked on mutations, returns the User), or the legacy shared (CSRF-checked on mutations, returns the User). While NO users exist
token (returns None -- token bearers act as an anonymous operator, yet (fresh install, or freshly migrated, before /setup has been run)
exactly the pre-user model). While NO users exist yet (fresh install the API instead stays open if no MANAGEMENT_TOKEN is set, or opens
or freshly migrated, before /setup has been run) the API stays open to whoever supplies it if one is -- there's nobody to log in as yet,
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior -- so this is purely the claim gate for who gets to run /setup. Once a
since there's nobody to log in as yet.""" user exists, only a session gets in."""
session = current_session(request, db) session = current_session(request, db)
if session is not None: if session is not None:
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session): if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
@@ -283,9 +286,8 @@ def require_browser(request: Request, db: Session = Depends(get_db)) -> User | N
user = db.get(User, session.user_id) user = db.get(User, session.user_id)
if user is not None: if user is not None:
return user return user
if browser_token_valid(request): if not users_exist(db):
return None if not management_token() or browser_token_valid(request):
if not users_exist(db) and not management_token():
return None return None
raise HTTPException(401, "Not logged in") raise HTTPException(401, "Not logged in")
@@ -326,63 +328,29 @@ def _register_frame(db: Session, device_id: str) -> Frame:
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame: def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
"""Resolves and authenticates the frame behind a /frame/* request. """Resolves and authenticates the frame behind a /frame/* request.
Firmware sends ?id=<12-hex-mac>&token=<per-frame device token>."""
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
(or nothing, on an open server) -- those requests resolve to the
unique legacy_token_enabled frame for as long as that migration
window stays open. The first id-bearing request arriving with legacy
credentials while the legacy frame has no device_id yet BINDS that id
to it -- that's the moment the deployed frame comes back up on new
firmware after its OTA, and it must not register as a second frame.
"""
device_id = request.query_params.get("id", "").strip().lower() device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "") token = request.query_params.get("token", "")
legacy = management_token()
legacy_ok = not legacy or token == legacy
if device_id: if not device_id:
raise HTTPException(401, "Missing device id")
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is None: if frame is None:
legacy_frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
legacy_frame.device_id = device_id
frame = legacy_frame
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
else:
frame = _register_frame(db, device_id) frame = _register_frame(db, device_id)
else: else:
token_ok = bool(token) and token == frame.device_token token_ok = bool(token) and token == frame.device_token
if token_ok and not frame.device_token_ack: if token_ok and not frame.device_token_ack:
frame.device_token_ack = True frame.device_token_ack = True
logger.info("Frame #%d acknowledged its device token", frame.id) logger.info("Frame #%d acknowledged its device token", frame.id)
if not token_ok: elif not token_ok and frame.device_token_ack:
if frame.legacy_token_enabled and legacy_ok:
pass
elif not frame.device_token_ack:
# Handshake window: the device registered but hasn't
# received its token yet (the wake cycle fetches the
# image BEFORE polling /frame/config, where the token
# is delivered) -- the id stays the credential, same
# trust level as the open registration that created
# the row. Closes permanently on the first
# authenticated request.
pass
else:
raise HTTPException(401, "Missing or invalid access token") raise HTTPException(401, "Missing or invalid access token")
else: # else: handshake window -- the device registered but hasn't
if not legacy_ok: # received its token yet (the wake cycle fetches the image
raise HTTPException(401, "Missing or invalid access token") # BEFORE polling /frame/config, where the token is delivered) --
frame = db.scalars( # the id stays the credential, same trust level as the open
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712 # registration that created the row. Closes permanently on the
).first() # first authenticated request.
if frame is None:
# Nothing to resolve a no-id request to. migration.py always
# creates frame #1 at startup, so this only happens if it was
# deleted -- treat like an unknown device.
raise HTTPException(401, "No frame accepts legacy credentials")
frame.last_seen = time.time() frame.last_seen = time.time()
db.commit() db.commit()
+71 -51
View File
@@ -71,24 +71,32 @@ def _day_section_data(day: date, events: list[dict], tz: ZoneInfo, palette_rgb,
def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, 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", theme_name: str | None = None) -> Image.Image: weather_units: str = "fahrenheit", theme_name: str | None = None,
"""HTML/CSS-rendered analogue of calendar_render._build_agenda. Has a font_scale: float = 1.0) -> Image.Image:
header bar -- dithered at the theme's accent_amplitude via """HTML/CSS-rendered analogue of calendar_render._build_agenda.
ordered_dither_regions."""
Bold-minimal: no card/border/shadow (theme["radius"]/theme["shadow"]
are unused, same carve-out as weather's build_current/build_daily --
see docs/widgets.md). The day header is plain ink text under a slim
accent-colored rule instead of white text on a full gradient band --
only that thin rule dithers at the theme's richer accent_amplitude
now, not the header text sitting on top of it, which is a legibility
improvement over the old design, not just a visual one."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb) theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
day = datetime.now(tz).date() + timedelta(days=browse_offset) day = datetime.now(tz).date() + timedelta(days=browse_offset)
title_size = max(14, min(target_w, target_h) // 12) title_size = panel_style.scaled_size(max(14, min(target_w, target_h) // 12), font_scale)
body_size = max(11, min(target_w, target_h) // 20) body_size = panel_style.scaled_size(max(11, min(target_w, target_h) // 20), font_scale)
weather_size = max(10, body_size - 2) weather_size = max(10, body_size - 2)
row_h = body_size + 14 row_h = body_size + 14
unit_suffix = "F" if weather_units == "fahrenheit" else "C" unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.025, 4, 8))
# Single day -- no cross-section alignment concern, so header_h can # Single day -- no cross-section alignment concern, so header_h can
# simply reflect whether THIS day actually has weather (unlike # simply reflect whether THIS day actually has weather (unlike
# build_today_tomorrow/build_week's vertical layout, which must # build_today_tomorrow/build_week's vertical layout, which must
# reserve the same header_h for every stacked section regardless). # reserve the same header_h for every stacked section regardless).
has_weather = bool(_weather_row(weather_cities, day, weather_units)) has_weather = bool(_weather_row(weather_cities, day, weather_units))
header_h = title_size + 24 + ((weather_size + 12) if has_weather else 0) header_h = accent_h + 10 + title_size + ((weather_size + 10) if has_weather else 0)
owners_seen: list[str] = [] owners_seen: list[str] = []
data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen, data = _day_section_data(day, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
@@ -96,16 +104,16 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
template = html_render._jinja_env.get_template("calendar_agenda.html.jinja") template = html_render._jinja_env.get_template("calendar_agenda.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
header=data["header"], title_size=title_size, header_h=header_h, header=data["header"], title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_entries=data["weather_entries"], accent_start=theme["accent_hex"], weather_entries=data["weather_entries"],
weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"], weather_size=weather_size, unit_suffix=unit_suffix, rows=data["rows"],
more_count=data["more_count"], row_h=row_h, body_size=body_size, more_count=data["more_count"], row_h=row_h, body_size=body_size,
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h) accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions( return html_render.ordered_dither_regions(
rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])] rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]
) )
@@ -113,25 +121,28 @@ def build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h
def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", theme_name: str | None = None) -> Image.Image: weather_units: str = "fahrenheit", theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow """HTML/CSS-rendered analogue of calendar_render._build_today_tomorrow
-- two day-sections stacked (see _day_section_data), each with its own -- two day-sections stacked (see _day_section_data). Bold-minimal, no
header bar dithered richer via ordered_dither_regions.""" card (see build_agenda's docstring) -- each section's own slim accent
rule dithers richer via ordered_dither_regions, not its header text."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb) theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
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 = target_h // 2
title_size = max(13, section_h // 8) title_size = panel_style.scaled_size(max(13, section_h // 8), font_scale)
body_size = max(10, min(target_w, target_h) // 26) body_size = panel_style.scaled_size(max(10, min(target_w, target_h) // 26), font_scale)
weather_size = max(9, body_size - 2) weather_size = max(9, body_size - 2)
row_h = body_size + 12 row_h = body_size + 12
unit_suffix = "F" if weather_units == "fahrenheit" else "C" unit_suffix = "F" if weather_units == "fahrenheit" else "C"
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
day_dates = [start_day + timedelta(days=i) for i in range(2)] day_dates = [start_day + timedelta(days=i) for i in range(2)]
# Uniform across both stacked sections regardless of which day(s) # Uniform across both stacked sections regardless of which day(s)
# actually have weather -- see _day_section_data's own docstring for # actually have weather -- see _day_section_data's own docstring for
# why a per-day header height misaligns where rows start. # why a per-day header height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates) any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 16 + ((weather_size + 10) if any_weather else 0) header_h = accent_h + 8 + title_size + ((weather_size + 8) if any_weather else 0)
owners_seen: list[str] = [] owners_seen: list[str] = []
days = [ days = [
@@ -142,16 +153,16 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja") template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=days, title_size=title_size, header_h=header_h, days=days, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size, accent_start=theme["accent_hex"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size, unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER gutter = panel_style.GUTTER
accent_regions = [ accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h), ((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"]) theme["accent_amplitude"])
for i in range(len(days)) for i in range(len(days))
] ]
@@ -161,7 +172,7 @@ def build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal", weather_units: str = "fahrenheit", days: int = 7, layout: str = "horizontal",
start_offset: int = 0, theme_name: str | None = None) -> Image.Image: start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_week -- both """HTML/CSS-rendered analogue of calendar_render._build_week -- both
the vertical (stacked day-sections, reusing build_today_tomorrow's the vertical (stacked day-sections, reusing build_today_tomorrow's
template with an arbitrary day count) and horizontal (side-by-side template with an arbitrary day count) and horizontal (side-by-side
@@ -180,16 +191,17 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
if layout == "vertical": if layout == "vertical":
section_h = target_h // days section_h = target_h // days
title_size = max(11, min(20, section_h // 6)) title_size = panel_style.scaled_size(max(11, min(20, section_h // 6)), font_scale)
body_size = max(9, min(target_w, target_h) // (18 + days)) body_size = panel_style.scaled_size(max(9, min(target_w, target_h) // (18 + days)), font_scale)
weather_size = max(8, body_size - 2) weather_size = max(8, body_size - 2)
row_h = body_size + 10 row_h = body_size + 10
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.018, 3, 5))
day_dates = [week_first_day + timedelta(days=i) for i in range(days)] day_dates = [week_first_day + timedelta(days=i) for i in range(days)]
# Uniform across all `days` stacked sections -- see # Uniform across all `days` stacked sections -- see
# _day_section_data's own docstring for why a per-day header # _day_section_data's own docstring for why a per-day header
# height misaligns where rows start. # height misaligns where rows start.
any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates) any_weather = any(_weather_row(weather_cities, d, weather_units) for d in day_dates)
header_h = title_size + 12 + ((weather_size + 8) if any_weather else 0) header_h = accent_h + 6 + title_size + ((weather_size + 6) if any_weather else 0)
day_sections = [ day_sections = [
_day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen, _day_section_data(d, events, tz, palette_rgb, weather_cities, weather_units, owners_seen,
section_h - header_h, row_h) section_h - header_h, row_h)
@@ -197,32 +209,33 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
] ]
template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja") template = html_render._jinja_env.get_template("calendar_today_tomorrow.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
days=day_sections, title_size=title_size, header_h=header_h, days=day_sections, title_size=title_size, header_h=header_h, accent_h=accent_h,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], weather_size=weather_size, accent_start=theme["accent_hex"], weather_size=weather_size,
unit_suffix=unit_suffix, row_h=row_h, body_size=body_size, unit_suffix=unit_suffix, row_h=row_h, body_size=body_size,
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_regions = [ accent_regions = [
((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + header_h), ((gutter, gutter + i * section_h, target_w - gutter, gutter + i * section_h + accent_h),
theme["accent_amplitude"]) theme["accent_amplitude"])
for i in range(len(day_sections)) for i in range(len(day_sections))
] ]
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions) return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=accent_regions)
header_size = max(10, min(16, (target_w // days) // 6)) header_size = panel_style.scaled_size(max(10, min(16, (target_w // days) // 6)), font_scale)
chip_size = max(9, header_size - 3) chip_size = max(9, header_size - 3)
weather_size = max(8, chip_size - 1) weather_size = max(8, chip_size - 1)
col_w = max(1, (target_w - panel_style.GUTTER * 2) // days) col_w = max(1, (target_w - panel_style.GUTTER * 2) // days)
row_h = chip_size + 8 row_h = chip_size + 8
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
# Reserve weather-line room in every column's header uniformly # Reserve weather-line room in every column's header uniformly
# (whether or not THIS specific day has a cached forecast) -- a # (whether or not THIS specific day has a cached forecast) -- a
# per-column height that depends on that day's own data would # per-column height that depends on that day's own data would
# misalign where each column's event rows start across the week # misalign where each column's event rows start across the week
# grid the moment any single day lacks a forecast entry. # grid the moment any single day lacks a forecast entry.
header_h = header_size + 22 + (weather_size + 6 if weather_cities else 0) header_h = header_size + 8 + (weather_size + 4 if weather_cities else 0)
max_rows = max(0, (target_h - panel_style.GUTTER * 2 - header_h) // row_h) max_rows = max(0, (target_h - panel_style.GUTTER * 2 - accent_h - 6 - header_h) // row_h)
cols = [] cols = []
for i in range(days): for i in range(days):
@@ -242,27 +255,34 @@ def build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja") template = html_render._jinja_env.get_template("calendar_week_horizontal.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
cols=cols, header_size=header_size, chip_size=chip_size, cols=cols, header_size=header_size, chip_size=chip_size,
header_h=header_h, weather_size=weather_size, unit_suffix=unit_suffix, header_h=header_h, accent_h=accent_h, weather_size=weather_size, unit_suffix=unit_suffix,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], accent_start=theme["accent_hex"],
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h) accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]) return html_render.ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, theme_name: str | None = None) -> Image.Image: week_start: int, palette_rgb: list | None = None, theme_name: str | None = None,
font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_month -- """HTML/CSS-rendered analogue of calendar_render._build_month --
density dots per day, not literal event text, same reasoning as the density dots per day, not literal event text, same reasoning as the
classic renderer (real text at typical month-cell size is close to classic renderer (real text at typical month-cell size is close to
unreadable on a 6-color dithered e-ink panel). The per-owner event unreadable on a 6-color dithered e-ink panel). The per-owner event
dots are identity-coding (like every other calendar view's chips) and dots are identity-coding (like every other calendar view's chips) and
are never touched by a theme; only the weekday-name row (a flat are never touched by a theme.
accent background, no gradient in this view) dithers richer via
ordered_dither_regions.""" Bold-minimal: no card (see build_agenda's docstring); the old flat
accent-colored weekday-name band is now a slim accent rule above
plain bold weekday labels, matching every other calendar view's
header treatment -- only that rule dithers at the theme's richer
accent_amplitude via ordered_dither_regions. "Today" is still called
out with a small accent-filled pill around its day number (a
genuinely small accent surface, not a band, so it was left alone)."""
theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb) theme = theme_tokens.resolve_theme(theme_name, "calendar", palette_rgb)
gutter = panel_style.GUTTER gutter = panel_style.GUTTER
today = datetime.now(tz).date() today = datetime.now(tz).date()
@@ -272,9 +292,10 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
) )
day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])] day_names = [n[:3] for n in (WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start])]
header_size = max(11, min(16, target_h // 30)) header_size = panel_style.scaled_size(max(11, min(16, target_h // 30)), font_scale)
day_size = max(10, min(15, target_w // 55)) day_size = panel_style.scaled_size(max(10, min(15, target_w // 55)), font_scale)
dot_size = max(4, day_size // 2) dot_size = max(4, day_size // 2)
accent_h = round(html_render._clamp(min(target_w, target_h) * 0.02, 3, 6))
owners_seen: list[str] = [] owners_seen: list[str] = []
weeks = [] weeks = []
@@ -291,14 +312,13 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
template = html_render._jinja_env.get_template("calendar_month.html.jinja") template = html_render._jinja_env.get_template("calendar_month.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=gutter, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, gutter=gutter,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
day_names=day_names, weeks=weeks, day_names=day_names, weeks=weeks, accent_h=accent_h,
header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"], header_size=header_size, day_size=day_size, dot_size=dot_size, accent_start=theme["accent_hex"],
) )
rendered = html_render.render_html_to_image(html, target_w, target_h) rendered = html_render.render_html_to_image(html, target_w, target_h)
weekday_row_h = header_size + 12 accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + weekday_row_h)
return html_render.ordered_dither_regions(rendered, palette_rgb, return html_render.ordered_dither_regions(rendered, palette_rgb,
accent_regions=[(accent_rect, theme["accent_amplitude"])]) accent_regions=[(accent_rect, theme["accent_amplitude"])])
@@ -306,7 +326,7 @@ def build_month(events: list[dict], browse_offset: int, target_w: int, target_h:
def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None, week_start: int, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal", weather_units: str = "fahrenheit", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0, theme_name: str | None = None) -> Image.Image: week_start_offset: int = 0, theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""Dispatches to the right build_* -- mirrors calendar_render._build's """Dispatches to the right build_* -- mirrors calendar_render._build's
exact "month falls back to agenda when it doesn't fit" resolution, so exact "month falls back to agenda when it doesn't fit" resolution, so
a narrow month-mode widget set to modern style still gets a sensible a narrow month-mode widget set to modern style still gets a sensible
@@ -317,11 +337,11 @@ def build(events: list[dict], view: str, browse_offset: int, target_w: int, targ
if effective_view == "agenda": if effective_view == "agenda":
return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, return build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name) weather_units, theme_name, font_scale)
if effective_view == "today_tomorrow": if effective_view == "today_tomorrow":
return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities, return build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, weather_cities,
weather_units, theme_name) weather_units, theme_name, font_scale)
if effective_view == "week": if effective_view == "week":
return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities, return build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, weather_cities,
weather_units, week_days, week_layout, week_start_offset, theme_name) weather_units, week_days, week_layout, week_start_offset, theme_name, font_scale)
return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name) return build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, theme_name, font_scale)
+51 -36
View File
@@ -29,6 +29,8 @@ from PIL import Image, ImageDraw, ImageFont
from . import panel_style from . import panel_style
from .image_pipeline import ( from .image_pipeline import (
DEFAULT_PALETTE_RGB, DEFAULT_PALETTE_RGB,
EPD_HEIGHT,
EPD_WIDTH,
_apply_manage_overlay, _apply_manage_overlay,
_quantize, _quantize,
_transpose_and_pack, _transpose_and_pack,
@@ -512,10 +514,12 @@ _AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16
def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, 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", font_scale: float = 1.0) -> Image.Image:
img, draw, region = panel_style.card_canvas(target_w, target_h) img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)] title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _AGENDA_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size) title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size) body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size) weather_font = panel_style.font_regular(weather_size)
@@ -533,7 +537,7 @@ _TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small":
def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
palette_rgb: list | None = None, weather_cities: list[dict] | None = None, palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
weather_units: str = "fahrenheit") -> Image.Image: weather_units: str = "fahrenheit", font_scale: float = 1.0) -> Image.Image:
"""Two _draw_agenda_day sections stacked vertically (below each other """Two _draw_agenda_day sections stacked vertically (below each other
rather than side-by-side -- narrower than tall doesn't leave enough rather than side-by-side -- narrower than tall doesn't leave enough
width per day for the event-row text at smaller sizes). browse_offset width per day for the event-row text at smaller sizes). browse_offset
@@ -542,7 +546,9 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int,
both views.""" both views."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)] title_size, body_size, weather_size = (
panel_style.scaled_size(v, font_scale) for v in _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size) title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size) body_font = panel_style.font_regular(body_size)
weather_font = panel_style.font_regular(weather_size) weather_font = panel_style.font_regular(weather_size)
@@ -572,7 +578,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
week_start: int, palette_rgb: list | None = None, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal", days: int = 7, layout: str = "horizontal",
start_offset: int = 0) -> Image.Image: start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side """`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing generalized) or stacked bands (layout="vertical", reusing
@@ -597,9 +603,9 @@ 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 = panel_style.font_bold(max(14, title_base - days)) title_font = panel_style.font_bold(panel_style.scaled_size(max(14, title_base - days), font_scale))
body_font = panel_style.font_regular(max(11, body_base - days)) body_font = panel_style.font_regular(panel_style.scaled_size(max(11, body_base - days), font_scale))
weather_font = panel_style.font_regular(max(9, weather_base - days)) weather_font = panel_style.font_regular(panel_style.scaled_size(max(9, weather_base - days), font_scale))
section_h = ch // days section_h = ch // days
for i in range(days): for i in range(days):
section_y0 = cy0 + i * section_h section_y0 = cy0 + i * section_h
@@ -611,7 +617,9 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
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 = (
panel_style.scaled_size(v, font_scale) for v in _WEEK_HORIZONTAL_FONTS[tier]
)
header_font = panel_style.font_bold(header_size) header_font = panel_style.font_bold(header_size)
chip_font = panel_style.font_regular(chip_size) chip_font = panel_style.font_regular(chip_size)
weather_font = panel_style.font_regular(weather_size) weather_font = panel_style.font_regular(weather_size)
@@ -667,7 +675,7 @@ _MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}
def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo, def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
week_start: int, palette_rgb: list | None = None) -> Image.Image: week_start: int, palette_rgb: list | None = None, font_scale: float = 1.0) -> Image.Image:
"""Density dots per day, not literal event text -- real text at """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.
@@ -677,7 +685,9 @@ def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h
comment above MARGIN/BG/FG.""" comment above MARGIN/BG/FG."""
img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h) img, draw, (cx0, cy0, cw, ch) = panel_style.card_canvas(target_w, target_h)
header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)] header_size, day_size = (
panel_style.scaled_size(v, font_scale) for v in _MONTH_FONTS[_size_tier(target_w, target_h)]
)
header_font = panel_style.font_bold(header_size) header_font = panel_style.font_bold(header_size)
day_font_in_month = panel_style.font_bold(day_size) day_font_in_month = panel_style.font_bold(day_size)
day_font_out_of_month = panel_style.font_regular(day_size) day_font_out_of_month = panel_style.font_regular(day_size)
@@ -750,7 +760,7 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
fetch_summary: str, week_start: int, palette_rgb: list | None = None, fetch_summary: str, week_start: int, palette_rgb: list | None = None,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> Image.Image: week_start_offset: int = 0, font_scale: float = 1.0) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
effective_view = view effective_view = view
if view == "month" and not _month_view_fits(target_w, target_h): if view == "month" and not _month_view_fits(target_w, target_h):
@@ -758,13 +768,13 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
if effective_view == "agenda": if effective_view == "agenda":
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units) weather_cities, weather_units, font_scale)
elif effective_view == "today_tomorrow": elif effective_view == "today_tomorrow":
img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb, img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units) weather_cities, weather_units, font_scale)
elif effective_view == "week": elif effective_view == "week":
img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
weather_cities, weather_units, week_days, week_layout, week_start_offset) weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
elif effective_view == "month": elif effective_view == "month":
# Never given weather -- no room for it at typical month-cell # Never given weather -- no room for it at typical month-cell
# size, same reasoning that already keeps this view to density # size, same reasoning that already keeps this view to density
@@ -772,10 +782,10 @@ def _build(events: list[dict], view: str, browse_offset: int, target_w: int, tar
# docstring). Colors are still passed through, though -- that's # docstring). Colors are still passed through, though -- that's
# a different concern (legibility of individual events) than # a different concern (legibility of individual events) than
# needing a whole extra strip of content. # needing a whole extra strip of content.
img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb) img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb, font_scale)
else: else:
img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb, img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
weather_cities, weather_units) weather_cities, weather_units, font_scale)
if fetch_summary: if fetch_summary:
# Drawn as a final overlay onto the already-composited img (not # Drawn as a final overlay onto the already-composited img (not
@@ -794,14 +804,14 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> bytes: week_start_offset: int = 0, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Renders one of CALENDAR_VIEWS full-panel to the panel's packed """Renders one of CALENDAR_VIEWS full-panel to the panel's packed
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same format. Returns exactly panel_w*panel_h/2 bytes (see
invariant every other renderer honors. weather_cities is image_pipeline.panel_size), same invariant every other renderer
routers/common.py's get_or_refresh_weather() cache, or None/[] to honors. weather_cities is routers/common.py's get_or_refresh_weather()
omit the weather strip entirely (also always omitted for view == cache, or None/[] to omit the weather strip entirely (also always
"month").""" omitted for view == "month")."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start, img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset) palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
@@ -814,13 +824,14 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
fetch_summary: str = "", manage: dict | None = None, week_start: int = 0, fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit", weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
week_days: int = 7, week_layout: str = "horizontal", week_days: int = 7, week_layout: str = "horizontal",
week_start_offset: int = 0) -> bytes: week_start_offset: int = 0, font_scale: float = 1.0,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable """Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame.""" image_pipeline.render_preview_png's relationship to render_frame."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start, img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset) palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
@@ -835,12 +846,14 @@ _TASKS_FONTS = {"large": (24, 18), "medium": (20, 16), "small": (16, 13)}
def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None, def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks") -> Image.Image: title: str = "Tasks", font_scale: float = 1.0) -> Image.Image:
"""A tasks widget's entire region is the checklist -- unlike the old """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, draw, region = panel_style.card_canvas(target_w, target_h) img, draw, region = panel_style.card_canvas(target_w, target_h)
title_size, body_size = _TASKS_FONTS[_size_tier(target_w, target_h)] title_size, body_size = (
panel_style.scaled_size(v, font_scale) for v in _TASKS_FONTS[_size_tier(target_w, target_h)]
)
title_font = panel_style.font_bold(title_size) title_font = panel_style.font_bold(title_size)
body_font = panel_style.font_regular(body_size) body_font = panel_style.font_regular(body_size)
_draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title) _draw_tasks(img, draw, region, tasks, title_font, body_font, palette_rgb, title)
@@ -848,11 +861,12 @@ def _build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: l
def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None, def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks") -> bytes: manage: dict | None = None, title: str = "Tasks",
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Renders the tasks widget full-panel to the panel's packed format. """Renders the tasks widget full-panel to the panel's packed format.
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant Returns exactly panel_w*panel_h/2 bytes, same invariant every other
every other renderer honors.""" renderer honors."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title) img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
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)
@@ -860,12 +874,13 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None, def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks") -> bytes: manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as render_tasks, but a normal browser-viewable PNG """Same pipeline as render_tasks, but a normal browser-viewable PNG
in logical (upright) orientation -- mirrors render_calendar_preview_ in logical (upright) orientation -- mirrors render_calendar_preview_
png's relationship to render_calendar.""" png's relationship to render_calendar."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title) img = _build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+4 -3
View File
@@ -15,7 +15,7 @@ import io
from PIL import Image, ImageOps from PIL import Image, ImageOps
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _has_bounding_box, _placement_transform, logical_render_size
# Not a memory constraint anymore (the overlay renders server-side now, # Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A # not malloc'd per-label on the device) -- purely a legibility cap. A
@@ -25,7 +25,8 @@ MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str, def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]: orientation: str = "landscape", region: tuple[int, int, int, int] | None = None,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the point -- manage_overlay.compose() draws these directly onto the
@@ -56,7 +57,7 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
return [] return []
if region is None: if region is None:
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else: else:
region_x0, region_y0, target_w, target_h = region region_x0, region_y0, target_w, target_h = region
+141 -45
View File
@@ -87,6 +87,20 @@ CATEGORY_EMOJI = {
"thunderstorm": "⛈️", "thunderstorm": "⛈️",
} }
# Spelled-out condition word for the "bold minimal" current-mode layout --
# classic's build_current never needed one (icon + temp only), but the
# redesigned modern layout has room for a secondary line under the temp.
CATEGORY_LABEL = {
"clear": "Clear",
"partly_cloudy": "Partly cloudy",
"cloudy": "Cloudy",
"fog": "Fog",
"rain": "Rain",
"snow": "Snow",
"thunderstorm": "Thunderstorm",
}
def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
return "#%02x%02x%02x" % tuple(rgb) return "#%02x%02x%02x" % tuple(rgb)
@@ -98,6 +112,15 @@ def _darken_hex(rgb: tuple[int, int, int], factor: float = 0.75) -> str:
return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb)) return _rgb_to_hex(tuple(max(0, round(c * factor)) for c in rgb))
def _clamp(value: float, lo: float, hi: float) -> float:
"""Keeps a size/spacing value proportional to widget dimensions
(`value` is always some fraction of target_w/target_h) while still
guaranteeing a floor (stays legible on a 1-2 grid-cell widget) and a
ceiling (stops padding/type from just growing forever on a
near-full-panel widget -- see build_current's docstring)."""
return max(lo, min(hi, value))
# --- Persistent background browser ------------------------------------- # --- Persistent background browser -------------------------------------
_loop: asyncio.AbstractEventLoop | None = None _loop: asyncio.AbstractEventLoop | None = None
@@ -268,29 +291,65 @@ def _day_label(day_date: date) -> str:
return day_date.strftime("%a") return day_date.strftime("%a")
def _short_city(city_label: str) -> str:
"""geocode_city (see docs/widgets.md's Weather widget section) hands
back a full "City, Region, Country" string -- fine for classic's
build_current (just drawn as one line, however wide) but wrong for
the bold-minimal layout's small top-row label, where a 1-2 grid-
cell widget has no room for the whole thing. Every phone-homescreen
weather widget this style is drawing from shows just the city, so
that's what this keeps -- CSS `text-overflow: ellipsis` is still in
the template as a safety net for a custom single-segment label
that's itself too long, not as the primary truncation strategy."""
return city_label.split(",")[0].strip()
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None, def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image: units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current -- """HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image either interchangeably. Returns an already-palette-exact RGB image
(see ordered_dither). No header/accent region here (just a centered (see ordered_dither).
icon+temp) -- theme-aware for font/radius only, plain ordered_dither
(no ordered_dither_regions call).""" "Bold minimal" layout: the temperature itself is the graphic --
city label + icon in a top row, the temp (dominant) and spelled-out
condition anchored to the bottom, no card/border/shadow at all. This
is a deliberate departure from every other modern-style widget's
card-on-white-canvas chrome (see docs/widgets.md) -- there's nothing
for a "card" to visually separate from here, so `theme["radius"]`/
`theme["shadow"]` have no effect on this template; still theme-aware
for font_family only, same as before. No header/accent region either
(see ordered_dither_regions' docstring) -- a themed accent has
nothing to attach to in a chrome-free layout.
Every size below is a fraction of `base` (the widget's shorter side),
clamped to a floor/ceiling rather than fixed -- so a 1-grid-cell
widget doesn't get comically oversized padding relative to its
content, and a near-full-panel widget doesn't get comically large
padding relative to *its* content either. Floors/ceilings are tuned
by eye against real widget sizes, not derived from anything."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255)) img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry: if not entry:
return img return img
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb) theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
icon_size = max(28, min(target_w, target_h) // 3) base = min(target_w, target_h)
pad = _clamp(base * 0.09, 10, 26)
icon_size = _clamp(base * 0.20, 22, 60)
temp_size = _clamp(base * 0.46, 30, 150)
deg_size = _clamp(temp_size * 0.28, 12, 40)
cond_size = _clamp(base * 0.075, 11, 20)
city_size = _clamp(base * 0.06, 10, 15)
template = _jinja_env.get_template("weather_current.html.jinja") template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], w=target_w, h=target_h, pad=round(pad),
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
emoji=CATEGORY_EMOJI.get(entry["category"], ""), emoji=CATEGORY_EMOJI.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label, condition=CATEGORY_LABEL.get(entry["category"], ""),
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3), temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=_short_city(city_label),
label_size=max(12, icon_size // 3), icon_size=round(icon_size), temp_size=round(temp_size), deg_size=round(deg_size),
cond_size=round(cond_size), city_size=round(city_size),
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb) return ordered_dither(rendered, palette_rgb)
@@ -300,9 +359,20 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image: units: str = "fahrenheit", city_label: str = "", theme_name: str | None = None) -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same """HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see call signature. Returns an already-palette-exact RGB image (see
ordered_dither). Has a header bar -- dithered at the theme's ordered_dither).
accent_amplitude via ordered_dither_regions, richer than the rest of
the widget (see that function's docstring for why).""" "Bold minimal" layout, matching build_current: no card/border/
shadow, a row of day columns each carrying its own high (dominant)
/ low (muted) temp the same way build_current makes the current
temp dominant. The old full-width gradient banner is gone --
city_label, when set, is a slim accent-colored rule (not a block)
with the city name understated beneath it, so there's still
somewhere for a theme's accent hue to show up (dithered richer via
ordered_dither_regions, same mechanism as before) without dragging
back the "card with a colored header" chrome this redesign is
moving away from. `theme["radius"]`/`theme["shadow"]` are unused
here for the same reason as build_current -- no card for them to
apply to."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255)) img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items()) days = list(daily.items())
if not days: if not days:
@@ -310,9 +380,16 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb) theme = theme_tokens.resolve_theme(theme_name, "weather", palette_rgb)
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0 base = min(target_w, target_h)
col_w = max(1, target_w // len(days)) pad = round(_clamp(base * 0.08, 10, 22))
icon_size = max(16, min(col_w // 2, 36)) col_w = max(1, (target_w - pad * 2) // len(days))
col_gap = round(_clamp(col_w * 0.12, 4, 16))
icon_size = round(_clamp(col_w * 0.30, 16, 32))
day_label_size = round(_clamp(col_w * 0.15, 10, 14))
high_size = round(_clamp(col_w * 0.32, 16, 32))
low_size = round(max(9, high_size * 0.55))
city_size = round(_clamp(base * 0.055, 10, 14))
accent_h = round(_clamp(base * 0.025, 4, 8))
day_entries = [ day_entries = [
{ {
"label": _day_label(date.fromisoformat(day_str)), "label": _day_label(date.fromisoformat(day_str)),
@@ -324,18 +401,17 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
] ]
template = _jinja_env.get_template("weather_daily.html.jinja") template = _jinja_env.get_template("weather_daily.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, pad=pad, col_gap=col_gap,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
city_label=city_label, header_h=header_h, city_label=_short_city(city_label), city_size=city_size, accent_h=accent_h,
title_size=max(14, header_h - 12), accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"],
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2), days=day_entries, icon_size=icon_size, day_label_size=day_label_size,
unit_suffix=unit_suffix, high_size=high_size, low_size=low_size, unit_suffix=unit_suffix,
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
if header_h <= 0: if not city_label:
return ordered_dither(rendered, palette_rgb) return ordered_dither(rendered, palette_rgb)
gutter = panel_style.GUTTER accent_rect = (pad, pad, target_w - pad, pad + accent_h)
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]) return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
@@ -357,15 +433,16 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None, def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "", units: str = "fahrenheit", city_label: str = "",
theme_name: str | None = None) -> bytes: theme_name: str | None = None, panel_w: int | None = None,
panel_h: int | None = None) -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png """Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview -- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see endpoint uses. build()'s output is already palette-exact (see
ordered_dither), so the final _quantize pass here is a no-op on it, ordered_dither), so the final _quantize pass here is a no-op on it,
same reasoning as the module docstring's compositing story.""" same reasoning as the module docstring's compositing story."""
from .image_pipeline import _quantize, _png_bytes, logical_render_size from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, _png_bytes, logical_render_size
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w or EPD_WIDTH, panel_h or EPD_HEIGHT)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name) img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized) return _png_bytes(quantized)
@@ -375,12 +452,12 @@ def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: l
def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict: def _battery_sizes(target_w: int, target_h: int, num_lines: int, scale: float) -> dict:
base = min(target_w, target_h) base = min(target_w, target_h)
icon_h = max(16, int(base // 3 * scale)) icon_h = max(14, int(base * 0.15 * scale))
pct_size = max(14, int(base // 3 * scale)) pct_size = max(20, int(base * 0.42 * scale))
line_size = max(9, int(base // 9 * scale)) line_size = max(9, int(base * 0.085 * scale))
gap = 8 gap = max(4, int(base * 0.035 * scale))
total = icon_h + gap + pct_size + num_lines * (line_size + gap) total = icon_h + gap + pct_size + num_lines * (line_size + gap)
return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "total": total} return {"icon_h": icon_h, "pct_size": pct_size, "line_size": line_size, "gap": gap, "total": total}
def build_battery(percent: int, lines: list[str], target_w: int, target_h: int, def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
@@ -390,18 +467,26 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
resolved by the caller (widgets/battery.py's _lines_for(), shared resolved by the caller (widgets/battery.py's _lines_for(), shared
with the classic path so the estimate/age formatting only lives in with the classic path so the estimate/age formatting only lives in
one place). Returns an already-palette-exact RGB image (see one place). Returns an already-palette-exact RGB image (see
ordered_dither). Theme-aware for font/radius only -- the charge-level ordered_dither). Theme-aware for font only -- the charge-level
fill_color below is a functional status signal (not a style choice) fill_color below is a functional status signal (not a style choice)
and is never touched by a theme, and there's no header/accent region and is never touched by a theme, and there's no header/accent region
to dither richer via ordered_dither_regions. to dither richer via ordered_dither_regions.
Bold-minimal: no card (theme["radius"] unused, same carve-out as
weather's build_current -- see docs/widgets.md); the percent is the
hero value anchored toward the bottom, same treatment build_current
gives the temperature, with the icon small and secondary above it
instead of both competing at the same size like the old centered
layout did.
Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until Shrinks icon/text sizes together (in 0.05 steps down to 0.3x) until
the whole stack actually fits the available height -- the classic the whole stack actually fits the available height -- the classic
PIL path solves the same "icon + percent + 0-2 lines in a fixed box" PIL path solves the same "icon + percent + 0-2 lines in a fixed box"
problem by truncating lines that don't fit; scaling down instead problem by truncating lines that don't fit; scaling down instead
keeps every resolved line visible, which reads better for a widget keeps every resolved line visible, which reads better for a widget
that only ever has at most 2 short caption lines to begin with.""" that only ever has at most 2 short caption lines to begin with."""
avail_h = target_h - panel_style.GUTTER * 2 pad = round(_clamp(min(target_w, target_h) * 0.09, 10, 26))
avail_h = target_h - pad * 2
num_lines = len(lines) num_lines = len(lines)
scale = 1.0 scale = 1.0
sizes = _battery_sizes(target_w, target_h, num_lines, scale) sizes = _battery_sizes(target_w, target_h, num_lines, scale)
@@ -426,13 +511,13 @@ def build_battery(percent: int, lines: list[str], target_w: int, target_h: int,
nub_w = max(3, icon_w // 10) nub_w = max(3, icon_w // 10)
template = _jinja_env.get_template("battery.html.jinja") template = _jinja_env.get_template("battery.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], w=target_w, h=target_h, pad=pad,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines, font_regular=theme["font_regular"], font_bold=theme["font_bold"], percent=percent, lines=lines,
icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke, icon_w=icon_w, icon_h=icon_h, icon_radius=icon_h // 6, stroke=stroke,
fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke), fill_pct=max(0, min(100, percent)), fill_radius=max(0, icon_h // 6 - stroke),
fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color), fill_color=_rgb_to_hex(fill_color), fill_color_dark=_darken_hex(fill_color),
nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3), nub_w=nub_w, nub_h=icon_h // 2, nub_radius=max(1, nub_w // 3),
pct_size=sizes["pct_size"], line_size=sizes["line_size"], pct_size=sizes["pct_size"], line_size=sizes["line_size"], line_gap=sizes["gap"],
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb) return ordered_dither(rendered, palette_rgb)
@@ -495,24 +580,35 @@ def build_text(cfg, target_w: int, target_h: int, palette_rgb: list | None = Non
# --- Tasks "modern" style --------------------------------------------------- # --- Tasks "modern" style ---------------------------------------------------
def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None, def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
title: str = "Tasks", theme_name: str | None = None) -> Image.Image: title: str = "Tasks", theme_name: str | None = None, font_scale: float = 1.0) -> Image.Image:
"""HTML/CSS-rendered analogue of calendar_render._build_tasks -- """HTML/CSS-rendered analogue of calendar_render._build_tasks --
same header+checklist shape. Reuses calendar_render's own same header+checklist shape. Reuses calendar_render's own
_event_colors/_fmt_task_due (the exact color-dedup/due-date-format _event_colors/_fmt_task_due (the exact color-dedup/due-date-format
logic the classic renderer uses) so a task's color chip/due string logic the classic renderer uses) so a task's color chip/due string
matches classic style exactly; only the drawing differs -- and a matches classic style exactly; only the drawing differs -- and a
theme's accent never touches those per-owner chip colors (identity- theme's accent never touches those per-owner chip colors (identity-
coding, not style). Has a header bar -- dithered at the theme's coding, not style) or the done-checkbox fill (a completion state
accent_amplitude via ordered_dither_regions. Returns an already- signal, not a style choice -- it happens to reuse the accent color,
palette-exact RGB image (see ordered_dither).""" but that's incidental, same as before this redesign).
Bold-minimal: no card (theme["shadow"]/["radius"] unused, same
carve-out as calendar's redesigned views -- see docs/widgets.md).
The old gradient header banner is now a slim accent rule + plain
bold title, matching every calendar view's day-header language --
only the rule dithers at the theme's richer accent_amplitude, not
the title text sitting on it. Returns an already-palette-exact RGB
image (see ordered_dither)."""
from .calendar_render import _event_colors, _fmt_task_due from .calendar_render import _event_colors, _fmt_task_due
theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb) theme = theme_tokens.resolve_theme(theme_name, "tasks", palette_rgb)
header_h = max(28, min(target_w, target_h) // 8) base = min(target_w, target_h)
body_size = max(11, min(target_w, target_h) // 20) accent_h = round(_clamp(base * 0.025, 3, 6))
title_size = panel_style.scaled_size(max(14, base // 12), font_scale)
body_size = panel_style.scaled_size(max(11, base // 20), font_scale)
row_h = body_size + 14 row_h = body_size + 14
box_size = max(10, body_size - 4) box_size = max(10, body_size - 4)
avail_h = target_h - header_h - 16 header_h = accent_h + 6 + title_size
avail_h = target_h - panel_style.GUTTER * 2 - header_h - 8
max_rows = max(0, avail_h // row_h) max_rows = max(0, avail_h // row_h)
owners_seen: list[str] = [] owners_seen: list[str] = []
@@ -531,15 +627,15 @@ def build_tasks(tasks: list[dict], target_w: int, target_h: int, palette_rgb: li
template = _jinja_env.get_template("tasks.html.jinja") template = _jinja_env.get_template("tasks.html.jinja")
html = template.render( html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=theme["radius"], shadow=theme["shadow"], w=target_w, h=target_h, gutter=panel_style.GUTTER,
font_regular=theme["font_regular"], font_bold=theme["font_bold"], font_regular=theme["font_regular"], font_bold=theme["font_bold"],
title=title, header_h=header_h, title_size=max(14, header_h - 12), title=title, header_h=header_h, accent_h=accent_h, title_size=title_size,
accent_start=theme["accent_hex"], accent_end=theme["accent_hex_dark"], accent_start=theme["accent_hex"],
rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size, rows=rows, more_count=more_count, row_h=row_h, box_size=box_size, body_size=body_size,
) )
rendered = render_html_to_image(html, target_w, target_h) rendered = render_html_to_image(html, target_w, target_h)
gutter = panel_style.GUTTER gutter = panel_style.GUTTER
accent_rect = (gutter, gutter, target_w - gutter, gutter + header_h) accent_rect = (gutter, gutter, target_w - gutter, gutter + accent_h)
return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])]) return ordered_dither_regions(rendered, palette_rgb, accent_regions=[(accent_rect, theme["accent_amplitude"])])
+145 -39
View File
@@ -10,6 +10,72 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
EPD_WIDTH = 800 EPD_WIDTH = 800
EPD_HEIGHT = 480 EPD_HEIGHT = 480
# Registry of every supported panel's native pixel size, keyed by
# Frame.panel_type. New entries get added here as a new EPD driver
# component is supported firmware-side (see firmware/components/) --
# geometry lives in exactly one place rather than as new module-level
# globals per panel.
DEFAULT_PANEL_TYPE = "epd7in3e"
PANEL_SPECS: dict[str, tuple[int, int]] = {
"epd7in3e": (EPD_WIDTH, EPD_HEIGHT),
# Waveshare's 13.3" e-Paper (E) Spectra 6 panel (270.40x202.80mm,
# 1600x1200px, 4:3) driven by Seeed's EE02 board. This is the panel's
# MOUNT/marketing size, not its SPI wire raster -- the controller
# itself addresses a native 1200x1600 (portrait) raster, rotated 90
# degrees from how the panel physically hangs. Both facts are now
# vendor-confirmed (see firmware/components/epd13in3e's own docstring)
# -- PANEL_SPECS stays in mount/logical terms like the 7.3" panel's
# entry (everything upstream of packing -- composition, the widget
# grid, face-label placement -- reasons in this space); the wire-raster
# rotation is applied only at pack time, see PANEL_WIRE_TRANSPOSE.
"epd13in3e": (1600, 1200),
}
# Panels whose SPI wire raster is rotated 90 degrees from PANEL_SPECS's
# mount/logical size (see that dict's own comment on epd13in3e). None =
# wire raster already matches the logical size, no extra rotation (true
# for the 7.3" panel). Applied in _transpose_and_pack AFTER the
# user-selected ORIENTATION_TRANSPOSE -- these are two independent
# rotations for two independent reasons (how the frame is hung vs. a fixed
# fact about this panel's controller wiring) and must not be conflated.
#
# Getting this wrong doesn't just rotate the output image: 1600x1200 and
# 1200x1600 don't share a row stride (800 bytes/row x 1200 rows vs 600
# bytes/row x 1600 rows), so packing at the wrong one slices real image
# rows at the wrong byte offsets and shreds the picture into a repeating
# diagonal garble on the real panel, not a clean rotation -- see
# test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride in
# tests/test_render_size_invariants.py, which catches exactly that
# regression without needing real hardware.
#
# Direction (ROTATE_90 vs ROTATE_270) is a physical-assembly fact this
# code can't derive from vendor driver bytes -- it depends on which edge
# of the panel ends up "up" in this project's frame housing. Picked
# ROTATE_90 as a documented placeholder; confirm/flip against real
# hardware once the EE02 firmware target is actually flashed and
# displaying (a wrong direction shows a rotated/mirrored image, not
# corruption, so it's safe to ship pending that check).
PANEL_WIRE_TRANSPOSE: dict[str, "Image.Transpose | None"] = {
"epd7in3e": None,
"epd13in3e": Image.Transpose.ROTATE_90,
}
# Human-readable label per PANEL_SPECS key, for the frame settings page's
# read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how
# a frame's panel_type actually gets set -- this is display-only).
PANEL_LABELS: dict[str, str] = {
"epd7in3e": '7.3" Spectra 6',
"epd13in3e": '13.3" Spectra 6',
}
def panel_size(panel_type: str) -> tuple[int, int]:
"""(width, height) native pixel size for a Frame.panel_type key.
Unknown/blank panel_type (e.g. a frame created before this field
existed) falls back to the original 7.3" panel this project shipped
with, never raises."""
return PANEL_SPECS.get(panel_type, PANEL_SPECS[DEFAULT_PANEL_TYPE])
# PIL's TrueType rendering antialiases by default (graduated gray edge # PIL's TrueType rendering antialiases by default (graduated gray edge
# pixels). Those survive straight into _quantize's Floyd-Steinberg # pixels). Those survive straight into _quantize's Floyd-Steinberg
# dithering, which -- confirmed visually -- turns them into scattered # dithering, which -- confirmed visually -- turns them into scattered
@@ -146,21 +212,24 @@ ORIENTATION_TRANSPOSE = {
} }
def logical_render_size(orientation: str) -> tuple[int, int]: def logical_render_size(orientation: str, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
"""(width, height) the photo is composed/cropped at for this """(width, height) the photo is composed/cropped at for this
orientation, before rotating into native panel space.""" orientation, before rotating into native panel space. Defaults to the
7.3" panel's native size; callers with a Frame in scope should pass
*panel_size(frame.panel_type) instead."""
if orientation in ("portrait", "portrait_flipped"): if orientation in ("portrait", "portrait_flipped"):
return EPD_HEIGHT, EPD_WIDTH return panel_h, panel_w
return EPD_WIDTH, EPD_HEIGHT return panel_w, panel_h
def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]: def logical_to_native(x: float, y: float, orientation: str,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
"""Maps a point in logical (pre-rotation) frame space to native """Maps a point in logical (pre-rotation) frame space to native
800x480 panel space, applying the same rotation ORIENTATION_TRANSPOSE panel space, applying the same rotation ORIENTATION_TRANSPOSE applies
applies to the pixels -- anything positioned in logical coordinates to the pixels -- anything positioned in logical coordinates (e.g.
(e.g. face labels) needs this to stay attached to the rotated face labels) needs this to stay attached to the rotated content.
content. PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise.""" PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
if orientation == "landscape_flipped": if orientation == "landscape_flipped":
return int(logical_w - 1 - x), int(logical_h - 1 - y) return int(logical_w - 1 - x), int(logical_h - 1 - y)
if orientation == "portrait": # ROTATE_90 (CCW) if orientation == "portrait": # ROTATE_90 (CCW)
@@ -208,10 +277,14 @@ CALIBRATED_SPECTRA6_RGB = [
(0x35, 0x56, 0x3A), # GREEN (0x35, 0x56, 0x3A), # GREEN
] ]
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e), # The 7.3" panel's actual 4-bit color codes (see
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the # firmware/components/epd7in3e), in the same order as DEFAULT_PALETTE_RGB/
# hardware protocol, never user-configurable. 0x4 is intentionally unused # PALETTE_LABELS -- fixed by the hardware protocol, never user-
# upstream. # configurable. 0x4 is intentionally unused upstream. Used unconditionally
# for every panel_type today -- confirmed (not just assumed) that the
# 13.3" panel's controller uses the identical codes, from the same vendor
# driver sources as PANEL_SPECS["epd13in3e"]'s own comment, so no
# panel-specific table is needed here.
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 # Per-widget optional border (models.Widget.border_style, see
@@ -428,11 +501,13 @@ def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, t
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image: def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB """Crop/resize/letterbox `source` per display_mode -- returns an RGB
image at logical_render_size(orientation), before enhancement or image at logical_render_size(orientation, panel_w, panel_h), before
quantization. See render_frame for what each display_mode does.""" enhancement or quantization. See render_frame for what each
return compose_into(source, faces, *logical_render_size(orientation), display_mode) display_mode does."""
return compose_into(source, faces, *logical_render_size(orientation, panel_w, panel_h), display_mode)
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image: def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
@@ -462,19 +537,34 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG) return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes: def _transpose_and_pack(quantized: Image.Image, orientation: str,
panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Rotates a logical-space quantized image into native panel space """Rotates a logical-space quantized image into native panel space
and packs it 2 pixels/byte the way epd7in3e.c expects. Always and packs it 2 pixels/byte the way the panel's EPD driver expects
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.""" (see firmware/components/epd7in3e). Returns exactly width*height/2
bytes for whatever native size `quantized` actually is post-rotation
-- the canvas was already built at the calling frame's own panel size
(see panel_size()), so this derives dimensions from the image itself
rather than a fixed global.
Two independent rotations happen here, in order: ORIENTATION_TRANSPOSE
(how the frame is physically hung -- a per-frame user choice), then
PANEL_WIRE_TRANSPOSE (a fixed fact about this panel_type's SPI wire
raster vs. its mount size -- see that dict's own comment). Most panels
need only the first; epd13in3e needs both."""
transpose = ORIENTATION_TRANSPOSE.get(orientation) transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None: if transpose is not None:
quantized = quantized.transpose(transpose) quantized = quantized.transpose(transpose)
wire_transpose = PANEL_WIRE_TRANSPOSE.get(panel_type)
if wire_transpose is not None:
quantized = quantized.transpose(wire_transpose)
pixels = quantized.load() pixels = quantized.load()
w, h = quantized.size
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2) out = bytearray(w * h // 2)
i = 0 i = 0
for y in range(EPD_HEIGHT): for y in range(h):
for x in range(0, EPD_WIDTH, 2): for x in range(0, w, 2):
left = PANEL_CODES[pixels[x, y]] left = PANEL_CODES[pixels[x, y]]
right = PANEL_CODES[pixels[x + 1, y]] right = PANEL_CODES[pixels[x + 1, y]]
out[i] = (left << 4) | right out[i] = (left << 4) | right
@@ -501,11 +591,11 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None, orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0, display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0, contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes: manage: dict | None = None, panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Fits `source` to the panel's resolution, applies color/contrast """Fits `source` to the panel's resolution, applies color/contrast
enhancement, quantizes it to the 6-color palette, and packs 2 enhancement, quantizes it to the 6-color palette, and packs 2
pixels/byte the way epd7in3e.c expects. Always returns exactly pixels/byte the way the target panel_type's EPD driver expects.
EPD_WIDTH*EPD_HEIGHT/2 bytes. Returns exactly width*height/2 bytes for that panel (see panel_size).
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio `display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
is reconciled with the panel's: crop_fill (center-crop to fill, is reconciled with the panel's: crop_fill (center-crop to fill,
@@ -531,11 +621,17 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
which callers pass this straight through from. Applied after which callers pass this straight through from. Applied after
enhancement, before quantization, so the overlay's pure black/white enhancement, before quantization, so the overlay's pure black/white
graphics aren't affected by color/contrast boost. graphics aren't affected by color/contrast boost.
`panel_type` (see Frame.panel_type/panel_size) picks which panel's
native resolution to render for -- None/unrecognized falls back to
the original 7.3" panel.
""" """
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) panel_w, panel_h = panel_size(panel_type)
fitted = _enhance(_compose(source, faces, orientation, display_mode, panel_w, panel_h),
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)
return _transpose_and_pack(quantized, orientation) return _transpose_and_pack(quantized, orientation, panel_type)
def _png_bytes(img: Image.Image) -> bytes: def _png_bytes(img: Image.Image) -> bytes:
@@ -547,7 +643,8 @@ def _png_bytes(img: Image.Image) -> bytes:
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, dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]: capture_snapshot: bool = False,
panel_type: str = DEFAULT_PANEL_TYPE) -> 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
@@ -586,8 +683,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
from the same already-quantized canvas, so a device-facing render can from the same already-quantized canvas, so a device-facing render can
also persist a browser-viewable copy (see routers/device.py's also persist a browser-viewable copy (see routers/device.py's
_record_last_displayed) without re-running composition/quantization a _record_last_displayed) without re-running composition/quantization a
second time.""" second time.
logical_w, logical_h = logical_render_size(orientation)
`panel_type` (see Frame.panel_type/panel_size) picks the target
panel's native resolution -- callers must have computed `regions`'
rects against this same panel's logical_render_size (see
routers/device.py's _render_widgets, which always derives both from
the same frame.panel_type)."""
logical_w, logical_h = logical_render_size(orientation, *panel_size(panel_type))
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:
canvas.paste(region_img.convert("RGB"), (x, y)) canvas.paste(region_img.convert("RGB"), (x, y))
@@ -597,7 +700,7 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
quantized = _quantize(fitted, palette_rgb, dither_strength) quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png: if as_png:
return _png_bytes(quantized) return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation) packed = _transpose_and_pack(quantized, orientation, panel_type)
if capture_snapshot: if capture_snapshot:
return packed, _png_bytes(quantized) return packed, _png_bytes(quantized)
return packed return packed
@@ -607,13 +710,15 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None, orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0, display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0, contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes: manage: dict | None = None, panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Identical composition/enhancement/quantization pipeline as """Identical composition/enhancement/quantization pipeline as
render_frame, but returned as a normal browser-viewable PNG in render_frame, but returned as a normal browser-viewable PNG in
logical (upright, as-the-frame-actually-hangs) orientation rather logical (upright, as-the-frame-actually-hangs) orientation rather
than packed native-panel bytes and rotation -- what the web UI's than packed native-panel bytes and rotation -- what the web UI's
"how it will look on the frame" preview shows.""" "how it will look on the frame" preview shows."""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) panel_w, panel_h = panel_size(panel_type)
fitted = _enhance(_compose(source, faces, orientation, display_mode, panel_w, panel_h),
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)
return _png_bytes(quantized) return _png_bytes(quantized)
@@ -622,7 +727,8 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
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, manage: dict | None = None, as_png: bool = False,
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]: capture_snapshot: bool = False,
panel_type: str = DEFAULT_PANEL_TYPE) -> 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
@@ -631,9 +737,9 @@ 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. `capture_snapshot`, same as render_panel's -- (packed, png) yet. `capture_snapshot`, same as render_panel's -- (packed, png)
instead of just packed.""" instead of just packed. `panel_type`, same as render_frame's."""
margin = 24 margin = 24
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation, *panel_size(panel_type))
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255)) img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
@@ -696,7 +802,7 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png: if as_png:
return _png_bytes(quantized) return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation) packed = _transpose_and_pack(quantized, orientation, panel_type)
if capture_snapshot: if capture_snapshot:
return packed, _png_bytes(quantized) return packed, _png_bytes(quantized)
return packed return packed
+4 -14
View File
@@ -110,26 +110,16 @@ def service_worker() -> FileResponse:
return FileResponse("app/static/sw.js", media_type="application/javascript") return FileResponse("app/static/sw.js", media_type="application/javascript")
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None: def _device_credential_redirect(request: Request, db) -> str | None:
"""The on-frame manage QR points at the server root with the device's """The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware: own credentials (?id=&token=). Those scans get the frame's limited
?token=<legacy shared token>). Those scans get the frame's limited manage page -- never the full UI, which requires a login."""
manage page -- never the full UI, which requires a login.
allow_legacy is False before /setup has run: at that point a bare
?token= hit is the admin coming through the token prompt to do
first-run setup, not a QR scan."""
device_id = request.query_params.get("id", "").strip().lower() device_id = request.query_params.get("id", "").strip().lower()
token = request.query_params.get("token", "") token = request.query_params.get("token", "")
if device_id and token: if device_id and token:
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first() frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
if frame is not None and token == frame.device_token: if frame is not None and token == frame.device_token:
return f"/m/{frame.manage_token}" return f"/m/{frame.manage_token}"
if allow_legacy and token and management_token() and token == management_token():
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is not None:
return f"/m/{frame.manage_token}"
return None return None
@@ -140,7 +130,7 @@ def index(request: Request):
else is walked through setup/login.""" else is walked through setup/login."""
with SessionLocal() as db: with SessionLocal() as db:
have_users = users_exist(db) have_users = users_exist(db)
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users) manage_redirect = _device_credential_redirect(request, db)
if manage_redirect is not None: if manage_redirect is not None:
return RedirectResponse(manage_redirect, status_code=303) return RedirectResponse(manage_redirect, status_code=303)
+370 -207
View File
@@ -22,13 +22,9 @@ from .db import SessionLocal, engine
from .models import ( from .models import (
Base, Base,
BatteryLog, BatteryLog,
CalendarWidgetConfig,
Frame, Frame,
FrameTaskList,
PhotoWidgetConfig, PhotoWidgetConfig,
ServerSettings, ServerSettings,
TaskWidgetConfig,
WhiteboardWidgetConfig,
Widget, Widget,
) )
from .widgets import default_button_actions from .widgets import default_button_actions
@@ -889,6 +885,290 @@ def _migration_39(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'")) conn.execute(text("ALTER TABLE frames ADD COLUMN theme TEXT NOT NULL DEFAULT 'classic'"))
def _migration_40(conn) -> None:
"""Per-widget text-size multiplier (models.Widget.font_scale, see
panel_style.FONT_SCALE_CHOICES) -- a Widget-level column like
border_style/border_thickness/border_color_index, not a per-type
config field, since any widget type with body text can use it. Every
existing widget defaults to 1.0 (unchanged size) until its own
dialog's "Text size" picker sets it. Guarded per-column, same
reasoning as every prior migration's own comment."""
existing = {c["name"] for c in inspect(conn).get_columns("widgets")}
if "font_scale" not in existing:
conn.execute(text("ALTER TABLE widgets ADD COLUMN font_scale REAL NOT NULL DEFAULT 1.0"))
def _raw_backfill_frame_widgets(conn, frame_row, now: float) -> None:
"""Raw-SQL equivalent of the old ORM-based _backfill_frame_widgets --
called from _migration_41 while the legacy Frame columns it reads
still physically exist, for the rare frame (if any) that somehow
reached this migration without ever getting a Widget during the long
window _ensure_widgets_backfilled ran unconditionally at every
startup between migration 16 and this one. Same mode dispatch,
including the calendar_photo_inlay two-widget split and the legacy
tasks-source carryover. Has to be hand-rolled in raw SQL rather than
reusing the old ORM helpers, since those read these columns off
models.Frame, which no longer declares them as of this migration."""
frame_id = frame_row["id"]
orientation = frame_row["orientation"] or "landscape"
cols, rows = grid.grid_dims(orientation)
mode = frame_row["mode"] if frame_row["mode"] in ("photos", "calendar", "whiteboard") else "photos"
def insert_widget(x, y, w, h, widget_type, sort_order):
# border_style/border_thickness/border_color_index/font_scale
# spelled out explicitly (migrations 26/40's own defaults)
# rather than relied on implicitly -- they're real SQL-level
# DEFAULTs in any database that reached this migration through
# the normal upgrade path, but this stays correct even if that
# ever stops being true.
result = conn.execute(text(
"INSERT INTO widgets (frame_id, widget_type, x, y, w, h, sort_order, created_at, "
"border_style, border_thickness, border_color_index, font_scale) "
"VALUES (:frame_id, :widget_type, :x, :y, :w, :h, :sort_order, :created_at, "
"'none', 3, 0, 1.0)"
), {"frame_id": frame_id, "widget_type": widget_type, "x": x, "y": y, "w": w, "h": h,
"sort_order": sort_order, "created_at": now})
return result.lastrowid
def insert_photo_config(widget_id):
conn.execute(text(
"INSERT INTO photo_widget_configs (widget_id, album_id, photo_order, display_mode, "
"queue_target_len, current_asset_id, current_asset_set_at, queue, queue_cursor, history, "
"excluded_asset_ids, locked) VALUES (:widget_id, :album_id, :photo_order, :display_mode, "
":queue_target_len, :current_asset_id, :current_asset_set_at, :queue, :queue_cursor, "
":history, :excluded_asset_ids, 0)"
), {"widget_id": widget_id, "album_id": frame_row["album_id"], "photo_order": frame_row["photo_order"],
"display_mode": frame_row["display_mode"], "queue_target_len": frame_row["queue_target_len"],
"current_asset_id": frame_row["current_asset_id"],
"current_asset_set_at": frame_row["current_asset_set_at"], "queue": frame_row["queue"],
"queue_cursor": frame_row["queue_cursor"], "history": frame_row["history"],
"excluded_asset_ids": frame_row["excluded_asset_ids"]})
def insert_calendar_config(widget_id):
conn.execute(text(
"INSERT INTO calendar_widget_configs (widget_id, view, week_start, browse_offset, checked_at, "
"cached_events, fetch_summary, weather_enabled, weather_units, weather_cities, "
"weather_checked_at, weather_cached, week_days, week_layout, week_start_offset, render_style) "
"VALUES (:widget_id, :view, :week_start, :browse_offset, :checked_at, :cached_events, "
":fetch_summary, :weather_enabled, :weather_units, :weather_cities, :weather_checked_at, "
":weather_cached, :week_days, :week_layout, :week_start_offset, 'classic')"
), {"widget_id": widget_id, "view": frame_row["calendar_view"],
"week_start": frame_row["calendar_week_start"], "browse_offset": frame_row["calendar_browse_offset"],
"checked_at": frame_row["calendar_checked_at"], "cached_events": frame_row["calendar_cached_events"],
"fetch_summary": frame_row["calendar_fetch_summary"],
"weather_enabled": frame_row["calendar_weather_enabled"],
"weather_units": frame_row["calendar_weather_units"],
"weather_cities": frame_row["calendar_weather_cities"],
"weather_checked_at": frame_row["calendar_weather_checked_at"],
"weather_cached": frame_row["calendar_weather_cached"], "week_days": frame_row["calendar_week_days"],
"week_layout": frame_row["calendar_week_layout"],
"week_start_offset": frame_row["calendar_week_start_offset"]})
def insert_whiteboard_config(widget_id):
conn.execute(text(
"INSERT INTO whiteboard_widget_configs (widget_id, user_id, url, checked_at, cached_image, "
"render_style) VALUES (:widget_id, :user_id, :url, :checked_at, :cached_image, 'classic')"
), {"widget_id": widget_id, "user_id": frame_row["whiteboard_user_id"], "url": frame_row["whiteboard_url"],
"checked_at": frame_row["whiteboard_checked_at"], "cached_image": frame_row["whiteboard_cached_image"]})
def insert_button_actions(widget_id, widget_type):
if widget_type == "whiteboard":
pairs = [("next", "check_now"), ("back", "check_now")]
elif widget_type in ("photos", "calendar"):
pairs = [("next", "advance"), ("back", "back")]
else:
pairs = []
for button, action in pairs:
conn.execute(text(
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
"VALUES (:frame_id, :button, :widget_id, :action, 0, :created_at)"
), {"frame_id": frame_id, "button": button, "widget_id": widget_id, "action": action,
"created_at": now})
def maybe_add_tasks_widget(existing_rects, next_sort_order):
if not frame_row["calendar_tasks_calendar_key"] or not frame_row["calendar_tasks_user_id"]:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(orientation, existing_rects, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame_id
)
return
x, y, w, h = rect
widget_id = insert_widget(x, y, w, h, "tasks", next_sort_order)
conn.execute(text(
"INSERT INTO task_widget_configs (widget_id, checked_at, cached, name, show_completed, "
"render_style) VALUES (:widget_id, :checked_at, :cached, '', 0, 'classic')"
), {"widget_id": widget_id, "checked_at": frame_row["calendar_tasks_checked_at"],
"cached": frame_row["calendar_tasks_cached"]})
conn.execute(text(
"INSERT INTO frame_task_lists (widget_id, user_id, calendar_key, included) "
"VALUES (:widget_id, :user_id, :calendar_key, 1)"
), {"widget_id": widget_id, "user_id": frame_row["calendar_tasks_user_id"],
"calendar_key": frame_row["calendar_tasks_calendar_key"]})
if mode == "calendar" and frame_row["calendar_photo_inlay"]:
half = cols // 2
cal_widget_id = insert_widget(0, 0, cols - half, rows, "calendar", 0)
photo_widget_id = insert_widget(cols - half, 0, half, rows, "photos", 1)
insert_calendar_config(cal_widget_id)
insert_photo_config(photo_widget_id)
insert_button_actions(cal_widget_id, "calendar")
maybe_add_tasks_widget([(0, 0, cols - half, rows), (cols - half, 0, half, rows)], 2)
return
widget_id = insert_widget(0, 0, cols, rows, mode, 0)
if mode == "photos":
insert_photo_config(widget_id)
elif mode == "calendar":
insert_calendar_config(widget_id)
elif mode == "whiteboard":
insert_whiteboard_config(widget_id)
insert_button_actions(widget_id, mode)
if mode == "calendar":
maybe_add_tasks_widget([(0, 0, cols, rows)], 1)
def _migration_41(conn) -> None:
"""Drops the legacy per-mode Frame columns the widget system
(migration 16) superseded -- mode, the photo-queue fields (album_id/
photo_order/display_mode/queue_target_len/current_asset_id/
current_asset_set_at/queue/queue_cursor/history/excluded_asset_ids),
every calendar_* field, every whiteboard_* field, and
legacy_token_enabled (models.py's own removal, alongside auth.py
dropping the shared MANAGEMENT_TOKEN device/browser fallback it
gated -- see auth.py's module docstring) -- see docs/widgets.md's
Known Gaps, which deliberately left this open as a much larger blast
radius than this project's usual same-migration-drop convention.
_ensure_widgets_backfilled ran unconditionally at the end of every
startup from migration 16 until this one, so in practice every frame
already has a Widget built from these columns' values by now; the
backfill loop below (_raw_backfill_frame_widgets) is the same safety
net migration 17/18 used for their own column drops, covering the
edge case of a frame that somehow reached this point with none (e.g.
a very old, never-restarted backup).
Guarded on "mode" existing, same reasoning as migration 26/27/29/
30/40's own comments: frames IS dropped/recreated here (unlike
widgets/photo_widget_configs, which those migrations left alone),
but a fresh-install create_all() copy already reflects today's
models.py -- i.e. the post-this-migration shape, missing "mode"
entirely -- so a test replaying migrations 16+ from an old
schema_version without also reconstructing frames' pre-41 columns
would otherwise hit "no such column: mode" here even though it has
nothing to do with what that test is actually exercising."""
if "mode" not in {c["name"] for c in inspect(conn).get_columns("frames")}:
return
now = time.time()
frame_rows = conn.execute(text(
"SELECT id, mode, orientation, album_id, photo_order, display_mode, queue_target_len, "
"current_asset_id, current_asset_set_at, queue, queue_cursor, history, excluded_asset_ids, "
"calendar_view, calendar_week_start, calendar_photo_inlay, calendar_browse_offset, "
"calendar_checked_at, calendar_cached_events, calendar_fetch_summary, calendar_weather_enabled, "
"calendar_weather_units, calendar_weather_cities, calendar_weather_checked_at, "
"calendar_weather_cached, calendar_week_days, calendar_week_layout, calendar_week_start_offset, "
"calendar_tasks_calendar_key, calendar_tasks_user_id, calendar_tasks_checked_at, "
"calendar_tasks_cached, whiteboard_user_id, whiteboard_url, whiteboard_checked_at, "
"whiteboard_cached_image FROM frames"
)).mappings().all()
for row in frame_rows:
has_widget = conn.execute(
text("SELECT 1 FROM widgets WHERE frame_id = :fid LIMIT 1"), {"fid": row["id"]}
).first()
if has_widget is None:
_raw_backfill_frame_widgets(conn, row, now)
conn.execute(text(
"CREATE TABLE frames_new ("
"id INTEGER PRIMARY KEY, "
"device_id TEXT UNIQUE, "
"name TEXT NOT NULL DEFAULT '', "
"owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"controlled_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
"device_token TEXT NOT NULL, "
"device_token_ack INTEGER NOT NULL DEFAULT 0, "
"manage_token TEXT NOT NULL UNIQUE, "
"claimed_at REAL, "
"created_at REAL NOT NULL DEFAULT 0.0, "
"immich_url TEXT NOT NULL DEFAULT '', "
"immich_api_key TEXT NOT NULL DEFAULT '', "
"refresh_interval_s INTEGER NOT NULL DEFAULT 3600, "
"quiet_hours_enabled INTEGER NOT NULL DEFAULT 0, "
"quiet_hours_start TEXT NOT NULL DEFAULT '22:00', "
"quiet_hours_end TEXT NOT NULL DEFAULT '07:00', "
"timezone TEXT NOT NULL DEFAULT 'UTC', "
"orientation TEXT NOT NULL DEFAULT 'landscape', "
"palette_rgb TEXT, "
"color_boost REAL NOT NULL DEFAULT 1.0, "
"contrast_boost REAL NOT NULL DEFAULT 1.0, "
"dither_strength REAL NOT NULL DEFAULT 1.0, "
"photo_palette_rgb TEXT, "
"photo_dither_strength REAL NOT NULL DEFAULT 1.0, "
"theme TEXT NOT NULL DEFAULT 'classic', "
"battery_percent INTEGER NOT NULL DEFAULT -1, "
"battery_as_of REAL NOT NULL DEFAULT 0.0, "
"battery_history TEXT NOT NULL DEFAULT '[]', "
"last_seen REAL NOT NULL DEFAULT 0.0, "
"device_firmware_version TEXT NOT NULL DEFAULT '', "
"device_board_variant TEXT NOT NULL DEFAULT '', "
"battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1, "
"battery_alert_sent INTEGER NOT NULL DEFAULT 0, "
"firmware_available_version TEXT NOT NULL DEFAULT '', "
"firmware_update_repo_url TEXT NOT NULL DEFAULT '', "
"firmware_auto_update INTEGER NOT NULL DEFAULT 0, "
"firmware_update_token TEXT NOT NULL DEFAULT '', "
"firmware_update_checked_at REAL NOT NULL DEFAULT 0.0, "
"firmware_gitea_latest_version TEXT NOT NULL DEFAULT '', "
"hold_duration_ms INTEGER NOT NULL DEFAULT 3000, "
"next_hold_action TEXT, "
"back_hold_action TEXT, "
"last_cycled_layout_id INTEGER, "
"last_displayed_image BLOB, "
"last_displayed_at REAL NOT NULL DEFAULT 0.0, "
"stats_first_seen REAL NOT NULL DEFAULT 0.0, "
"stats_device_wakes INTEGER NOT NULL DEFAULT 0, "
"stats_photos_displayed INTEGER NOT NULL DEFAULT 0, "
"stats_photos_removed INTEGER NOT NULL DEFAULT 0, "
"stats_battery_reports INTEGER NOT NULL DEFAULT 0, "
"stats_recharge_cycles INTEGER NOT NULL DEFAULT 0, "
"stats_ota_updates_applied INTEGER NOT NULL DEFAULT 0, "
"stats_config_saves INTEGER NOT NULL DEFAULT 0)"
))
kept_columns = (
"id, device_id, name, owner_user_id, controlled_by_user_id, device_token, device_token_ack, "
"manage_token, claimed_at, created_at, immich_url, immich_api_key, refresh_interval_s, "
"quiet_hours_enabled, quiet_hours_start, quiet_hours_end, timezone, orientation, palette_rgb, "
"color_boost, contrast_boost, dither_strength, photo_palette_rgb, photo_dither_strength, theme, "
"battery_percent, battery_as_of, battery_history, last_seen, device_firmware_version, "
"device_board_variant, battery_alert_threshold_pct, battery_alert_sent, "
"firmware_available_version, firmware_update_repo_url, firmware_auto_update, "
"firmware_update_token, firmware_update_checked_at, firmware_gitea_latest_version, "
"hold_duration_ms, next_hold_action, back_hold_action, last_cycled_layout_id, "
"last_displayed_image, last_displayed_at, stats_first_seen, stats_device_wakes, "
"stats_photos_displayed, stats_photos_removed, stats_battery_reports, stats_recharge_cycles, "
"stats_ota_updates_applied, stats_config_saves"
)
conn.execute(text(f"INSERT INTO frames_new ({kept_columns}) SELECT {kept_columns} FROM frames"))
conn.execute(text("DROP TABLE frames"))
conn.execute(text("ALTER TABLE frames_new RENAME TO frames"))
def _migration_42(conn) -> None:
"""Which EPD panel a frame renders for (models.Frame.panel_type, see
image_pipeline.PANEL_SPECS) -- same guarded-per-column shape as every
prior migration. Every existing frame defaults to 'epd7in3e' (the
original 7.3" panel), auto-corrected on next check-in if the device
actually reports a different board (routers/device.py's
BOARD_PANEL_MAP)."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "panel_type" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN panel_type TEXT NOT NULL DEFAULT 'epd7in3e'"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -929,6 +1209,9 @@ MIGRATIONS = [
(37, _migration_37), (37, _migration_37),
(38, _migration_38), (38, _migration_38),
(39, _migration_39), (39, _migration_39),
(40, _migration_40),
(41, _migration_41),
(42, _migration_42),
] ]
@@ -945,18 +1228,53 @@ def run_migrations() -> None:
# already added (e.g. "duplicate column name"). Jump # already added (e.g. "duplicate column name"). Jump
# straight to the latest version instead. # straight to the latest version instead.
_migration_1(conn) _migration_1(conn)
latest = MIGRATIONS[-1][0] current = MIGRATIONS[-1][0]
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest}) conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": current})
else: else:
current = row[0] current = row[0]
# Each migration commits in its own transaction (rather than the
# whole batch sharing one, like this used to) so that _migration_41
# can get a connection with no transaction pending on it yet --
# SQLite only honors toggling PRAGMA foreign_keys when issued as a
# connection's literal first statement, and it needs that off for
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
# for widgets/frame_button_actions/user_frames/etc, so leaving
# enforcement on there would cascade-delete every frame's widgets,
# not just the columns that migration means to drop). A crash
# partway through now simply leaves schema_version at the last
# migration that actually completed, same as it always could
# between separate runs of this function.
for version, fn in MIGRATIONS: for version, fn in MIGRATIONS:
if version > current: if version <= current:
continue
logger.info("Applying schema migration %d", version) logger.info("Applying schema migration %d", version)
with engine.connect() as conn:
if fn is _migration_41:
# Executing this before anything else auto-begins
# SQLAlchemy's own Transaction bookkeeping too, so an
# explicit conn.begin() below would conflict with it --
# fn(conn) and the version UPDATE just ride that same
# auto-begun transaction, committed explicitly at the end.
conn.execute(text("PRAGMA foreign_keys=OFF"))
fn(conn) fn(conn)
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
conn.commit()
if fn is _migration_41:
# Restore it before this connection goes back to the
# pool -- otherwise a later checkout of the same
# underlying DBAPI connection (the connect-event
# listener in db.py only fires for a genuinely new one)
# would silently run with enforcement off. Has to
# happen AFTER commit(), same "no pending transaction"
# requirement as the OFF toggle above -- issuing it
# before the commit is exactly the mid-transaction
# no-op this migration exists to work around in the
# first place, just in the other direction.
conn.execute(text("PRAGMA foreign_keys=ON"))
_ensure_frame_one() _ensure_frame_one()
_ensure_server_settings() _ensure_server_settings()
_ensure_widgets_backfilled()
_ensure_frame_calendars_rekeyed() _ensure_frame_calendars_rekeyed()
@@ -971,11 +1289,11 @@ def new_manage_token() -> str:
def _ensure_frame_one() -> None: def _ensure_frame_one() -> None:
"""First boot only (frames table empty): create frame #1 -- imported """First boot only (frames table empty): create frame #1 -- imported
verbatim from a legacy config.json if one exists, otherwise fresh verbatim from a legacy config.json if one exists, otherwise fresh
defaults. Either way it's the legacy-token frame: the deployed defaults -- plus a single full-panel photos widget carrying over
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN, whatever photo-queue state that file had (the widget system's
and require_device resolves those requests here. The frames-nonempty equivalent of what used to live directly on Frame; see migration
guard makes this idempotent; config.json is left untouched as the 41). The frames-nonempty guard makes this idempotent; config.json is
rollback path.""" left untouched as the rollback path."""
with SessionLocal() as db: with SessionLocal() as db:
if db.scalars(select(Frame).limit(1)).first() is not None: if db.scalars(select(Frame).limit(1)).first() is not None:
return return
@@ -988,26 +1306,15 @@ def _ensure_frame_one() -> None:
device_id=None, device_id=None,
device_token=new_device_token(), device_token=new_device_token(),
manage_token=new_manage_token(), manage_token=new_manage_token(),
legacy_token_enabled=True,
created_at=time.time(), created_at=time.time(),
immich_url=cfg.immich_url, immich_url=cfg.immich_url,
immich_api_key=cfg.immich_api_key, immich_api_key=cfg.immich_api_key,
album_id=cfg.album_id,
order=cfg.order,
refresh_interval_s=cfg.refresh_interval_s, refresh_interval_s=cfg.refresh_interval_s,
quiet_hours_enabled=cfg.quiet_hours_enabled, quiet_hours_enabled=cfg.quiet_hours_enabled,
quiet_hours_start=cfg.quiet_hours_start, quiet_hours_start=cfg.quiet_hours_start,
quiet_hours_end=cfg.quiet_hours_end, quiet_hours_end=cfg.quiet_hours_end,
timezone=cfg.timezone, timezone=cfg.timezone,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
orientation=cfg.orientation, orientation=cfg.orientation,
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
battery_percent=cfg.battery_percent, battery_percent=cfg.battery_percent,
battery_as_of=cfg.battery_as_of, battery_as_of=cfg.battery_as_of,
battery_history=[list(pair) for pair in cfg.battery_history], battery_history=[list(pair) for pair in cfg.battery_history],
@@ -1030,11 +1337,31 @@ def _ensure_frame_one() -> None:
stats_config_saves=cfg.stats.config_saves, stats_config_saves=cfg.stats.config_saves,
) )
db.add(frame) db.add(frame)
db.flush() # assign frame.id for the battery log rows db.flush() # assign frame.id for the battery log rows + widget FK
for pair in cfg.battery_log: for pair in cfg.battery_log:
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1])) db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
cols, rows = grid.grid_dims(frame.orientation)
widget = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush() # assign widget.id for the config row's FK
db.add(PhotoWidgetConfig(
widget_id=widget.id,
album_id=cfg.album_id,
order=cfg.order,
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
queue_target_len=cfg.queue_target_len,
current_asset_id=cfg.current_asset_id,
current_asset_set_at=cfg.current_asset_set_at,
queue=list(cfg.queue),
queue_cursor=cfg.queue_cursor,
history=list(cfg.history),
excluded_asset_ids=list(cfg.excluded_asset_ids),
))
db.add_all(default_button_actions(frame.id, widget.id, "photos"))
db.commit() db.commit()
# The single legacy firmware slot becomes frame #1's per-frame slot. # The single legacy firmware slot becomes frame #1's per-frame slot.
@@ -1064,168 +1391,6 @@ def _ensure_server_settings() -> None:
db.commit() db.commit()
def _photo_config_from_frame(frame: Frame, widget_id: int) -> PhotoWidgetConfig:
return PhotoWidgetConfig(
widget_id=widget_id,
album_id=frame.album_id,
order=frame.order,
display_mode=frame.display_mode,
queue_target_len=frame.queue_target_len,
current_asset_id=frame.current_asset_id,
current_asset_set_at=frame.current_asset_set_at,
queue=list(frame.queue),
queue_cursor=frame.queue_cursor,
history=list(frame.history),
excluded_asset_ids=list(frame.excluded_asset_ids),
)
def _calendar_config_from_frame(frame: Frame, widget_id: int) -> CalendarWidgetConfig:
return CalendarWidgetConfig(
widget_id=widget_id,
view=frame.calendar_view,
week_start=frame.calendar_week_start,
browse_offset=frame.calendar_browse_offset,
checked_at=frame.calendar_checked_at,
cached_events=list(frame.calendar_cached_events) if frame.calendar_cached_events else None,
fetch_summary=frame.calendar_fetch_summary,
weather_enabled=frame.calendar_weather_enabled,
weather_units=frame.calendar_weather_units,
weather_cities=list(frame.calendar_weather_cities) if frame.calendar_weather_cities else None,
weather_checked_at=frame.calendar_weather_checked_at,
weather_cached=list(frame.calendar_weather_cached) if frame.calendar_weather_cached else None,
week_days=frame.calendar_week_days,
week_layout=frame.calendar_week_layout,
week_start_offset=frame.calendar_week_start_offset,
# tasks_* deliberately not carried over -- see
# _task_config_and_list_from_frame, a sibling standalone widget
# now, not part of this config.
)
def _task_config_and_list_from_frame(frame: Frame, widget_id: int) -> tuple[TaskWidgetConfig, FrameTaskList]:
"""Only ever called for a frame whose legacy calendar_tasks_* columns
(see Frame's own docstring on those -- a dead pre-widget-system
field set, same status as calendar_photo_inlay below) still carry a
configured source -- i.e. a database jumping straight from before
the widget system existed to after tasks became their own
multi-list widget type in a single upgrade, skipping both
intermediate periods where it would have lived on
CalendarWidgetConfig (_migration_17's extraction) and then a
single-source TaskWidgetConfig (_migration_18's extraction) instead.
Reproduces the same shape those two migrations arrive at directly:
a bare cache-state config plus one included FrameTaskList row."""
cfg = TaskWidgetConfig(
widget_id=widget_id,
checked_at=frame.calendar_tasks_checked_at,
cached=list(frame.calendar_tasks_cached) if frame.calendar_tasks_cached else None,
)
task_list = FrameTaskList(
widget_id=widget_id,
user_id=frame.calendar_tasks_user_id,
calendar_key=frame.calendar_tasks_calendar_key,
included=True,
)
return cfg, task_list
def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWidgetConfig:
return WhiteboardWidgetConfig(
widget_id=widget_id,
user_id=frame.whiteboard_user_id,
url=frame.whiteboard_url,
checked_at=frame.whiteboard_checked_at,
cached_image=frame.whiteboard_cached_image,
)
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
in one upgrade (see _task_config_and_list_from_frame) --
frame.calendar_tasks_* is the dead legacy field set otherwise.
Requires both calendar_key and user_id (FrameTaskList.user_id is
NOT NULL) -- same guard _migration_18's own SQL extraction uses.
Auto-placed in whatever open space is left after the widget(s) above
it in _backfill_frame_widgets claimed theirs, same find_open_rect
logic a manual "add widget" uses; silently dropped (logged) if none
fits, same as this migration having nowhere else to put it either."""
if not frame.calendar_tasks_calendar_key or not frame.calendar_tasks_user_id:
return
min_w, min_h = grid.MIN_FOOTPRINT["tasks"]
rect = grid.find_open_rect(frame.orientation, existing, min_w, min_h)
if rect is None:
logger.warning(
"Frame %d had a legacy task list configured but no open grid space for a "
"standalone tasks widget during backfill -- its task source was dropped", frame.id
)
return
x, y, w, h = rect
task_widget = Widget(frame_id=frame.id, widget_type="tasks", x=x, y=y, w=w, h=h,
sort_order=next_sort_order, created_at=time.time())
db.add(task_widget)
db.flush()
cfg, task_list = _task_config_and_list_from_frame(frame, task_widget.id)
db.add(cfg)
db.add(task_list)
def _backfill_frame_widgets(db, frame: Frame) -> None:
cols, rows = grid.grid_dims(frame.orientation)
mode = frame.mode if frame.mode in ("photos", "calendar", "whiteboard") else "photos"
if mode == "calendar" and frame.calendar_photo_inlay:
# Reproduces the old fixed 50/50 inlay split as two independent
# widgets instead of silently dropping half of what the frame was
# showing -- see models.py's CalendarWidgetConfig docstring on why
# "photo inlay" isn't a widget-system concept anymore otherwise.
half = cols // 2
cal_widget = Widget(frame_id=frame.id, widget_type="calendar",
x=0, y=0, w=cols - half, h=rows, sort_order=0, created_at=time.time())
photo_widget = Widget(frame_id=frame.id, widget_type="photos",
x=cols - half, y=0, w=half, h=rows, sort_order=1, created_at=time.time())
db.add_all([cal_widget, photo_widget])
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
return
widget = Widget(frame_id=frame.id, widget_type=mode, x=0, y=0, w=cols, h=rows,
sort_order=0, created_at=time.time())
db.add(widget)
db.flush()
if mode == "photos":
db.add(_photo_config_from_frame(frame, widget.id))
elif mode == "calendar":
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
def _ensure_widgets_backfilled() -> None:
"""Every frame needs at least one Widget once the widget system is
live -- runs unconditionally after every startup (both a from-scratch
_ensure_frame_one() install and an existing-install upgrade past
_migration_16 land here) and is a no-op for any frame that already
has one. Builds a widget that reproduces the frame's current mode/
settings/state exactly, so upgrading never changes what a frame
displays or what its physical buttons do on its own."""
with SessionLocal() as db:
for frame in db.scalars(select(Frame)).all():
has_widget = db.scalars(select(Widget).where(Widget.frame_id == frame.id).limit(1)).first()
if has_widget is not None:
continue
_backfill_frame_widgets(db, frame)
db.commit()
def _ensure_frame_calendars_rekeyed() -> None: def _ensure_frame_calendars_rekeyed() -> None:
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can """Re-keys frame_calendars from frame_id to widget_id -- a frame can
hold more than one independent calendar widget (see the widget hold more than one independent calendar widget (see the widget
@@ -1238,27 +1403,25 @@ def _ensure_frame_calendars_rekeyed() -> None:
and savable even while a frame's old `mode` was "photos"), not real and savable even while a frame's old `mode` was "photos"), not real
live configuration. live configuration.
Deliberately NOT a numbered migration: this needs each frame's Deliberately NOT a numbered migration: every frame's calendar widget
calendar widget to already exist to know what to re-key against, and must already exist to know what to re-key against, and for a genuine
those widget rows aren't created by a schema migration at all -- pre-widget-system database those rows only exist once _migration_41's
they come from _ensure_widgets_backfilled() above, which (like this own backfill has run (a step inside that migration, not before it).
function) runs unconditionally after every startup rather than being A numbered migration for this would race ahead of that backfill (the
tracked by schema_version. Running this as a numbered migration numbered-migration loop runs top to bottom in one pass, see
would execute it *before* that backfill during a real upgrade (the run_migrations), silently dropping every row -- caught by
numbered-migration loop runs first, see run_migrations), silently test_migrations.py actually exercising the raw-SQL upgrade path
dropping every row -- caught by test_migrations.py actually exercising instead of the fresh-install create_all() shortcut every other test
the raw-SQL upgrade path instead of the fresh-install create_all() in that file takes.
shortcut every other test in that file takes.
Runs unconditionally after every startup, like _ensure_widgets_ Runs unconditionally after every startup instead; a no-op the moment
backfilled; a no-op the moment frame_calendars is already frame_calendars is already widget_id-shaped (every fresh install,
widget_id-shaped (every fresh install, and any existing install and any existing install after its first run past this code) --
after its first run past this code) -- SQLite can't ALTER a column's SQLite can't ALTER a column's FK target or drop a column that's part
FK target or drop a column that's part of an index/FK constraint, so of an index/FK constraint, so when it isn't a no-op this is the
when it isn't a no-op this is the standard SQLite "rebuild" pattern: standard SQLite "rebuild" pattern: create the new-shape table, copy
create the new-shape table, copy matching rows across (joining to matching rows across (joining to find each row's calendar widget),
find each row's calendar widget), drop the old table, rename the new drop the old table, rename the new one into place."""
one into place."""
inspector = inspect(engine) inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("frame_calendars")} columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
if "widget_id" in columns: if "widget_id" in columns:
+16 -125
View File
@@ -117,14 +117,10 @@ class Frame(Base):
__tablename__ = "frames" __tablename__ = "frames"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# 12 lowercase hex chars of the device's full STA MAC. NULL only for # 12 lowercase hex chars of the device's full STA MAC. NULL until its
# the migrated legacy frame until its device first reports an id. # device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="") name: Mapped[str] = mapped_column(String, default="")
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
# and routers/common.py FRAME_MODES).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed. # Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column( owner_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True ForeignKey("users.id", ondelete="SET NULL"), nullable=True
@@ -138,11 +134,6 @@ class Frame(Base):
# pushing it in /frame/config responses. # pushing it in /frame/config responses.
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False) device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
manage_token: Mapped[str] = mapped_column(String, unique=True) manage_token: Mapped[str] = mapped_column(String, unique=True)
# Migration window: this frame also accepts the legacy shared
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
# migrated frame #1; cleared from /admin once the device is on
# per-frame auth.
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True) claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, default=time.time) created_at: Mapped[float] = mapped_column(Float, default=time.time)
@@ -153,19 +144,20 @@ class Frame(Base):
immich_url: Mapped[str] = mapped_column(String, default="") immich_url: Mapped[str] = mapped_column(String, default="")
immich_api_key: Mapped[str] = mapped_column(String, default="") immich_api_key: Mapped[str] = mapped_column(String, default="")
# -- settings (attribute names match the old FrameConfig fields) -- # -- settings --
album_id: Mapped[str] = mapped_column(String, default="")
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600) refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False) quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00") quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00") quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
timezone: Mapped[str] = mapped_column(String, default="UTC") timezone: Mapped[str] = mapped_column(String, default="UTC")
# How a photo's aspect ratio is reconciled with the panel's -- see
# image_pipeline.DISPLAY_MODES.
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
orientation: Mapped[str] = mapped_column(String, default="landscape") orientation: Mapped[str] = mapped_column(String, default="landscape")
queue_target_len: Mapped[int] = mapped_column(Integer, default=20) # Which EPD panel this frame renders for (image_pipeline.PANEL_SPECS
# key) -- a property of the device's hardware, auto-derived from its
# self-reported board (see routers/device.py's BOARD_PANEL_MAP), never
# a user-editable setting: a mismatched value would corrupt every
# image sent to the device. Defaults to the original 7.3" panel this
# project shipped with.
panel_type: Mapped[str] = mapped_column(String, default="epd7in3e")
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/ # Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding # blue/green, matching image_pipeline.PANEL_CODES order) overriding
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the # DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
@@ -192,113 +184,6 @@ class Frame(Base):
# Widgets rendered in classic (PIL) style ignore this entirely. # Widgets rendered in classic (PIL) style ignore this entirely.
theme: Mapped[str] = mapped_column(String, default="classic") theme: Mapped[str] = mapped_column(String, default="classic")
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# 0=Monday..6=Sunday (matches date.weekday()/calendar.Calendar) --
# which day week/month views start their grid on.
calendar_week_start: Mapped[int] = mapped_column(Integer, default=0)
# Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
# How many periods (unit depends on calendar_view: days/weeks/months)
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
# (non-button) /frame/image request, and whenever calendar_view
# itself changes -- a stale offset means something different in a
# different view's units.
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
# Throttled merge-fetch cache (see routers/common.py's
# get_or_refresh_calendar_events) -- same shape as the
# firmware_update_checked_at/firmware_gitea_latest_version pattern
# below. One shared cache for every included user's merged events,
# not per-user.
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
# calendars unavailable" -- never names which user's feed failed, a
# shared household display shouldn't call out a specific person's
# outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# Optional weather strip, agenda/today & tomorrow/week views only --
# never month, there's no room (see calendar_render.py's _BUILDERS).
# Off by default.
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
# weather.geocode_city() when added from the Calendar tab.
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Throttled per-city forecast cache (see routers/common.py's
# get_or_refresh_weather) -- same shape idiom as
# calendar_checked_at/calendar_cached_events above.
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Week view: how many days to show (2-10, default 7 -- the original
# fixed behavior) and whether they're laid out as side-by-side
# columns or stacked bands (see calendar_render.py's _build_week).
calendar_week_days: Mapped[int] = mapped_column(Integer, default=7)
calendar_week_layout: Mapped[str] = mapped_column(String, default="horizontal") # "horizontal" | "vertical"
# Only used when calendar_week_days != 7 -- calendar_week_start's
# fixed-weekday anchor ("start on the most recent Monday") stops
# making sense once the view isn't a literal calendar week, so a
# non-7-day view instead starts this many days from today (0 =
# starts today, negative = starts in the past, positive = starts in
# the future). Ignored (calendar_week_start governs instead) at the
# default 7 days, so this has no effect until someone actually
# changes the day count.
calendar_week_start_offset: Mapped[int] = mapped_column(Integer, default=0)
# Optional task list, week view only -- takes the space of one day
# slot rather than adding an extra one (see calendar_render.py's
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
# something a plain ICS subscription meaningfully has); source is
# one specific linked user's own CalDAV calendar, same
# owner-controls-their-own-data permission split as FrameCalendar.
# calendar_tasks_user_id
# SET NULL on the user's deletion clears the source rather than
# leaving a dangling reference (checked_at isn't reset by that, but
# the next refresh attempt finds no source and just returns []).
calendar_tasks_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_tasks_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
calendar_tasks_calendar_key: Mapped[str | None] = mapped_column(String, nullable=True)
calendar_tasks_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# [{"summary", "due" (ISO date/datetime string or None)}, ...],
# already filtered to outstanding (not-completed) tasks and sorted
# by due date -- see caldav_client.fetch_tasks.
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
# whiteboard_user_id may point the frame at their own account, since
# it's their credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
# account-has-several-calendars case, a WebDAV account doesn't need
# a picker step here since the user already knows which one file).
whiteboard_url: Mapped[str] = mapped_column(String, default="")
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
# BLOB rather than the JSON columns the rest of this cache-pattern
# family uses, since this is binary image data, not JSON-shaped.
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
# -- telemetry -- # -- telemetry --
battery_percent: Mapped[int] = mapped_column(Integer, default=-1) battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0) battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
@@ -499,6 +384,12 @@ class Widget(Base):
border_style: Mapped[str] = mapped_column(String, default="none") border_style: Mapped[str] = mapped_column(String, default="none")
border_thickness: Mapped[int] = mapped_column(Integer, default=3) border_thickness: Mapped[int] = mapped_column(Integer, default=3)
border_color_index: Mapped[int] = mapped_column(Integer, default=0) border_color_index: Mapped[int] = mapped_column(Integer, default=0)
# Text-size multiplier for this widget's own body/title text -- another
# Widget-level property regardless of widget_type, same reasoning as
# border_style above (any widget type with text can use it). One of
# panel_style.FONT_SCALE_CHOICES; 1.0 (unchanged size) for every
# existing widget until its dialog's "Text size" picker sets it.
font_scale: Mapped[float] = mapped_column(Float, default=1.0)
__table_args__ = (Index("ix_widgets_frame", "frame_id"),) __table_args__ = (Index("ix_widgets_frame", "frame_id"),)
+22
View File
@@ -36,6 +36,28 @@ CONTENT_MARGIN = 20
CARD_RADIUS = 12 CARD_RADIUS = 12
CHIP_RADIUS = 4 CHIP_RADIUS = 4
# models.Widget.font_scale's allowed values -- a per-widget text-size
# multiplier (the Layout dialog's "Text size" picker), same "reject
# invalid, don't silently coerce" posture as border_style. Deliberately a
# small fixed set (a <select>, not a raw slider) rather than an arbitrary
# float: every _AGENDA_FONTS/_TASKS_FONTS-style tuple in calendar_render.py
# and every proportional size in html_render.py/calendar_html_render.py
# derives row heights/max_rows from the same scaled font size, so an
# unbounded scale risks a layout that no longer fits its own box.
FONT_SCALE_CHOICES = (1.0, 1.25, 1.5)
def scaled_size(value: float, font_scale: float) -> int:
"""Applies a widget's font_scale to a text-size value and rounds to
an int px, floored at 1 so an extreme scale can never zero out a
font. Shared by both the classic (PIL, calendar_render.py) and modern
(HTML/CSS, html_render.py/calendar_html_render.py) renderers so "make
this widget's text bigger" behaves identically regardless of
render_style -- every caller applies this immediately after its own
tier lookup/floor calc, so row heights/max_rows computed from the
result already account for the bigger text."""
return max(1, round(value * font_scale))
# Index constants into DEFAULT_PALETTE_RGB/a frame's own Frame. # Index constants into DEFAULT_PALETTE_RGB/a frame's own Frame.
# palette_rgb override -- same order as image_pipeline.PALETTE_LABELS. # palette_rgb override -- same order as image_pipeline.PALETTE_LABELS.
BLACK, WHITE, YELLOW, RED, BLUE, GREEN = range(6) BLACK, WHITE, YELLOW, RED, BLUE, GREEN = range(6)
+51 -13
View File
@@ -26,7 +26,7 @@ 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, weather_render, webdav_client from .. import calendar_render, grid, panel_style, photo_queue, quiet_hours, weather, weather_render, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api from ..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 (
@@ -40,6 +40,7 @@ from ..image_pipeline import (
MIN_BORDER_THICKNESS, MIN_BORDER_THICKNESS,
PALETTE_LABELS, PALETTE_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
panel_size,
render_preview_png, render_preview_png,
compose_into, compose_into,
_enhance, _enhance,
@@ -96,7 +97,7 @@ def _widget_dict(w: Widget, locked: bool = False) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order, "border_style": w.border_style, "sort_order": w.sort_order, "border_style": w.border_style,
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index, "border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
"locked": locked} "font_scale": w.font_scale, "locked": locked}
def require_widget_view( def require_widget_view(
@@ -280,6 +281,31 @@ def api_widget_border(
return _widget_dict(widget) return _widget_dict(widget)
class WidgetFontScaleRequest(BaseModel):
font_scale: float
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/font-scale")
def api_widget_font_scale(
body: WidgetFontScaleRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's text-size multiplier (the Layout dialog's "Text
size" picker) -- a shared Widget-level property (see models.Widget.
font_scale), not a per-type config field, since any widget type with
body text can use it. Its own endpoint for the same reason
api_widget_border has one: api_widget_config_save's per-type dispatch
edits a config row via widget_locked, and font_scale lives on Widget
itself, not any per-type config table."""
frame, widget = frame_widget
if body.font_scale not in panel_style.FONT_SCALE_CHOICES:
raise HTTPException(400, f"font_scale must be one of {panel_style.FONT_SCALE_CHOICES}")
with frame_locked(db, frame.id):
widget.font_scale = body.font_scale
db.commit()
return _widget_dict(widget)
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}") @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)
@@ -758,6 +784,7 @@ def api_widget_preview_rendered(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb, source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=pcfg.display_mode, color_boost=frame.color_boost, display_mode=pcfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
panel_type=frame.panel_type,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -866,7 +893,7 @@ def api_widget_preview_calendar(
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget) events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
target_w, target_h = logical_render_size(frame.orientation) target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
if ccfg.render_style == "modern": if ccfg.render_style == "modern":
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -876,18 +903,20 @@ def api_widget_preview_calendar(
img = calendar_html_render.build( img = calendar_html_render.build(
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb, events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset, weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
frame.theme, frame.theme, widget.font_scale,
) )
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized) png = _png_bytes(quantized)
else: else:
native_w, native_h = panel_size(frame.panel_type)
png = calendar_render.render_calendar_preview_png( png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation, events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary, palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
week_start=ccfg.week_start, week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units, weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset, week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
panel_w=native_w, panel_h=native_h,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -911,13 +940,17 @@ def api_widget_preview_tasks(
if tcfg.render_style == "modern": if tcfg.render_style == "modern":
from .. import html_render from .. import html_render
target_w, target_h = logical_render_size(frame.orientation) target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme) img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
widget.font_scale)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0) quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized) png = _png_bytes(quantized)
else: else:
native_w, native_h = panel_size(frame.panel_type)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation, png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, title=title) palette_rgb=frame.palette_rgb, title=title,
font_scale=widget.font_scale,
panel_w=native_w, panel_h=native_h)
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -1169,14 +1202,17 @@ def api_widget_preview_weather(
# Same local-import reasoning as widgets/weather.py's render(). # Same local-import reasoning as widgets/weather.py's render().
from .. import html_render from .. import html_render
native_w, native_h = panel_size(frame.panel_type)
png = html_render.render_weather_preview_png( png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units, wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", theme_name=frame.theme, city_label=wcfg.city_label or "", theme_name=frame.theme, panel_w=native_w, panel_h=native_h,
) )
else: else:
native_w, native_h = panel_size(frame.panel_type)
png = weather_render.render_weather_preview_png( png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units, 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, city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
panel_w=native_w, panel_h=native_h,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -1227,7 +1263,7 @@ def api_widget_preview_static(
if scfg.render_style == "modern": if scfg.render_style == "modern":
from .. import html_render from .. import html_render
target_w, target_h = logical_render_size(frame.orientation) target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode=scfg.display_mode) display_mode=scfg.display_mode)
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost) fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
@@ -1239,6 +1275,7 @@ def api_widget_preview_static(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=scfg.display_mode, color_boost=frame.color_boost, display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
panel_type=frame.panel_type,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -1258,8 +1295,9 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id) xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content): if not has_text(xcfg.content):
raise HTTPException(400, "No text authored on this widget yet") raise HTTPException(400, "No text authored on this widget yet")
native_w, native_h = panel_size(frame.panel_type)
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb, png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
theme_name=frame.theme) theme_name=frame.theme, panel_w=native_w, panel_h=native_h)
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
@@ -1378,7 +1416,7 @@ def api_widget_preview_whiteboard(
if wcfg.render_style == "modern": if wcfg.render_style == "modern":
from .. import html_render from .. import html_render
target_w, target_h = logical_render_size(frame.orientation) target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h, composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
display_mode="letterbox") display_mode="letterbox")
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme, img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
@@ -1388,6 +1426,6 @@ def api_widget_preview_whiteboard(
else: else:
png = render_preview_png( png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox", display_mode="letterbox", panel_type=frame.panel_type,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
+2 -2
View File
@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import widget_locked from ..db import widget_locked
from ..image_pipeline import logical_render_size from ..image_pipeline import logical_render_size, panel_size
from ..immich_client import ImmichClient from ..immich_client import ImmichClient
from ..models import ( from ..models import (
BatteryLog, BatteryLog,
@@ -514,7 +514,7 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets): 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}" 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, *panel_size(frame.panel_type))
face_labels: list[dict] = [] face_labels: list[dict] = []
for widget in photo_widgets: for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
+42 -9
View File
@@ -27,7 +27,14 @@ from ..auth import get_server_settings, require_device
from ..db import SessionLocal, frame_locked, get_db from ..db import SessionLocal, frame_locked, get_db
from ..firmware import firmware_path from ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color from ..image_pipeline import (
draw_widget_border,
logical_render_size,
panel_size,
render_panel,
render_placeholder,
resolve_border_color,
)
from ..models import BatteryLog, Frame, FrameButtonAction, Widget from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES
from .common import ( from .common import (
@@ -62,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
if frame.owner_user_id is None: if frame.owner_user_id is None:
return render_placeholder( return render_placeholder(
@@ -71,6 +79,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
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],
@@ -80,6 +89,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
@@ -141,7 +151,7 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
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, *panel_size(frame.panel_type))
regions = [] regions = []
if all_widgets: if all_widgets:
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool: with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
@@ -160,7 +170,7 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
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, capture_snapshot=capture_snapshot, panel_type=frame.panel_type,
) )
@@ -185,6 +195,7 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
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, capture_snapshot=capture_snapshot, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot) return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
@@ -251,6 +262,23 @@ def _run_global_action(db: Session, frame: Frame, button: str) -> None:
logger.exception("Global hold action %r failed for frame %d", action, frame.id) logger.exception("Global hold action %r failed for frame %d", action, frame.id)
# Maps a device's self-reported board (X-Frame-Board, CONFIG_FRAME_BOARD_
# NAME) to which EPD panel it drives -- the panel type is a property of
# the board's firmware, not something a person picks in the UI (see
# Frame.panel_type). Includes both the legacy bare names ("devkit",
# "xiao") already baked into fielded firmware and the current chip-
# qualified names ("devkit_esp32c6", "xiao_esp32c6") -- keep both
# indefinitely, since already-flashed devices can't be retroactively
# renamed and there's no cost to accepting either.
BOARD_PANEL_MAP = {
"devkit": "epd7in3e",
"xiao": "epd7in3e",
"devkit_esp32c6": "epd7in3e",
"xiao_esp32c6": "epd7in3e",
"ee02": "epd13in3e",
}
@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
@@ -259,7 +287,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
signal. Also captures the device's running firmware version and board signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs available OTA image's version, so the device's update check costs
zero extra round trips.""" zero extra round trips. The reported board also auto-sets
Frame.panel_type (see BOARD_PANEL_MAP) -- which EPD panel a frame
renders for is derived from what the hardware reports, never a manual
setting."""
reported_version = request.headers.get("X-Frame-Version", "") reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "") reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked: with frame_locked(db, frame.id) as locked:
@@ -272,6 +303,9 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
locked.device_firmware_version = reported_version locked.device_firmware_version = reported_version
if reported_board: if reported_board:
locked.device_board_variant = reported_board locked.device_board_variant = reported_board
mapped_panel = BOARD_PANEL_MAP.get(reported_board)
if mapped_panel and mapped_panel != locked.panel_type:
locked.panel_type = mapped_panel
response = { response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked), "refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
@@ -282,11 +316,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
# firmware/main/next_button.c, app/global_actions.py). # firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms, "hold_duration_ms": locked.hold_duration_ms,
} }
# Per-frame token push: only once the device has introduced itself # Per-frame token push: only until the device has authenticated
# by id (so the response to pure-legacy firmware stays byte- # with it once (device_token_ack) -- no reason to keep sending it
# compatible with its 256-byte parse buffer), and only until the # on every wake once the device has it.
# device has authenticated with the token once (device_token_ack). if not locked.device_token_ack:
if locked.device_id is not None and not locked.device_token_ack:
response["device_token"] = locked.device_token response["device_token"] = locked.device_token
return response return response
+15 -3
View File
@@ -17,7 +17,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import theme_tokens, weather from .. import panel_style, theme_tokens, weather
from ..auth import can_view_frame, current_user from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db from ..db import get_db
@@ -31,6 +31,7 @@ from ..image_pipeline import (
MAX_BORDER_THICKNESS, MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS, MIN_BORDER_THICKNESS,
PALETTE_LABELS, PALETTE_LABELS,
PANEL_LABELS,
STATIC_DISPLAY_MODES, STATIC_DISPLAY_MODES,
palette_to_hex, palette_to_hex,
) )
@@ -89,6 +90,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
request, db, frame_id, "frame_config.html", "config", request, db, frame_id, "frame_config.html", "config",
timezones=ALL_TIMEZONES, timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS, palette_labels=PALETTE_LABELS,
panel_labels=PANEL_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB, default_palette_rgb=DEFAULT_PALETTE_RGB,
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB), calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
palette_to_hex=palette_to_hex, palette_to_hex=palette_to_hex,
@@ -258,6 +260,16 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"back_button_action": bindings.get("back", ""), "back_button_action": bindings.get("back", ""),
} }
# The shared "Text size" card (_widget_font_scale_fields.html,
# models.Widget.font_scale) -- only included on the dialogs whose
# on-panel content is mostly body text (calendar/tasks; the other
# types are either image-only or, for text, already have their own
# richer per-widget font_size control -- see TextWidgetConfig).
font_scale_labels = {1.0: "Normal", 1.25: "Large", 1.5: "X-Large"}
font_scale_ctx = {
"font_scale_choices": [(v, font_scale_labels[v]) for v in panel_style.FONT_SCALE_CHOICES],
}
if widget.widget_type == "photos": 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", {
@@ -273,7 +285,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,
**border_ctx, **button_ctx, **border_ctx, **button_ctx, **font_scale_ctx,
}) })
if widget.widget_type == "tasks": if widget.widget_type == "tasks":
@@ -282,7 +294,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,
**border_ctx, **button_ctx, **border_ctx, **button_ctx, **font_scale_ctx,
}) })
if widget.widget_type == "static": if widget.widget_type == "static":
+3 -20
View File
@@ -562,6 +562,8 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
users_by_id = {u.id: u for u in users} users_by_id = {u.id: u for u in users}
for link in links: for link in links:
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id]) links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
from ..image_pipeline import PANEL_LABELS
ctx = shell_context(request, db, admin, active_nav="admin") ctx = shell_context(request, db, admin, active_nav="admin")
ctx.update({ ctx.update({
"users": users, "users": users,
@@ -571,6 +573,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
"notice": notice, "notice": notice,
"error": error, "error": error,
"active_admin_tab": "main", "active_admin_tab": "main",
"panel_labels": PANEL_LABELS,
}) })
return templates.TemplateResponse("admin.html", ctx) return templates.TemplateResponse("admin.html", ctx)
@@ -707,26 +710,6 @@ def admin_link_user(
notice=f"Linked '{target.username}' to frame #{frame_id}.") notice=f"Linked '{target.username}' to frame #{frame_id}.")
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
def admin_end_legacy(
frame_id: int,
request: Request,
csrf_token: str = Form(""),
db: Session = Depends(get_db),
):
"""Closes the legacy-token migration window once the device is
confirmed on per-frame auth (device_token_ack + recent last_seen in
the frames table below)."""
admin = _require_admin_page(request, db)
_check_form_csrf(request, db, csrf_token)
frame = db.get(Frame, frame_id)
if frame is None:
return _render_admin(request, db, admin, error="No such frame.")
frame.legacy_token_enabled = False
db.commit()
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
@router.post("/admin/smtp", response_class=HTMLResponse) @router.post("/admin/smtp", response_class=HTMLResponse)
def admin_smtp_save( def admin_smtp_save(
request: Request, request: Request,
@@ -199,6 +199,7 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview(); loadCalendarPreview();
initBorderFields(); initBorderFields();
initFontScaleFields();
initButtonActionFields(); initButtonActionFields();
} }
@@ -0,0 +1,32 @@
// Shared "Text size" card (models.Widget.font_scale,
// _widget_font_scale_fields.html) -- only present on the calendar/tasks
// dialogs (see frame_pages.py's widget_dialog), same "one shared init
// function" shape as initBorderFields, just not included on every
// dialog since it isn't relevant to every widget type.
function initFontScaleFields() {
const select = document.getElementById('widget_font_scale');
if (!select) return; // dialog fragment didn't render the font-scale card -- shouldn't happen
document.getElementById('font-scale-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const resp = await fetch(`${window.FRAME_API}/font-scale`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ font_scale: Number(select.value) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Text size saved.');
// Whichever dialog is actually open -- both loadCalendarPreview and
// loadTasksPreview are always defined (plain script tags, not
// module-scoped), so checking the function's existence isn't
// enough; check for the <img> it actually targets instead (calling
// the wrong one throws setting .src on a null element).
if (document.getElementById('calendar-preview')) loadCalendarPreview();
if (document.getElementById('tasks-preview')) loadTasksPreview();
} catch (err) {
showStatus(false, err.message);
}
});
}
+1
View File
@@ -90,6 +90,7 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview(); loadTasksPreview();
initBorderFields(); initBorderFields();
initFontScaleFields();
initButtonActionFields(); initButtonActionFields();
} }
@@ -135,6 +135,8 @@
{% include "_widget_border_fields.html" %} {% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %} {% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
@@ -67,6 +67,8 @@
{% include "_widget_border_fields.html" %} {% include "_widget_border_fields.html" %}
{% include "_widget_font_scale_fields.html" %}
{% include "_widget_button_fields.html" %} {% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;"> <section class="card" style="margin-top: 20px;">
@@ -0,0 +1,15 @@
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Text size</h2>
<p class="sub">Scales this widget's own text up for easier reading on
the panel -- rows/columns re-fit around the bigger text automatically.</p>
<form id="font-scale-config-form">
<label>Size
<select id="widget_font_scale">
{% for value, label in font_scale_choices %}
<option value="{{ value }}" {% if widget.font_scale == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
+1 -8
View File
@@ -108,21 +108,14 @@
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }} owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br> &middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }}) firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }} &middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
{% if f.legacy_token_enabled %}&middot; <strong>legacy token window OPEN</strong>{% endif %}
</p> </p>
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form"> <form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="username" placeholder="Link user by name" required> <input type="text" name="username" placeholder="Link user by name" required>
<button type="submit" class="secondary btn-inline">Link</button> <button type="submit" class="secondary btn-inline">Link</button>
</form> </form>
{% if f.legacy_token_enabled %}
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Close legacy window</button>
</form>
{% endif %}
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form" <form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');"> onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+4
View File
@@ -95,6 +95,10 @@
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }} {% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %} {% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p> </p>
<p class="sub" id="firmware-panel">
{% if frame.device_board_variant %}Panel: {{ panel_labels.get(frame.panel_type, frame.panel_type) }}
{% else %}Panel not detected yet -- determined automatically from the frame's board.{% endif %}
</p>
<p class="sub" id="firmware-available"> <p class="sub" id="firmware-available">
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame {% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %} updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
+1
View File
@@ -77,6 +77,7 @@
<script src="/static/widget_dialog_weather.js"></script> <script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script> <script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script> <script src="/static/widget_dialog_border.js"></script>
<script src="/static/widget_dialog_font_scale.js"></script>
<script src="/static/widget_dialog_button_actions.js"></script> <script src="/static/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>
@@ -1,12 +1,18 @@
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %} {% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) %}
<div class="day-section"> <div class="day-section">
{#- Fixed height (not auto) -- every stacked day-section's header {#- Fixed height (not auto) -- every stacked day-section's header
must be exactly this tall regardless of whether THIS particular must be exactly this tall regardless of whether THIS particular
day has a weather entry, or days with/without weather misalign day has a weather entry, or days with/without weather misalign
where their event rows start (see calendar_week_horizontal's where their event rows start (see calendar_week_horizontal's
identical fix/reasoning). #} identical fix/reasoning). Bold-minimal: a slim accent-colored
<div class="day-header" style="background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); font-size: {{ title_size }}px; height: {{ header_h }}px;"> rule (not a full gradient band) carries the theme identity --
<div class="day-header-title">{{ header }}</div> the header text itself is plain ink, dithered at the base
amplitude like everything else, not the richer accent amplitude
the old white-on-gradient text needed to stay legible. #}
<div class="day-header" style="height: {{ header_h }}px;">
<div class="accent-rule" style="height: {{ accent_h }}px; background: {{ accent_start }};"></div>
<div class="day-header-row">
<div class="day-header-title" style="font-size: {{ title_size }}px;">{{ header }}</div>
{% if weather_entries %} {% if weather_entries %}
<div class="day-weather-row" style="font-size: {{ weather_size }}px;"> <div class="day-weather-row" style="font-size: {{ weather_size }}px;">
{% for we in weather_entries %} {% for we in weather_entries %}
@@ -15,6 +21,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
<div class="day-rows"> <div class="day-rows">
{% if not rows and not more_count %} {% if not rows and not more_count %}
<div class="day-empty" style="font-size: {{ body_size }}px;">Nothing scheduled</div> <div class="day-empty" style="font-size: {{ body_size }}px;">Nothing scheduled</div>
@@ -4,18 +4,9 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
height: {{ h - gutter * 2 }}px; display: flex; flex-direction: column; justify-content: space-between;
margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
} }
.icon-wrap { display: flex; align-items: center; } .icon-wrap { display: flex; align-items: center; }
.icon-body { .icon-body {
@@ -24,7 +15,6 @@
border: {{ stroke }}px solid #000000; border: {{ stroke }}px solid #000000;
border-radius: {{ icon_radius }}px; border-radius: {{ icon_radius }}px;
padding: {{ stroke }}px; padding: {{ stroke }}px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
} }
.icon-fill { .icon-fill {
width: {{ fill_pct }}%; width: {{ fill_pct }}%;
@@ -38,16 +28,19 @@
background: #000000; background: #000000;
border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0; border-radius: 0 {{ nub_radius }}px {{ nub_radius }}px 0;
} }
.pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 1; color: {{ fill_color }}; } .pct { font-weight: 700; font-size: {{ pct_size }}px; line-height: 0.85; color: {{ fill_color }}; letter-spacing: -0.02em; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1; color: #5b6674; } .lines { margin-top: {{ line_gap }}px; }
.line { font-weight: 400; font-size: {{ line_size }}px; line-height: 1.3; color: #5b6674; }
</style></head> </style></head>
<body> <body>
<div class="card"> <div class="wrap">
<div class="icon-wrap"> <div class="icon-wrap">
<div class="icon-body"><div class="icon-fill"></div></div> <div class="icon-body"><div class="icon-fill"></div></div>
<div class="icon-nub"></div> <div class="icon-nub"></div>
</div> </div>
<div>
<div class="pct">{{ percent }}%</div> <div class="pct">{{ percent }}%</div>
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %} <div class="lines">{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}</div>
</div>
</div> </div>
</body></html> </body></html>
@@ -4,33 +4,32 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px; height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; } .day-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 10px 16px; overflow: hidden; } .day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.day-weather-row { display: flex; gap: 14px; margin-top: 6px; } .accent-rule { width: 100%; border-radius: 100px; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); } .day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 6px; min-height: 0; }
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-weather-row { display: flex; gap: 14px; flex: 0 0 auto; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
.day-weather-icon { line-height: 1; } .day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; } .day-rows { flex: 1 1 auto; padding: 8px 0; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; } .day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; } .day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; } .day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; } .day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 4px; } .day-empty, .day-more { color: #5b6674; padding-top: 4px; }
</style></head> </style></head>
<body> <body>
{% import "_calendar_day_section.html.jinja" as ds %} {% import "_calendar_day_section.html.jinja" as ds %}
<div class="card"> <div class="wrap">
{{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }} {{ ds.day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
</div> </div>
</body></html> </body></html>
@@ -4,22 +4,27 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px; height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.weekday-row { display: flex; flex: 0 0 auto; background: {{ accent_start }}; } .accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.weekday-cell { flex: 1 1 0; color: #ffffff; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; } .weekday-row { display: flex; flex: 0 0 auto; margin-top: 6px; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; } .weekday-cell { flex: 1 1 0; color: #17233b; font-weight: 700; font-size: {{ header_size }}px; text-align: center; padding: 4px 0; }
.weeks { flex: 1 1 auto; display: flex; flex-direction: column; margin-top: 2px; }
.week-row { flex: 1 1 0; display: flex; } .week-row { flex: 1 1 0; display: flex; }
.day-cell { flex: 1 1 0; border: 1px solid #e2e6ec; padding: 4px; min-width: 0; overflow: hidden; } {#- A real palette color (pure black), not a pale gray -- this panel's
6-color palette has no gray to dither toward, so #e2e6ec-style
"hairlines" don't survive at all (confirmed by sampling actual
rendered pixels: every one came back pure white). Horizontal
rules only, between week rows -- enough for the grid to scan
top-to-bottom without boxing every single day cell, which read
more like the old structured-dashboard mockup than bold-minimal. #}
.week-row + .week-row { border-top: 1px solid #000000; }
.day-cell { flex: 1 1 0; padding: 4px; min-width: 0; overflow: hidden; }
{#- Bold everywhere, including out-of-month -- de-emphasis is via {#- Bold everywhere, including out-of-month -- de-emphasis is via
smaller size only, not weight or a gray color. Regular-weight and smaller size only, not weight or a gray color. Regular-weight and
gray text are both individually fragile under Bayer ordered gray text are both individually fragile under Bayer ordered
@@ -40,7 +45,8 @@
.dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; } .dot-more { font-size: {{ day_size * 0.8 }}px; color: #5b6674; }
</style></head> </style></head>
<body> <body>
<div class="card"> <div class="wrap">
<div class="accent-rule"></div>
<div class="weekday-row"> <div class="weekday-row">
{% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %} {% for name in day_names %}<div class="weekday-cell">{{ name }}</div>{% endfor %}
</div> </div>
@@ -4,36 +4,39 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px; height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; } .day-section { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; overflow: hidden; }
.day-section + .day-section { border-top: 1px solid #e2e6ec; } {#- No divider line -- #e2e6ec was invisible on this panel's 6-color
.day-header { flex: 0 0 auto; color: #ffffff; font-weight: 700; line-height: 1.2; padding: 8px 16px; overflow: hidden; } palette anyway (no gray to dither toward, confirmed by sampling
.day-weather-row { display: flex; gap: 14px; margin-top: 4px; } rendered pixels), and the accent rule + margin at the top of the
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: rgba(255,255,255,0.9); } next section already reads as a clear boundary without one. #}
.day-section + .day-section { margin-top: 8px; }
.day-header { flex: 0 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.accent-rule { width: 100%; border-radius: 100px; }
.day-header-row { flex: 1 1 auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 4px; min-height: 0; }
.day-header-title { font-weight: 700; color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-weather-row { display: flex; gap: 12px; flex: 0 0 auto; }
.day-weather-entry { display: flex; align-items: center; gap: 4px; color: #5b6674; }
.day-weather-icon { line-height: 1; } .day-weather-icon { line-height: 1; }
.day-rows { flex: 1 1 auto; padding: 6px 14px; overflow: hidden; } .day-rows { flex: 1 1 auto; padding: 4px 0; overflow: hidden; }
.day-row { display: flex; align-items: center; gap: 8px; } .day-row { display: flex; align-items: center; gap: 8px; }
.day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; } .day-chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.day-chip span { flex: 1 1 0; } .day-chip span { flex: 1 1 0; }
.day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; } .day-time { color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .day-summary { color: #17233b; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-empty, .day-more { color: #5b6674; padding-top: 2px; } .day-empty, .day-more { color: #5b6674; padding-top: 2px; }
</style></head> </style></head>
<body> <body>
{% import "_calendar_day_section.html.jinja" as ds %} {% import "_calendar_day_section.html.jinja" as ds %}
<div class="card"> <div class="wrap">
{% for day in days %} {% for day in days %}
{{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) }} {{ ds.day_section(day.header, day.weather_entries, weather_size, unit_suffix, day.rows, day.more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) }}
{% endfor %} {% endfor %}
</div> </div>
</body></html> </body></html>
@@ -4,32 +4,36 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px; height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex; display: flex;
flex-direction: column;
} }
.accent-rule { flex: 0 0 auto; width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
.cols { flex: 1 1 auto; display: flex; margin-top: 6px; min-height: 0; }
.col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; } .col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; }
.col + .col { border-left: 1px solid #e2e6ec; } {#- No divider line -- #e2e6ec was invisible on this panel's 6-color
palette anyway (no gray to dither toward, confirmed by sampling
rendered pixels); the shared accent rule above already frames the
whole week as one unit, and each column's own label anchors it. #}
.col + .col { margin-left: 4px; }
.col-header { .col-header {
/* Fixed height (not auto) -- every column must be exactly this tall {#- Fixed height (not auto) -- every column must be exactly this tall
regardless of whether THIS particular day has a weather entry, or regardless of whether THIS particular day has a weather entry, or
columns with/without weather misalign their event rows to columns with/without weather misalign their event rows to
different starting Y positions across the week grid. */ different starting Y positions across the week grid. #}
height: {{ header_h }}px; height: {{ header_h }}px;
flex: 0 0 auto; flex: 0 0 auto;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); padding: 2px 6px 4px;
color: #ffffff; font-weight: 700; font-size: {{ header_size }}px;
padding: 6px 6px;
overflow: hidden; overflow: hidden;
} }
.col-header .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .col-header .label {
.col-weather { display: flex; align-items: center; gap: 3px; color: rgba(255,255,255,0.9); font-size: {{ weather_size }}px; margin-top: 2px; } font-weight: 700; color: #17233b; font-size: {{ header_size }}px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.col-weather { display: flex; align-items: center; gap: 3px; color: #5b6674; font-size: {{ weather_size }}px; margin-top: 2px; }
.col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; } .col-rows { flex: 1 1 auto; padding: 4px; overflow: hidden; }
.col-row { display: flex; align-items: center; gap: 4px; min-width: 0; } .col-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; } .col-chip { width: 7px; height: 7px; border-radius: 2px; flex: 0 0 auto; }
@@ -41,7 +45,9 @@
.col-more { font-size: {{ chip_size }}px; color: #5b6674; } .col-more { font-size: {{ chip_size }}px; color: #5b6674; }
</style></head> </style></head>
<body> <body>
<div class="card"> <div class="wrap">
<div class="accent-rule"></div>
<div class="cols">
{% for col in cols %} {% for col in cols %}
<div class="col"> <div class="col">
<div class="col-header"> <div class="col-header">
@@ -57,4 +63,5 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
</body></html> </body></html>
@@ -4,27 +4,21 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w - gutter * 2 }}px;
height: {{ h - gutter * 2 }}px; height: {{ h - gutter * 2 }}px;
margin: {{ gutter }}px; margin: {{ gutter }}px;
border-radius: {{ radius }}px;
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.2);{% endif %}
background: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.header { .header { flex: 0 0 auto; height: {{ header_h }}px; display: flex; flex-direction: column; overflow: hidden; }
height: {{ header_h }}px; .accent-rule { width: 100%; height: {{ accent_h }}px; border-radius: 100px; background: {{ accent_start }}; }
flex: 0 0 auto; .title {
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); flex: 1 1 auto; display: flex; align-items: center;
display: flex; color: #17233b; font-weight: 700; font-size: {{ title_size }}px; line-height: 1;
align-items: center; padding-top: 4px;
padding: 0 16px;
} }
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; line-height: 1; } .rows { flex: 1 1 auto; padding-top: 4px; overflow: hidden; }
.rows { flex: 1 1 auto; padding: 8px 14px; overflow: hidden; }
.row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; } .row { display: flex; align-items: center; gap: 8px; height: {{ row_h }}px; }
.chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; } .chip { display: flex; height: 12px; width: 10px; border-radius: 3px; overflow: hidden; flex: 0 0 auto; }
.chip span { flex: 1 1 0; } .chip span { flex: 1 1 0; }
@@ -33,7 +27,7 @@
border: 2px solid #17233b; border: 2px solid #17233b;
} }
.box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; } .box.done { border-color: {{ accent_start }}; background: {{ accent_start }}; }
.due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; } .due { font-size: {{ body_size }}px; color: #5b6674; flex: 0 0 auto; white-space: nowrap; font-variant-numeric: tabular-nums; }
.summary { .summary {
font-size: {{ body_size }}px; color: #17233b; line-height: 1.2; font-size: {{ body_size }}px; color: #17233b; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -42,8 +36,11 @@
.more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; } .more { font-size: {{ body_size }}px; color: #5b6674; padding-top: 2px; }
</style></head> </style></head>
<body> <body>
<div class="card"> <div class="wrap">
<div class="header"><div class="title">{{ title }}</div></div> <div class="header">
<div class="accent-rule"></div>
<div class="title">{{ title }}</div>
</div>
<div class="rows"> <div class="rows">
{% if not rows %} {% if not rows %}
<div class="empty">Nothing outstanding</div> <div class="empty">Nothing outstanding</div>
@@ -4,27 +4,34 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap {
width: {{ w - gutter * 2 }}px; width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px;
height: {{ h - gutter * 2 }}px; display: flex; flex-direction: column; justify-content: space-between;
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)); } .top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.temp { font-weight: 700; font-size: {{ temp_size }}px; color: #17233b; } .city {
.city { font-weight: 400; font-size: {{ label_size }}px; color: #5b6674; } flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 700; font-size: {{ city_size }}px; line-height: 1.2;
letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
}
.icon { flex: 0 0 auto; font-size: {{ icon_size }}px; line-height: 1; }
.temp-row { display: flex; align-items: flex-start; }
.temp {
font-weight: 700; font-size: {{ temp_size }}px; line-height: 0.85;
color: #17233b; letter-spacing: -0.03em;
}
.deg { font-weight: 400; font-size: {{ deg_size }}px; line-height: 1.3; color: #5b6674; }
.cond { font-weight: 400; font-size: {{ cond_size }}px; color: #5b6674; margin-top: {{ (pad * 0.25) | round(0, 'floor') }}px; }
</style></head> </style></head>
<body> <body>
<div class="card"> <div class="wrap">
<div class="top">
{% if city_label %}<div class="city">{{ city_label }}</div>{% else %}<div></div>{% endif %}
<div class="icon">{{ emoji }}</div> <div class="icon">{{ emoji }}</div>
<div class="temp">{{ temp }}&deg;{{ unit_suffix }}</div> </div>
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %} <div>
<div class="temp-row"><div class="temp">{{ temp }}</div><div class="deg">&deg;{{ unit_suffix }}</div></div>
{% if condition %}<div class="cond">{{ condition }}</div>{% endif %}
</div>
</div> </div>
</body></html> </body></html>
@@ -4,46 +4,40 @@
@font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; } @font-face { font-family: "ThemeFont"; src: url("file://{{ font_bold }}"); font-weight: 700; }
* { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; } * { margin: 0; padding: 0; box-sizing: border-box; font-family: "ThemeFont", sans-serif; }
body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; } body { width: {{ w }}px; height: {{ h }}px; background: #ffffff; }
.card { .wrap { width: {{ w }}px; height: {{ h }}px; padding: {{ pad }}px; display: flex; flex-direction: column; }
width: {{ w - gutter * 2 }}px; .accent-bar {
height: {{ h - gutter * 2 }}px; flex: 0 0 auto; height: {{ accent_h }}px; width: 100%;
margin: {{ gutter }}px; border-radius: {{ (accent_h / 2) | round(0, 'floor') }}px;
border-radius: {{ radius }}px; background: linear-gradient(90deg, {{ accent_start }} 0%, {{ accent_end }} 100%);
overflow: hidden;
{% if shadow %}box-shadow: 0 4px 14px rgba(0, 20, 60, 0.25);{% endif %}
background: #ffffff;
} }
.header { .city {
height: {{ header_h }}px; flex: 0 0 auto; margin-top: 6px; font-weight: 700; font-size: {{ city_size }}px;
background: linear-gradient(135deg, {{ accent_start }} 0%, {{ accent_end }} 100%); letter-spacing: 0.12em; text-transform: uppercase; color: #5b6674;
display: flex; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
align-items: center;
padding: 0 16px;
} }
.header .title { color: #ffffff; font-weight: 700; font-size: {{ title_size }}px; } .days { flex: 1 1 auto; display: flex; align-items: center; gap: {{ col_gap }}px; }
.body { display: flex; height: calc(100% - {{ header_h }}px); padding: 12px 8px; } .col { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
.col { .day-label {
flex: 1; font-weight: 700; font-size: {{ day_label_size }}px; letter-spacing: 0.06em;
display: flex; text-transform: uppercase; color: #5b6674;
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; } .icon { font-size: {{ icon_size }}px; line-height: 1; }
.temps { font-weight: 700; font-size: {{ label_size }}px; color: #17233b; } .temps { display: flex; align-items: baseline; gap: 3px; }
.temps .low { color: #6b7788; font-weight: 400; } .high { font-weight: 700; font-size: {{ high_size }}px; line-height: 1; color: #17233b; }
.low { font-weight: 400; font-size: {{ low_size }}px; line-height: 1; color: #5b6674; }
</style></head> </style></head>
<body> <body>
<div class="card"> <div class="wrap">
{% if city_label %}<div class="header"><div class="title">{{ city_label }}</div></div>{% endif %} {% if city_label %}
<div class="body"> <div class="accent-bar"></div>
<div class="city">{{ city_label }}</div>
{% endif %}
<div class="days">
{% for d in days %} {% for d in days %}
<div class="col"> <div class="col">
<div class="day">{{ d.label }}</div> <div class="day-label">{{ d.label }}</div>
<div class="icon">{{ d.emoji }}</div> <div class="icon">{{ d.emoji }}</div>
<div class="temps">{{ d.high }}&deg;<span class="low">/{{ d.low }}&deg;{{ unit_suffix }}</span></div> <div class="temps"><div class="high">{{ d.high }}&deg;</div><div class="low">{{ d.low }}&deg;{{ unit_suffix }}</div></div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+4 -3
View File
@@ -34,7 +34,7 @@ from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from . import panel_style from . import panel_style
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _apply_manage_overlay, _quantize, draw_text, logical_render_size
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not # MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned -- every column-width/icon-size calc below was measured # re-tuned -- every column-width/icon-size calc below was measured
@@ -421,10 +421,11 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None, def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", manage: dict | None = None, units: str = "fahrenheit", manage: dict | None = None,
city_label: str = "", interval_hours: int = 4) -> bytes: city_label: str = "", interval_hours: int = 4,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as calendar_render.render_tasks_preview_png -- a """Same pipeline as calendar_render.render_tasks_preview_png -- a
normal browser-viewable PNG in logical (upright) orientation.""" normal browser-viewable PNG in logical (upright) orientation."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours) img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
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)
+2 -2
View File
@@ -21,7 +21,7 @@ from PIL import Image, ImageDraw
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import panel_style from .. import panel_style
from ..image_pipeline import _quantize, draw_text, logical_render_size from ..image_pipeline import _quantize, draw_text, logical_render_size, panel_size
from ..models import BatteryWidgetConfig, Frame, Widget from ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s from ..routers.common import battery_estimate_s
from ._shared import placeholder_image from ._shared import placeholder_image
@@ -135,7 +135,7 @@ def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: s
"""A normal browser-viewable PNG at full logical panel size -- same """A normal browser-viewable PNG at full logical panel size -- same
"dialog preview always renders at the frame's full size, not the "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.""" widget's actual grid box" convention as text.py's render_preview_png."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, *panel_size(frame.panel_type))
img = render(db, frame, widget, target_w, target_h) img = render(db, frame, widget, target_w, target_h)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+2 -2
View File
@@ -59,14 +59,14 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
return calendar_html_render.build( return calendar_html_render.build(
events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb, events, cfg.view, cfg.browse_offset, target_w, target_h, tz, cfg.week_start, frame.palette_rgb,
weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset, weather_cities, cfg.weather_units, cfg.week_days, cfg.week_layout, cfg.week_start_offset,
frame.theme, frame.theme, widget.font_scale,
) )
return _build( return _build(
events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h, events, view=cfg.view, browse_offset=cfg.browse_offset, target_w=target_w, target_h=target_h,
timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start, timezone=frame.timezone, fetch_summary=fetch_summary, week_start=cfg.week_start,
palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units, palette_rgb=frame.palette_rgb, weather_cities=weather_cities, weather_units=cfg.weather_units,
week_days=cfg.week_days, week_layout=cfg.week_layout, week_days=cfg.week_days, week_layout=cfg.week_layout, font_scale=widget.font_scale,
week_start_offset=cfg.week_start_offset, week_start_offset=cfg.week_start_offset,
) )
+3 -2
View File
@@ -43,8 +43,9 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
# own classic path, should never pay for it. # own classic path, should never pay for it.
from .. import html_render from .. import html_render
return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme) return html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title) widget.font_scale)
return _build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, font_scale=widget.font_scale)
ACTIONS: dict = {} ACTIONS: dict = {}
+4 -3
View File
@@ -25,7 +25,7 @@ from PIL import Image, ImageDraw
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import theme_tokens from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size from ..image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text from ..text_content import has_text
from ._shared import placeholder_image from ._shared import placeholder_image
@@ -212,7 +212,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None, def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
theme_name: str | None = None) -> bytes: theme_name: str | None = None, panel_w: int = EPD_WIDTH,
panel_h: int = EPD_HEIGHT) -> bytes:
"""A normal browser-viewable PNG at full logical panel size -- """A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at render_tasks (the dialog's own preview endpoint always renders at
@@ -220,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
convention every other widget type's preview endpoint follows).""" convention every other widget type's preview endpoint follows)."""
import io import io
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name) img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+5 -6
View File
@@ -10,12 +10,11 @@ services:
- CONFIG_PATH=/data/config.json - CONFIG_PATH=/data/config.json
- IMMICH_URL=http://your-immich-host:2283 - IMMICH_URL=http://your-immich-host:2283
- IMMICH_API_KEY=your-immich-api-key-here - IMMICH_API_KEY=your-immich-api-key-here
# Optional: gates the entire server -- the web UI (/, /api/*) AND # Optional: gates first-run /setup on a freshly deployed server --
# every device-facing /frame/* endpoint -- behind this shared secret. # whoever supplies this value is the one who gets to create the
# Leave unset to keep it all open on a trusted LAN, same as before. # first admin account. Meaningless once that account exists (every
# Paste the same value into the ESP32's captive portal setup form # other route always requires a real login), so leave unset unless
# (Access Token field) so it's sent on every device request and gets # you're worried about someone else reaching /setup before you do.
# embedded automatically in the manage-menu/share QR codes.
- MANAGEMENT_TOKEN=changeme - MANAGEMENT_TOKEN=changeme
# Optional: only needed if the Gitea repo configured in the web UI's # Optional: only needed if the Gitea repo configured in the web UI's
# "Firmware Gitea repo URL" field is private. A read-only PAT is # "Firmware Gitea repo URL" field is private. A read-only PAT is
+13
View File
@@ -112,6 +112,19 @@ def link_user(db: Session, user: User, frame: Frame) -> None:
db.flush() db.flush()
def claim_device(db: Session, frame: Frame, device_id: str = "001122334455",
token: str = "devtok-1") -> str:
"""Gives `frame` device credentials and returns the "id=...&token=..."
query string real firmware always sends -- require_device has no
fallback for a bare /frame/* request without ?id= (the old shared-
MANAGEMENT_TOKEN/no-id path this project used to resolve to a single
legacy frame is gone), so any device-facing test needs this."""
frame.device_id = device_id
frame.device_token = token
db.commit()
return f"id={device_id}&token={token}"
def login(client: TestClient, username: str, password: str = "testpass123") -> None: def login(client: TestClient, username: str, password: str = "testpass123") -> None:
resp = client.post("/login", data={"username": username, "password": password}) resp = client.post("/login", data={"username": username, "password": password})
assert resp.status_code == 303, resp.text assert resp.status_code == 303, resp.text
+80
View File
@@ -0,0 +1,80 @@
"""GET /frame/config auto-deriving Frame.panel_type from the device's
self-reported board (X-Frame-Board header) -- see routers/device.py's
BOARD_PANEL_MAP. Panel type is a property of the hardware, never a user
setting, so this mapping is the only thing that's allowed to change it."""
from __future__ import annotations
from app.models import Frame
from .conftest import claim_device
def test_new_chip_qualified_board_names_map_to_the_right_panel(client, db_session):
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "ee02"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "ee02"
assert frame.panel_type == "epd13in3e"
def test_legacy_bare_board_names_still_map_correctly(client, db_session):
"""Already-flashed devices that haven't been OTA'd past the
devkit/xiao -> devkit_esp32c6/xiao_esp32c6 rename must keep reporting
their old bare name and still get mapped to the right panel -- fielded
firmware can't be retroactively renamed."""
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "xiao"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "xiao"
assert frame.panel_type == "epd7in3e"
def test_renamed_chip_qualified_board_names_map_correctly(client, db_session):
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "devkit_esp32c6"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "devkit_esp32c6"
assert frame.panel_type == "epd7in3e"
def test_unrecognized_board_name_leaves_panel_type_unchanged(client, db_session):
"""An unrecognized board string still gets recorded (same as today's
device_board_variant behavior) but must never blow away whatever
panel_type is already set -- an unknown value is more likely a typo
or a not-yet-supported board than evidence the frame's actual panel
changed."""
frame = db_session.get(Frame, 1)
frame.panel_type = "epd13in3e"
db_session.commit()
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "some_future_board"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "some_future_board"
assert frame.panel_type == "epd13in3e"
def test_no_board_header_leaves_panel_type_at_its_default(client, db_session):
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}")
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.panel_type == "epd7in3e"
+13 -10
View File
@@ -26,6 +26,8 @@ from app.models import (
Widget, Widget,
) )
from .conftest import claim_device
EXPECTED_BYTES = 800 * 480 // 2 EXPECTED_BYTES = 800 * 480 // 2
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}] _ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
@@ -41,16 +43,17 @@ def _mock_immich(monkeypatch):
def test_unclaimed_frame_shows_placeholder(client, db_session): def test_unclaimed_frame_shows_placeholder(client, db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
frame.owner_user_id = None frame.owner_user_id = None
db_session.commit() creds = claim_device(db_session, frame)
resp = client.get("/frame/image") resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session): def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/frame/image") creds = claim_device(db_session, db_session.get(Frame, 1))
resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
@@ -66,24 +69,24 @@ def test_configured_photo_widget_renders_and_advances_via_button(client, db_sess
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id) cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1" cfg.album_id = "album-1"
db_session.commit() creds = claim_device(db_session, frame)
_mock_immich(monkeypatch) _mock_immich(monkeypatch)
resp = client.get("/frame/image") resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg) db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" assert cfg.current_asset_id == "asset-1"
resp = client.post("/frame/advance") resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg) db_session.refresh(cfg)
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
resp = client.post("/frame/back") resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
db_session.refresh(cfg) db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" # back undid it assert cfg.current_asset_id == "asset-1" # back undid it
@@ -94,11 +97,11 @@ def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1" db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
db_session.commit() creds = claim_device(db_session, frame)
_mock_immich(monkeypatch) _mock_immich(monkeypatch)
plain = client.get("/frame/image").content plain = client.get(f"/frame/image?{creds}").content
with_manage = client.get("/frame/image?manage=1").content with_manage = client.get(f"/frame/image?{creds}&manage=1").content
assert len(with_manage) == EXPECTED_BYTES assert len(with_manage) == EXPECTED_BYTES
assert with_manage != plain # the manage-QR overlay actually got composited in assert with_manage != plain # the manage-QR overlay actually got composited in
+9 -8
View File
@@ -19,7 +19,7 @@ from app.models import (
WhiteboardWidgetConfig, WhiteboardWidgetConfig,
) )
from .conftest import csrf_headers, link_user, login, make_user from .conftest import claim_device, csrf_headers, link_user, login, make_user
EXPECTED_BYTES = 800 * 480 // 2 EXPECTED_BYTES = 800 * 480 // 2
@@ -113,7 +113,8 @@ def test_save_logged_out_401s(client, db_session):
def test_global_next_is_a_noop_when_unset(client, db_session): def test_global_next_is_a_noop_when_unset(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/frame/global-next") creds = claim_device(db_session, db_session.get(Frame, 1))
resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
@@ -123,9 +124,9 @@ def test_global_next_runs_the_configured_action(client, db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.next_hold_action = "toggle_all_photo_locks" frame.next_hold_action = "toggle_all_photo_locks"
db_session.commit() creds = claim_device(db_session, frame)
resp = client.post("/frame/global-next") resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
@@ -136,9 +137,9 @@ def test_global_back_runs_the_configured_action(client, db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.back_hold_action = "toggle_all_photo_locks" frame.back_hold_action = "toggle_all_photo_locks"
db_session.commit() creds = claim_device(db_session, frame)
resp = client.post("/frame/global-back") resp = client.post(f"/frame/global-back?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
@@ -149,9 +150,9 @@ def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_ses
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
frame.next_hold_action = "no_longer_exists" frame.next_hold_action = "no_longer_exists"
db_session.commit() creds = claim_device(db_session, frame)
resp = client.post("/frame/global-next") resp = client.post(f"/frame/global-next?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES assert len(resp.content) == EXPECTED_BYTES
+143 -29
View File
@@ -28,6 +28,60 @@ from app.models import (
from .conftest import make_user from .conftest import make_user
# Columns migration 41 drops from `frames` -- a fresh-install create_all()
# copy (what every db_session fixture starts from) already reflects
# today's models.py, i.e. the post-41 shape without these, so a test that
# wants to simulate a pre-41 database has to add them back itself before
# setting schema_version below 41 and calling run_migrations() -- same
# "frames isn't dropped/recreated by these replay tests" situation
# test_migration_29/30's own comments describe, just for columns being
# removed instead of added.
_LEGACY_FRAME_COLUMNS = [
"mode TEXT NOT NULL DEFAULT 'photos'",
"album_id TEXT NOT NULL DEFAULT ''",
"photo_order TEXT NOT NULL DEFAULT 'sequential'",
"display_mode TEXT NOT NULL DEFAULT 'crop_faces'",
"queue_target_len INTEGER NOT NULL DEFAULT 20",
"current_asset_id TEXT NOT NULL DEFAULT ''",
"current_asset_set_at REAL NOT NULL DEFAULT 0.0",
"queue TEXT NOT NULL DEFAULT '[]'",
"queue_cursor INTEGER NOT NULL DEFAULT 0",
"history TEXT NOT NULL DEFAULT '[]'",
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]'",
"calendar_view TEXT NOT NULL DEFAULT 'agenda'",
"calendar_week_start INTEGER NOT NULL DEFAULT 0",
"calendar_photo_inlay INTEGER NOT NULL DEFAULT 0",
"calendar_browse_offset INTEGER NOT NULL DEFAULT 0",
"calendar_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_cached_events TEXT",
"calendar_fetch_summary TEXT NOT NULL DEFAULT ''",
"calendar_weather_enabled INTEGER NOT NULL DEFAULT 0",
"calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'",
"calendar_weather_cities TEXT",
"calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_weather_cached TEXT",
"calendar_week_days INTEGER NOT NULL DEFAULT 7",
"calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'",
"calendar_week_start_offset INTEGER NOT NULL DEFAULT 0",
"calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0",
"calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
"calendar_tasks_calendar_key TEXT",
"calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0",
"calendar_tasks_cached TEXT",
"whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
"whiteboard_url TEXT NOT NULL DEFAULT ''",
"whiteboard_checked_at REAL NOT NULL DEFAULT 0.0",
"whiteboard_cached_image BLOB",
"legacy_token_enabled INTEGER NOT NULL DEFAULT 0",
]
def _add_legacy_frame_columns(conn) -> None:
existing = {c["name"] for c in inspect(db_module.engine).get_columns("frames")}
for col_def in _LEGACY_FRAME_COLUMNS:
if col_def.split()[0] not in existing:
conn.execute(text(f"ALTER TABLE frames ADD COLUMN {col_def}"))
def test_migrations_list_is_sequential_and_unique(): def test_migrations_list_is_sequential_and_unique():
versions = [v for v, _ in MIGRATIONS] versions = [v for v, _ in MIGRATIONS]
@@ -72,8 +126,6 @@ def test_expected_columns_exist_on_current_schema():
assert "webdav_base_url" in user_columns # migration 15 assert "webdav_base_url" in user_columns # migration 15
assert "webdav_username" in user_columns # migration 14 assert "webdav_username" in user_columns # migration 14
assert "calendar_caldav_url" in user_columns assert "calendar_caldav_url" in user_columns
assert "whiteboard_cached_image" in frame_columns # migration 14
assert "calendar_week_start_offset" in frame_columns
assert "name" in task_widget_columns # migration 19 assert "name" in task_widget_columns # migration 19
assert "static_widget_configs" in inspector.get_table_names() # migration 20 assert "static_widget_configs" in inspector.get_table_names() # migration 20
assert "text_widget_configs" in inspector.get_table_names() # migration 21 assert "text_widget_configs" in inspector.get_table_names() # migration 21
@@ -104,14 +156,18 @@ def test_expected_columns_exist_on_current_schema():
assert "render_style" in whiteboard_widget_columns # migration 37 assert "render_style" in whiteboard_widget_columns # migration 37
calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")} calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")}
assert "render_style" in calendar_widget_columns # migration 38 assert "render_style" in calendar_widget_columns # migration 38
assert "theme" in frame_columns # migration 39
assert "font_scale" in widget_columns # migration 40
assert not {"mode", "album_id", "current_asset_id", "calendar_view", "whiteboard_url",
"legacy_token_enabled"} & frame_columns # migration 41
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- # --- widget system: fresh-install default widget, and migration 41's
# raw-SQL backfill safety net for a pre-widget-system database ---
def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_session): def test_fresh_install_creates_a_default_photos_widget_with_default_buttons(db_session):
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
assert frame.mode == "photos"
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
assert len(widgets) == 1 assert len(widgets) == 1
@@ -121,7 +177,7 @@ def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_sessi
config = db_session.get(PhotoWidgetConfig, widget.id) config = db_session.get(PhotoWidgetConfig, widget.id)
assert config is not None assert config is not None
assert config.album_id == frame.album_id assert config.album_id == ""
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all() actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"} assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
@@ -138,17 +194,26 @@ def test_rerunning_migrations_does_not_duplicate_widgets(db_session):
def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session): def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
"""Reproduces the old fixed 50/50 inlay split as two independent, """Reproduces the old fixed 50/50 inlay split as two independent,
non-overlapping widgets instead of silently dropping the photo half non-overlapping widgets instead of silently dropping the photo half
on upgrade -- see models.py's CalendarWidgetConfig docstring.""" on upgrade -- see models.py's CalendarWidgetConfig docstring. Exercises
frame = Frame( _migration_41's raw-SQL backfill safety net: a frame whose legacy
name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay", Frame columns (pre-widget-system) still carry real data but which
mode="calendar", orientation="landscape", calendar_view="week", somehow has no Widget yet."""
calendar_photo_inlay=True, album_id="album-123", frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
current_asset_id="asset-1", queue=["asset-1", "asset-2"], orientation="landscape", created_at=time.time())
created_at=time.time(),
)
db_session.add(frame) db_session.add(frame)
db_session.flush()
frame_id = frame.id
db_session.commit() db_session.commit()
with db_module.engine.begin() as conn:
_add_legacy_frame_columns(conn)
conn.execute(text(
"UPDATE frames SET mode='calendar', calendar_view='week', calendar_photo_inlay=1, "
"album_id='album-123', current_asset_id='asset-1', queue='[\"asset-1\", \"asset-2\"]' "
"WHERE id = :id"
), {"id": frame_id})
conn.execute(text("UPDATE schema_version SET version = 40"))
run_migrations() run_migrations()
widgets = db_session.scalars( widgets = db_session.scalars(
@@ -178,15 +243,24 @@ def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session): def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session):
frame = Frame( """Exercises _migration_41's raw-SQL backfill safety net for a
name="WB Frame", device_token="tok-wb", manage_token="mtok-wb", whiteboard-mode legacy frame -- same shape as the calendar-inlay case
mode="whiteboard", orientation="portrait", above, just the simpler single-widget mode dispatch branch."""
whiteboard_url="https://example.com/board.whiteboard", frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
created_at=time.time(), orientation="portrait", created_at=time.time())
)
db_session.add(frame) db_session.add(frame)
db_session.flush()
frame_id = frame.id
db_session.commit() db_session.commit()
with db_module.engine.begin() as conn:
_add_legacy_frame_columns(conn)
conn.execute(text(
"UPDATE frames SET mode='whiteboard', "
"whiteboard_url='https://example.com/board.whiteboard' WHERE id = :id"
), {"id": frame_id})
conn.execute(text("UPDATE schema_version SET version = 40"))
run_migrations() run_migrations()
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all() widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
@@ -247,19 +321,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
conn.execute(text( conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
)) ))
_add_legacy_frame_columns(conn)
conn.execute(text("UPDATE schema_version SET version = 15")) conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame( frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up", created_at=time.time())
mode="photos", album_id="legacy-album", current_asset_id="legacy-asset",
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
db_session.add(frame) db_session.add(frame)
db_session.flush() db_session.flush()
user = make_user(db_session, "legacy-owner") user = make_user(db_session, "legacy-owner")
db_session.commit() db_session.commit()
frame_id, user_id = frame.id, user.id frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text(
"UPDATE frames SET mode='photos', album_id='legacy-album', current_asset_id='legacy-asset', "
"queue='[\"legacy-asset\", \"next-asset\"]' WHERE id = :id"
), {"id": frame_id})
# An orphaned frame_calendars row (this frame's mode was never # An orphaned frame_calendars row (this frame's mode was never
# "calendar", so it has no calendar widget for _ensure_frame_ # "calendar", so it has no calendar widget for _ensure_frame_
# calendars_rekeyed to attach it to) -- exercises that it's dropped # calendars_rekeyed to attach it to) -- exercises that it's dropped
@@ -362,6 +440,42 @@ def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_ses
assert frame.last_displayed_at == 0.0 assert frame.last_displayed_at == 0.0
def test_migration_40_adds_font_scale_to_an_existing_database(db_session):
"""Exercises _migration_40's real guarded ALTER path (widgets isn't
dropped/recreated by the pre-widget-system replay tests, so its
columns must be added defensively, same reasoning as migration
26/27/29/30's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 39"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
widget = db_session.query(Widget).filter(Widget.frame_id == 1).first()
assert widget.font_scale == 1.0
def test_migration_42_adds_panel_type_to_an_existing_database(db_session):
"""Exercises _migration_42's real guarded ALTER path (frames isn't
dropped/recreated by this replay -- migration 41 already ran -- so
the column must be added defensively, same reasoning as migration
39/40's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 41"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
frame = db_session.query(Frame).filter(Frame.id == 1).first()
assert frame.panel_type == "epd7in3e"
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session): def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction """Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database SQL back to back (the real "existing widget-system database
@@ -513,12 +627,11 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
conn.execute(text( conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)" "CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
)) ))
_add_legacy_frame_columns(conn)
conn.execute(text("UPDATE schema_version SET version = 15")) conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame( frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal", created_at=time.time())
mode="calendar", created_at=time.time(),
)
db_session.add(frame) db_session.add(frame)
db_session.flush() db_session.flush()
user = make_user(db_session, "cal-owner") user = make_user(db_session, "cal-owner")
@@ -526,6 +639,7 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
frame_id, user_id = frame.id, user.id frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn: with db_module.engine.begin() as conn:
conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id})
conn.execute(text( conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) " "INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) "
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)" "VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)"
+9 -7
View File
@@ -14,7 +14,7 @@ from PIL import Image
from app.image_pipeline import logical_render_size from app.image_pipeline import logical_render_size
from app.models import Frame from app.models import Frame
from .conftest import link_user, login, make_user from .conftest import claim_device, link_user, login, make_user
def test_now_displaying_404s_before_any_device_fetch(client, db_session): def test_now_displaying_404s_before_any_device_fetch(client, db_session):
@@ -27,8 +27,9 @@ def test_now_displaying_404s_before_any_device_fetch(client, db_session):
def test_frame_image_records_now_displaying(client, db_session): def test_frame_image_records_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get("/frame/image") resp = client.get(f"/frame/image?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = client.get("/api/frames/1/now-displaying") resp = client.get("/api/frames/1/now-displaying")
@@ -43,18 +44,18 @@ def test_frame_image_records_now_displaying(client, db_session):
def test_advance_and_back_also_update_now_displaying(client, db_session): def test_advance_and_back_also_update_now_displaying(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"}) client.post("/setup", data={"username": "alice", "password": "hunter22"})
db_session.get(Frame, 1) creds = claim_device(db_session, db_session.get(Frame, 1))
client.get("/frame/image") client.get(f"/frame/image?{creds}")
first = client.get("/api/frames/1/now-displaying") first = client.get("/api/frames/1/now-displaying")
assert first.status_code == 200 assert first.status_code == 200
resp = client.post("/frame/advance") resp = client.post(f"/frame/advance?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
after_advance = client.get("/api/frames/1/now-displaying") after_advance = client.get("/api/frames/1/now-displaying")
assert after_advance.status_code == 200 assert after_advance.status_code == 200
resp = client.post("/frame/back") resp = client.post(f"/frame/back?{creds}")
assert resp.status_code == 200 assert resp.status_code == 200
after_back = client.get("/api/frames/1/now-displaying") after_back = client.get("/api/frames/1/now-displaying")
assert after_back.status_code == 200 assert after_back.status_code == 200
@@ -66,7 +67,8 @@ def test_now_displaying_visible_to_linked_user(client, db_session):
bob = make_user(db_session, "bob") bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1) frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame) link_user(db_session, bob, frame)
client.get("/frame/image") creds = claim_device(db_session, frame)
client.get(f"/frame/image?{creds}")
client.cookies.clear() client.cookies.clear()
login(client, "bob") login(client, "bob")
+134
View File
@@ -161,3 +161,137 @@ def test_render_panel_backfilled_full_panel_widget_matches_grid_full_panel_rect(
region = Image.new("RGB", px[2:], (10, 20, 30)) region = Image.new("RGB", px[2:], (10, 20, 30))
data = render_panel([(px, region)], orientation="landscape") data = render_panel([(px, region)], orientation="landscape")
assert len(data) == EXPECTED_BYTES assert len(data) == EXPECTED_BYTES
# --- a second, synthetic panel size (proves the packing path is genuinely
# resolution-agnostic, ahead of the real 13.3" panel's numbers existing --
# see image_pipeline._transpose_and_pack, which derives its output size
# from the quantized image itself rather than a hardcoded EPD_WIDTH/
# EPD_HEIGHT global) ---
@pytest.fixture
def synthetic_panel(monkeypatch):
"""Registers a second PANEL_SPECS entry, a different size than the
real 7.3" panel, without needing the real 13.3" panel's confirmed
resolution to exist yet."""
from app import image_pipeline
monkeypatch.setitem(image_pipeline.PANEL_SPECS, "test_panel", (600, 400))
return "test_panel", 600, 400
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_panel_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation):
from app.image_pipeline import logical_render_size
panel_type, panel_w, panel_h = synthetic_panel
w, h = logical_render_size(orientation, panel_w, panel_h)
region = Image.new("RGB", (w, h), (200, 0, 0))
data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type=panel_type)
assert len(data) == panel_w * panel_h // 2
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_placeholder_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation):
panel_type, panel_w, panel_h = synthetic_panel
data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type=panel_type)
assert len(data) == panel_w * panel_h // 2
def test_render_panel_default_panel_type_is_unaffected_by_a_new_registry_entry(synthetic_panel):
"""A second PANEL_SPECS entry existing must never change what an
ordinary (no panel_type passed) render produces -- every existing
7.3" frame's output stays byte-identical regardless of what other
panels get registered."""
data = render_placeholder(["Not configured yet"], orientation="landscape")
assert len(data) == EXPECTED_BYTES
def test_panel_size_falls_back_to_the_original_panel_for_unknown_types():
from app.image_pipeline import panel_size
assert panel_size("nonexistent") == (EPD_WIDTH, EPD_HEIGHT)
assert panel_size("") == (EPD_WIDTH, EPD_HEIGHT)
# --- the real (not synthetic) 13.3" Spectra 6 / EE02 panel geometry,
# confirmed from Waveshare's/Seeed's public product pages -- the vendor
# init/LUT/refresh sequence firmware-side is still unconfirmed (see
# image_pipeline.PANEL_SPECS's own comment), but the geometry itself is
# real, not a placeholder, so it gets the same coverage as the 7.3" panel
# rather than just the synthetic-panel tests above. ---
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_panel_size_for_the_real_13in3_panel(orientation):
from app.image_pipeline import PANEL_SPECS, logical_render_size
panel_w, panel_h = PANEL_SPECS["epd13in3e"]
w, h = logical_render_size(orientation, panel_w, panel_h)
region = Image.new("RGB", (w, h), (200, 0, 0))
data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type="epd13in3e")
assert len(data) == panel_w * panel_h // 2
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_placeholder_size_for_the_real_13in3_panel(orientation):
from app.image_pipeline import PANEL_SPECS
panel_w, panel_h = PANEL_SPECS["epd13in3e"]
data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type="epd13in3e")
assert len(data) == panel_w * panel_h // 2
def test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride():
"""Regression guard for a corruption bug, not just a rotation bug: the
13.3" panel's SPI controller addresses a native 1200x1600 raster (600
bytes/row x 1600 rows), rotated 90 degrees from PANEL_SPECS's
1600x1200 mount/marketing size (800 bytes/row x 1200 rows) -- see
PANEL_WIRE_TRANSPOSE's own comment. Both shapes pack to the identical
960000-byte total, so a regression here wouldn't fail a plain length
assertion -- it would ship a driver that slices real image rows at the
wrong byte offsets and shreds the picture on a real panel.
This probes stride, not rotation direction: a vertical stripe (values
constant along the *mount* image's y-axis) stays constant along
whichever axis absorbs that constancy under ANY 90-degree-multiple
rotation, so this holds regardless of which direction
PANEL_WIRE_TRANSPOSE ends up using -- only the true 600-byte wire row
stride makes each decoded row uniform; decoding at the wrong (800-byte
mount) stride would slice across real row boundaries and mix both
colors into every "row"."""
from PIL import ImageDraw
from app.image_pipeline import (
DEFAULT_PALETTE_RGB,
PANEL_SPECS,
_build_palette_image,
_transpose_and_pack,
)
mount_w, mount_h = PANEL_SPECS["epd13in3e"] # (1600, 1200)
wire_w, wire_h = mount_h, mount_w # (1200, 1600) -- the true SPI wire raster
img = Image.new("RGB", (mount_w, mount_h), (255, 255, 255))
ImageDraw.Draw(img).rectangle([0, 0, mount_w // 2 - 1, mount_h - 1], fill=(0, 0, 0))
quantized = img.quantize(palette=_build_palette_image(DEFAULT_PALETTE_RGB))
packed = _transpose_and_pack(quantized, "landscape", panel_type="epd13in3e")
assert len(packed) == wire_w * wire_h // 2
row_bytes = wire_w // 2 # 600 -- the true wire row stride
first_row = packed[0:row_bytes]
last_row = packed[(wire_h - 1) * row_bytes: wire_h * row_bytes]
def nibbles(row_bytes_slice):
vals = set()
for b in row_bytes_slice:
vals.add(b >> 4)
vals.add(b & 0x0F)
return vals
first_nibbles, last_nibbles = nibbles(first_row), nibbles(last_row)
assert len(first_nibbles) == 1, "first wire row should be a single color at the true 600-byte stride"
assert len(last_nibbles) == 1, "last wire row should be a single color at the true 600-byte stride"
assert first_nibbles != last_nibbles, "the black/white split should still show up across wire rows"
+2 -1
View File
@@ -37,7 +37,8 @@ def test_set_border_persists(client, db_session):
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json() == { assert resp.json() == {
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0, "id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "locked": False, "border_style": "dashed", "border_thickness": 5, "border_color_index": 3, "font_scale": 1.0,
"locked": False,
} }
widget = db_session.get(Widget, widget_id) widget = db_session.get(Widget, widget_id)
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3) assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
+184
View File
@@ -0,0 +1,184 @@
"""routers/api_widgets.py's POST .../font-scale endpoint (models.Widget.
font_scale) -- a Widget-level property, not a per-type config field, same
reasoning/shape as test_widget_border.py's border coverage. The second
half confirms the actual render threading (widgets/calendar.py and
widgets/tasks.py pass widget.font_scale into both the classic and modern
builders), the same "spy on the resolve call" approach test_widgets_tasks.
py/test_widgets_calendar.py already use for frame.theme threading."""
from __future__ import annotations
import time
from app.models import CalendarWidgetConfig, Frame, TaskWidgetConfig, Widget
from .conftest import csrf_headers, link_user, login, make_user
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).one().id
def test_new_widget_defaults_to_normal_font_scale(db_session):
widget = db_session.query(Widget).filter_by(frame_id=1).one()
assert widget.font_scale == 1.0
def test_set_font_scale_persists(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.json()["font_scale"] == 1.25
widget = db_session.get(Widget, widget_id)
assert widget.font_scale == 1.25
def test_set_font_scale_rejects_a_value_outside_the_fixed_choices(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 3.0}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "font_scale" in resp.json()["detail"]
assert db_session.get(Widget, widget_id).font_scale == 1.0
def test_set_font_scale_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_set_font_scale_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.get(Widget, widget_id).font_scale == 1.0
def test_set_font_scale_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/font-scale",
json={"font_scale": 1.25}, headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
# --- actually threads through to the renderers -----------------------------
def test_calendar_classic_render_threads_font_scale_through(db_session, monkeypatch):
"""Confirms widgets/calendar.py's classic branch passes widget.
font_scale into calendar_render._build, by spying on panel_style.
scaled_size (every classic builder's one shared scale point -- see
its own docstring)."""
from app import panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time(), font_scale=1.5)
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda"))
db_session.commit()
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.calendar.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.5 for s in seen_scales)
def test_calendar_modern_render_threads_font_scale_through(db_session, monkeypatch):
from PIL import Image
from app import html_render, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time(), font_scale=1.25)
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id, view="agenda", render_style="modern"))
db_session.commit()
monkeypatch.setattr(widgets.calendar, "get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""))
monkeypatch.setattr(widgets.calendar, "get_or_refresh_weather_for_widget",
lambda db, frame, widget: [])
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
from app import panel_style
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.calendar.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.25 for s in seen_scales)
def test_tasks_classic_render_threads_font_scale_through(db_session, monkeypatch):
from app import panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time(), font_scale=1.5)
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id))
db_session.commit()
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.5 for s in seen_scales)
def test_tasks_modern_render_threads_font_scale_through(db_session, monkeypatch):
from PIL import Image
from app import html_render, panel_style, widgets
frame = db_session.get(Frame, 1)
widget = Widget(frame_id=frame.id, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=0, created_at=time.time(), font_scale=1.25)
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id, render_style="modern"))
db_session.commit()
monkeypatch.setattr(widgets.tasks, "get_or_refresh_tasks_for_widget", lambda db, frame, widget: [])
monkeypatch.setattr(html_render, "render_html_to_image",
lambda html, target_w, target_h: Image.new("RGB", (target_w, target_h), (255, 255, 255)))
seen_scales = []
real_scaled_size = panel_style.scaled_size
monkeypatch.setattr(panel_style, "scaled_size", lambda v, s: (seen_scales.append(s), real_scaled_size(v, s))[1])
widgets.tasks.render(db_session, frame, widget, 300, 200)
assert seen_scales and all(s == 1.25 for s in seen_scales)
+2 -2
View File
@@ -76,9 +76,9 @@ def _capture_build_tasks_title(monkeypatch):
seen_titles = [] seen_titles = []
real_build_tasks = widgets.tasks._build_tasks real_build_tasks = widgets.tasks._build_tasks
def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks"): def spy(tasks, target_w, target_h, palette_rgb=None, title="Tasks", font_scale=1.0):
seen_titles.append(title) seen_titles.append(title)
return real_build_tasks(tasks, target_w, target_h, palette_rgb, title) return real_build_tasks(tasks, target_w, target_h, palette_rgb, title, font_scale=font_scale)
monkeypatch.setattr(widgets.tasks, "_build_tasks", spy) monkeypatch.setattr(widgets.tasks, "_build_tasks", spy)
return seen_titles return seen_titles