Compare commits
10
Commits
466efdb873
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c2afeba78 | ||
|
|
c0fefc19f1 | ||
|
|
454c03586e | ||
|
|
fe5a2df074 | ||
|
|
474b92a282 | ||
|
|
1d39e439ff | ||
|
|
2868087467 | ||
|
|
09119e775f | ||
|
|
f209880fd0 | ||
|
|
455020cb1f |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
build_one devkit "$@"
|
both)
|
||||||
build_one xiao "$@"
|
build_one devkit "$@"
|
||||||
else
|
build_one xiao "$@"
|
||||||
build_one "$board" "$@"
|
;;
|
||||||
fi
|
all)
|
||||||
|
build_one devkit "$@"
|
||||||
|
build_one xiao "$@"
|
||||||
|
build_one ee02 "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
build_one "$board" "$@"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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})")
|
||||||
|
|||||||
@@ -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 down && 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
|
||||||
|
|||||||
@@ -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__/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
+117
-36
@@ -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,16 +310,17 @@ 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 header/accent color
|
**What a theme actually changes, in practice**: the accent color (now a
|
||||||
is the most visible change, but `font_family` applies to *every* text
|
slim rule rather than a full header band -- see "Bold minimal" above) is
|
||||||
element in the widget, not just the header title -- day labels,
|
still the most visible change on widgets that have one, but `font_family`
|
||||||
temperatures, task rows, event times, day numbers all switch fonts too
|
applies to *every* text element in the widget, not just the header title
|
||||||
(e.g. "Moss" is serif, "Ochre" a slab serif), often more noticeable than
|
-- day labels, temperatures, task rows, event times, day numbers all
|
||||||
the accent color on text-heavy widgets. `radius`/`shadow` change the
|
switch fonts too (e.g. "Moss" is serif, "Ochre" a slab serif), often
|
||||||
card's corners and whether it has a drop shadow at all (e.g. "Slate" is
|
more noticeable than the accent color on text-heavy widgets. `radius`/
|
||||||
square-cornered with no shadow). Battery and text/static/whiteboard have
|
`shadow` only affect static image/whiteboard's card now (every other
|
||||||
no header, so for those a theme only shows up as font/radius/shadow
|
modern-style widget dropped its card in the "bold minimal" pass); text
|
||||||
differences, not an accent color.
|
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
|
||||||
@@ -279,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
|
||||||
|
|
||||||
@@ -484,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
@@ -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
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 --
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 — 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 — reconnect to
|
take you to the server to claim your frame — reconnect to
|
||||||
your normal WiFi if it doesn't happen automatically.</p>
|
your normal WiFi if it doesn't happen automatically.</p>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
@@ -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.
|
||||||
@@ -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 @@
|
|||||||
1.4.1
|
1.5.0
|
||||||
|
|||||||
+32
-27
@@ -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
|
||||||
|
|||||||
+44
-76
@@ -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,10 +286,9 @@ 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:
|
||||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
raise HTTPException(401, "Missing device id")
|
||||||
if frame is None:
|
|
||||||
legacy_frame = db.scalars(
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
if frame is None:
|
||||||
).first()
|
frame = _register_frame(db, device_id)
|
||||||
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
|
|
||||||
legacy_frame.device_id = device_id
|
|
||||||
frame = legacy_frame
|
|
||||||
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
|
|
||||||
else:
|
|
||||||
frame = _register_frame(db, device_id)
|
|
||||||
else:
|
|
||||||
token_ok = bool(token) and token == frame.device_token
|
|
||||||
if token_ok and not frame.device_token_ack:
|
|
||||||
frame.device_token_ack = True
|
|
||||||
logger.info("Frame #%d acknowledged its device token", frame.id)
|
|
||||||
if not token_ok:
|
|
||||||
if frame.legacy_token_enabled and legacy_ok:
|
|
||||||
pass
|
|
||||||
elif not frame.device_token_ack:
|
|
||||||
# Handshake window: the device registered but hasn't
|
|
||||||
# received its token yet (the wake cycle fetches the
|
|
||||||
# image BEFORE polling /frame/config, where the token
|
|
||||||
# is delivered) -- the id stays the credential, same
|
|
||||||
# trust level as the open registration that created
|
|
||||||
# the row. Closes permanently on the first
|
|
||||||
# authenticated request.
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise HTTPException(401, "Missing or invalid access token")
|
|
||||||
else:
|
else:
|
||||||
if not legacy_ok:
|
token_ok = bool(token) and token == frame.device_token
|
||||||
|
if token_ok and not frame.device_token_ack:
|
||||||
|
frame.device_token_ack = True
|
||||||
|
logger.info("Frame #%d acknowledged its device token", frame.id)
|
||||||
|
elif not token_ok and frame.device_token_ack:
|
||||||
raise HTTPException(401, "Missing or invalid access token")
|
raise HTTPException(401, "Missing or invalid access token")
|
||||||
frame = db.scalars(
|
# else: handshake window -- the device registered but hasn't
|
||||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
# received its token yet (the wake cycle fetches the image
|
||||||
).first()
|
# BEFORE polling /frame/config, where the token is delivered) --
|
||||||
if frame is None:
|
# the id stays the credential, same trust level as the open
|
||||||
# Nothing to resolve a no-id request to. migration.py always
|
# registration that created the row. Closes permanently on the
|
||||||
# creates frame #1 at startup, so this only happens if it was
|
# first authenticated request.
|
||||||
# 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,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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+374
-211
@@ -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]
|
||||||
for version, fn in MIGRATIONS:
|
|
||||||
if version > current:
|
# Each migration commits in its own transaction (rather than the
|
||||||
logger.info("Applying schema migration %d", version)
|
# whole batch sharing one, like this used to) so that _migration_41
|
||||||
fn(conn)
|
# can get a connection with no transaction pending on it yet --
|
||||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
# SQLite only honors toggling PRAGMA foreign_keys when issued as a
|
||||||
|
# connection's literal first statement, and it needs that off for
|
||||||
|
# its own DROP TABLE frames (frames is an ON DELETE CASCADE target
|
||||||
|
# for widgets/frame_button_actions/user_frames/etc, so leaving
|
||||||
|
# enforcement on there would cascade-delete every frame's widgets,
|
||||||
|
# not just the columns that migration means to drop). A crash
|
||||||
|
# partway through now simply leaves schema_version at the last
|
||||||
|
# migration that actually completed, same as it always could
|
||||||
|
# between separate runs of this function.
|
||||||
|
for version, fn in MIGRATIONS:
|
||||||
|
if version <= current:
|
||||||
|
continue
|
||||||
|
logger.info("Applying schema migration %d", version)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
if fn is _migration_41:
|
||||||
|
# Executing this before anything else auto-begins
|
||||||
|
# SQLAlchemy's own Transaction bookkeeping too, so an
|
||||||
|
# explicit conn.begin() below would conflict with it --
|
||||||
|
# fn(conn) and the version UPDATE just ride that same
|
||||||
|
# auto-begun transaction, committed explicitly at the end.
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=OFF"))
|
||||||
|
fn(conn)
|
||||||
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||||
|
conn.commit()
|
||||||
|
if fn is _migration_41:
|
||||||
|
# Restore it before this connection goes back to the
|
||||||
|
# pool -- otherwise a later checkout of the same
|
||||||
|
# underlying DBAPI connection (the connect-event
|
||||||
|
# listener in db.py only fires for a genuinely new one)
|
||||||
|
# would silently run with enforcement off. Has to
|
||||||
|
# happen AFTER commit(), same "no pending transaction"
|
||||||
|
# requirement as the OFF toggle above -- issuing it
|
||||||
|
# before the commit is exactly the mid-transaction
|
||||||
|
# no-op this migration exists to work around in the
|
||||||
|
# first place, just in the other direction.
|
||||||
|
conn.execute(text("PRAGMA foreign_keys=ON"))
|
||||||
|
|
||||||
_ensure_frame_one()
|
_ensure_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
@@ -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"),)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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" }}
|
||||||
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
· 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" }})
|
||||||
|
· panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
|
||||||
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
||||||
{% if f.legacy_token_enabled %}· <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 }}">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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,19 +1,26 @@
|
|||||||
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, accent_end, header_h) %}
|
{% macro day_section(header, weather_entries, weather_size, unit_suffix, rows, more_count, row_h, body_size, title_size, accent_start, header_h, accent_h) %}
|
||||||
<div class="day-section">
|
<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
|
||||||
{% if weather_entries %}
|
amplitude like everything else, not the richer accent amplitude
|
||||||
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
|
the old white-on-gradient text needed to stay legible. #}
|
||||||
{% for we in weather_entries %}
|
<div class="day-header" style="height: {{ header_h }}px;">
|
||||||
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}</span></div>
|
<div class="accent-rule" style="height: {{ accent_h }}px; background: {{ accent_start }};"></div>
|
||||||
{% endfor %}
|
<div class="day-header-row">
|
||||||
</div>
|
<div class="day-header-title" style="font-size: {{ title_size }}px;">{{ header }}</div>
|
||||||
{% endif %}
|
{% if weather_entries %}
|
||||||
|
<div class="day-weather-row" style="font-size: {{ weather_size }}px;">
|
||||||
|
{% for we in weather_entries %}
|
||||||
|
<div class="day-weather-entry"><span class="day-weather-icon" style="font-size: {{ weather_size * 1.3 }}px;">{{ we.emoji }}</span><span>{{ we.high }}°/{{ we.low }}°{{ unit_suffix }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
@@ -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 class="pct">{{ percent }}%</div>
|
<div>
|
||||||
{% for line in lines %}<div class="line">{{ line }}</div>{% endfor %}
|
<div class="pct">{{ percent }}%</div>
|
||||||
|
<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,20 +45,23 @@
|
|||||||
.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">
|
||||||
{% for col in cols %}
|
<div class="accent-rule"></div>
|
||||||
<div class="col">
|
<div class="cols">
|
||||||
<div class="col-header">
|
{% for col in cols %}
|
||||||
<div class="label">{{ col.label }}</div>
|
<div class="col">
|
||||||
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}</span></div>{% endif %}
|
<div class="col-header">
|
||||||
|
<div class="label">{{ col.label }}</div>
|
||||||
|
{% if col.weather %}<div class="col-weather"><span>{{ col.weather.emoji }}</span><span>{{ col.weather.high }}°/{{ col.weather.low }}°{{ unit_suffix }}</span></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-rows">
|
||||||
|
{% for row in col.rows %}
|
||||||
|
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-rows">
|
{% endfor %}
|
||||||
{% for row in col.rows %}
|
</div>
|
||||||
<div class="col-row"><div class="col-chip" style="background:{{ row.color }};"></div><div class="col-summary">{{ row.summary }}</div></div>
|
|
||||||
{% endfor %}
|
|
||||||
{% if col.more_count %}<div class="col-more">+{{ col.more_count }}</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</body></html>
|
</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="icon">{{ emoji }}</div>
|
<div class="top">
|
||||||
<div class="temp">{{ temp }}°{{ unit_suffix }}</div>
|
{% if city_label %}<div class="city">{{ city_label }}</div>{% else %}<div></div>{% endif %}
|
||||||
{% if city_label %}<div class="city">{{ city_label }}</div>{% endif %}
|
<div class="icon">{{ emoji }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="temp-row"><div class="temp">{{ temp }}</div><div class="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 }}°<span class="low">/{{ d.low }}°{{ unit_suffix }}</span></div>
|
<div class="temps"><div class="high">{{ d.high }}°</div><div class="low">{{ d.low }}°{{ unit_suffix }}</div></div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user