Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s

Server: Frame.panel_type (new column + migration) is auto-derived from
the device's reported board (X-Frame-Board), never user-set -- the
panel is a property of the hardware, not a picker in the UI.
image_pipeline's packing/render pipeline is parameterized by panel
geometry instead of hardcoded 800x480 globals, with the real confirmed
13.3in geometry (1600x1200) registered alongside the original 7.3in
panel. Existing 7.3in frames are unaffected (column default + board
mapping both resolve to the original panel).

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

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
This commit is contained in:
2026-08-04 20:08:22 +00:00
parent 1d39e439ff
commit 474b92a282
44 changed files with 1287 additions and 172 deletions
+25 -21
View File
@@ -1,10 +1,11 @@
--- ---
name: build-firmware name: build-firmware
description: Compile the espresso_frame ESP32-C6 firmware (firmware/) for both board variants without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check both the devkit and xiao board targets. description: Compile the espresso_frame firmware (firmware/) for all three board variants (ESP32-C6 devkit/xiao, ESP32-S3 ee02) without Docker -- a native, non-container ESP-IDF v6.0 install. Use when asked to build the firmware, verify a firmware/main/*.c change actually compiles, or check the devkit/xiao/ee02 board targets.
--- ---
Compiles `firmware/` (ESP-IDF, targeting ESP32-C6) locally, without Compiles `firmware/` (ESP-IDF, targeting ESP32-C6 for devkit/xiao and
Docker -- CI's `firmware-build-check.yml`/`firmware-release-build.yml` ESP32-S3 for ee02) locally, without Docker -- CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
build inside the `espressif/idf:release-v6.0` container image, but build inside the `espressif/idf:release-v6.0` container image, but
**this sandbox cannot run containers at all**: `docker.io` installs and **this sandbox cannot run containers at all**: `docker.io` installs and
`dockerd` starts fine even as root, but the sandbox strips `dockerd` starts fine even as root, but the sandbox strips
@@ -39,13 +40,13 @@ had neither):
of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches of `release/v6.0` (~700MB) into `~/.espressif-idf/esp-idf` -- matches
the IDF version CI's Docker image pins. Only clones once; re-running the IDF version CI's Docker image pins. Only clones once; re-running
`setup.sh` never touches an existing checkout. `setup.sh` never touches an existing checkout.
- The esp32c6 toolchain + Python venv, via ESP-IDF's own - The esp32c6+esp32s3 toolchains + Python venv, via ESP-IDF's own
`./install.sh esp32c6` -- scoped to just this project's one target `./install.sh esp32c6,esp32s3` -- scoped to just this project's two
(see `firmware/README.md`'s board table), not every chip ESP-IDF chip targets (see `firmware/README.md`'s board table: devkit/xiao are
supports, to keep the download/disk footprint down. `install.sh` is esp32c6, ee02 is esp32s3), not every chip ESP-IDF supports, to keep
already idempotent on its own, so `setup.sh` always calls it rather the download/disk footprint down. `install.sh` is already idempotent
than duplicating that check -- a re-run costs a few seconds once on its own, so `setup.sh` always calls it rather than duplicating
everything's cached. that check -- a re-run costs a few seconds once everything's cached.
Takes a few minutes on a cold run (mostly `install.sh`'s own pip/tool Takes a few minutes on a cold run (mostly `install.sh`'s own pip/tool
downloads), well under a minute on a re-run. Needs real root (`apt-get downloads), well under a minute on a re-run. Needs real root (`apt-get
@@ -63,15 +64,17 @@ checkout itself). Confirmed working with as little as ~7GB free.
```bash ```bash
bash .claude/skills/build-firmware/build.sh # devkit (default) bash .claude/skills/build-firmware/build.sh # devkit (default)
bash .claude/skills/build-firmware/build.sh xiao bash .claude/skills/build-firmware/build.sh xiao
bash .claude/skills/build-firmware/build.sh both # both variants bash .claude/skills/build-firmware/build.sh ee02
bash .claude/skills/build-firmware/build.sh both # devkit + xiao
bash .claude/skills/build-firmware/build.sh all # devkit + xiao + ee02
``` ```
Each board gets its own build directory and generated sdkconfig (see Each board gets its own build directory and generated sdkconfig (see
`firmware/build_for_board.sh`'s own comment) -- building one never `firmware/build_for_board.sh`'s own comment) -- building one never
disturbs the other. `build.sh` auto-runs `set-target esp32c6` the very disturbs the other. `build.sh` auto-runs `set-target` (esp32c6 for
first time a board is built (no generated sdkconfig yet); later builds devkit/xiao, esp32s3 for ee02) the very first time a board is built (no
skip straight to `idf.py build`. Extra arguments pass straight through generated sdkconfig yet); later builds skip straight to `idf.py build`.
to `idf.py`, e.g.: Extra arguments pass straight through to `idf.py`, e.g.:
```bash ```bash
bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0 bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0
@@ -83,15 +86,16 @@ somewhere hardware is actually plugged in (a real dev machine, or a
differently-configured environment with device passthrough). differently-configured environment with device passthrough).
A clean build of one board takes ~30s once the target's already been A clean build of one board takes ~30s once the target's already been
configured (~1,000 build steps total split across both boards, most of configured (~1,000 build steps total split across the boards, most of
it ESP-IDF's own components -- this project's own `firmware/main/*.c` it ESP-IDF's own components -- this project's own `firmware/main/*.c`
and `firmware/components/*` sources are a small fraction of that and and `firmware/components/*` sources are a small fraction of that and
compile in a few seconds). Output lands at compile in a few seconds). Output lands at
`firmware/build/espresso_frame.bin` (devkit) or `firmware/build/espresso_frame.bin` (devkit),
`firmware/build_xiao/espresso_frame.bin` (xiao) -- both paths are `firmware/build_xiao/espresso_frame.bin` (xiao), or
gitignored (`firmware/.gitignore`... actually the repo root `firmware/build_ee02/espresso_frame.bin` (ee02, once its driver actually
`.gitignore`'s "ESP-IDF firmware build output" section), so nothing compiles -- see the note above) -- all three paths are gitignored (the
here needs cleaning up before a commit. repo root `.gitignore`'s "ESP-IDF firmware build output" section), so
nothing here needs cleaning up before a commit.
## Verified ## Verified
+35 -17
View File
@@ -2,16 +2,25 @@
# Builds (or flashes/monitors, if a serial port is actually attached) # Builds (or flashes/monitors, if a serial port is actually attached)
# the espresso_frame firmware for one board variant, via the project's # the espresso_frame firmware for one board variant, via the project's
# own firmware/build_for_board.sh -- this script just sources the # own firmware/build_for_board.sh -- this script just sources the
# ESP-IDF environment first and auto-runs `set-target esp32c6` on a # ESP-IDF environment first and auto-runs `set-target` (esp32c6 for
# board's very first build (a fresh clone has no generated sdkconfig # devkit/xiao, esp32s3 for ee02) on a board's very first build (a fresh
# yet, same reasoning as CI's own build steps -- see firmware/README.md's # clone has no generated sdkconfig yet, same reasoning as CI's own build
# "Building for the Seeed XIAO ESP32-C6" section). # steps -- see firmware/README.md's "Building for the Seeed XIAO
# ESP32-C6" section).
#
# NOTE: ee02 builds will fail to compile -- deliberately -- until
# firmware/components/epd13in3e's panel driver is ported from vendor
# demo code (see that component's own top-of-file comment). The build
# plumbing itself (target selection, partition table, sdkconfig
# layering) is exercised regardless; only the final compile step fails.
# #
# Usage: # Usage:
# build.sh # build devkit (default) # build.sh # build devkit (default)
# build.sh devkit # build.sh devkit
# build.sh xiao # build.sh xiao
# build.sh both # build both board variants # build.sh ee02
# build.sh both # build devkit + xiao (unchanged meaning)
# build.sh all # build devkit + xiao + ee02
# build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached # build.sh xiao flash -p /dev/ttyUSB0 # only meaningful with real hardware attached
set -euo pipefail set -euo pipefail
@@ -33,16 +42,17 @@ cd "$firmware_dir"
build_one() { build_one() {
local board="$1" local board="$1"
shift shift
local sdkconfig local sdkconfig target
case "$board" in case "$board" in
devkit) sdkconfig="sdkconfig" ;; devkit) sdkconfig="sdkconfig"; target="esp32c6" ;;
xiao) sdkconfig="sdkconfig.xiao_local" ;; xiao) sdkconfig="sdkconfig.xiao_local"; target="esp32c6" ;;
*) echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2; exit 1 ;; ee02) sdkconfig="sdkconfig.ee02_local"; target="esp32s3" ;;
*) echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2; exit 1 ;;
esac esac
if [ ! -f "$sdkconfig" ]; then if [ ! -f "$sdkconfig" ]; then
echo "==> $board: no generated sdkconfig yet, setting target esp32c6" echo "==> $board: no generated sdkconfig yet, setting target $target"
./build_for_board.sh "$board" set-target esp32c6 ./build_for_board.sh "$board" set-target "$target"
fi fi
local args=("$@") local args=("$@")
@@ -55,9 +65,17 @@ build_one() {
board="${1:-devkit}" board="${1:-devkit}"
shift || true shift || true
if [ "$board" = "both" ]; then case "$board" in
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
+7 -6
View File
@@ -45,14 +45,15 @@ else
echo "esp-idf already cloned at $IDF_DIR" echo "esp-idf already cloned at $IDF_DIR"
fi fi
# 3. Toolchain + Python virtualenv, scoped to esp32c6 only -- this # 3. Toolchain + Python virtualenv, scoped to esp32c6+esp32s3 only --
# project's one target (see firmware/README.md's board table). Scoping # this project's two chip targets (see firmware/README.md's board
# avoids downloading toolchains for every chip ESP-IDF supports, which # table: devkit/xiao are esp32c6, ee02 is esp32s3). Scoping avoids
# matters given this container's disk headroom. install.sh is already # downloading toolchains for every chip ESP-IDF supports, which matters
# given this container's disk headroom. install.sh is already
# idempotent on its own (checks what's present and skips it), so this # idempotent on its own (checks what's present and skips it), so this
# always calls it rather than trying to duplicate that check here -- # always calls it rather than trying to duplicate that check here --
# a re-run only costs a few seconds once everything's cached. # a re-run only costs a few seconds once everything's cached.
echo "running esp-idf install.sh esp32c6 (fast if already installed) ..." echo "running esp-idf install.sh esp32c6,esp32s3 (fast if already installed) ..."
(cd "$IDF_DIR" && ./install.sh esp32c6) (cd "$IDF_DIR" && ./install.sh esp32c6,esp32s3)
echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)" echo "setup complete -> $IDF_DIR/export.sh (build.sh sources this for you)"
+28 -3
View File
@@ -3,9 +3,11 @@ name: Firmware build check
# Fires on every push touching firmware source, unlike # Fires on every push touching firmware source, unlike
# firmware-release-build.yml (which only builds+publishes when # firmware-release-build.yml (which only builds+publishes when
# firmware/version.txt itself is bumped -- the "cut a release" signal). # firmware/version.txt itself is bumped -- the "cut a release" signal).
# This just verifies both board variants still compile; nothing else in # This just verifies every board variant still compiles (or, for ee02,
# CI catches a firmware/** push that breaks the build until someone # that everything up to its known/tracked #error still compiles --
# happens to bump the version next. # see that step's own comment); nothing else in CI catches a
# firmware/** push that breaks the build until someone happens to bump
# the version next.
on: on:
push: push:
branches: [main] branches: [main]
@@ -49,3 +51,26 @@ jobs:
docker cp "$PWD/." "$cid:/workspace" docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid" docker start -a "$cid"
docker rm "$cid" docker rm "$cid"
# Expected to fail until firmware/components/epd13in3e's panel
# driver is ported from vendor demo code (deliberate #error, see
# that file's own top comment) -- continue-on-error so this known
# gap doesn't block every other firmware/** push. Still worth
# running: catches a regression in the surrounding scaffolding
# (Kconfig, main/CMakeLists.txt's component selection, sdkconfig
# layering) up to the point of that #error, same value a build
# check normally provides. Remove continue-on-error once
# epd13in3e's driver is real, so a build failure here goes back to
# being a genuine regression signal.
- name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
continue-on-error: true
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh ee02 set-target esp32s3 &&
./build_for_board.sh ee02 build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker rm "$cid"
+60 -8
View File
@@ -18,7 +18,7 @@ jobs:
# actions) is a Node action that gets exec'd *inside* whatever container # actions) is a Node action that gets exec'd *inside* whatever container
# the job specifies, so checkout fails immediately with "node: not # the job specifies, so checkout fails immediately with "node: not
# found" (hit this on the first real run). Checkout instead runs on the # found" (hit this on the first real run). Checkout instead runs on the
# plain runner (which has Node), and only the two build steps below # plain runner (which has Node), and only the three build steps below
# spin up the ESP-IDF image themselves (docker create/cp/start, see the # spin up the ESP-IDF image themselves (docker create/cp/start, see the
# comment on those steps for why not a plain `docker run -v`) -- the # comment on those steps for why not a plain `docker run -v`) -- the
# runner already bind-mounts the host's docker socket, so docker-in- # runner already bind-mounts the host's docker socket, so docker-in-
@@ -33,11 +33,13 @@ jobs:
id: version id: version
run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT" run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"
# Two board variants, two partition tables/flash sizes (see # Three board variants: devkit/xiao (ESP32-C6, different partition
# firmware/README.md's "Building for the Seeed XIAO ESP32-C6" # tables/flash sizes -- see firmware/README.md's "Building for the
# section) -- build_for_board.sh gives each its own build dir/ # Seeed XIAO ESP32-C6" section) and ee02 (ESP32-S3 + 13.3" panel,
# generated sdkconfig so this never fights over shared state. # a genuinely different chip target, not just a Kconfig variant).
# set-target first since a fresh checkout has no cached sdkconfig # build_for_board.sh gives each its own build dir/generated
# sdkconfig so this never fights over shared state. set-target
# first since a fresh checkout has no cached sdkconfig
# (firmware/sdkconfig* is gitignored, see firmware/.gitignore). # (firmware/sdkconfig* is gitignored, see firmware/.gitignore).
# safe.directory guards against git's "dubious ownership" check, # safe.directory guards against git's "dubious ownership" check,
# since the container runs as root over content owned by a # since the container runs as root over content owned by a
@@ -64,8 +66,15 @@ jobs:
') ')
docker cp "$PWD/." "$cid:/workspace" docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid" docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit.bin docker cp "$cid:/workspace/firmware/build/espresso_frame.bin" /tmp/release-assets/firmware-devkit_esp32c6.bin
docker rm "$cid" docker rm "$cid"
# Rename bridge: fielded devices flashed before this rename still
# report the bare "devkit" board name and look up "firmware-
# devkit.bin" for their OTA check -- publish a duplicate under
# the old name too so they can update at all. Safe to drop this
# duplicate in a later release once no fielded device reports
# the bare name anymore.
cp /tmp/release-assets/firmware-devkit_esp32c6.bin /tmp/release-assets/firmware-devkit.bin
- name: Build (xiao -- Seeed XIAO ESP32-C6) - name: Build (xiao -- Seeed XIAO ESP32-C6)
run: | run: |
@@ -77,7 +86,35 @@ jobs:
') ')
docker cp "$PWD/." "$cid:/workspace" docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid" docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao.bin docker cp "$cid:/workspace/firmware/build_xiao/espresso_frame.bin" /tmp/release-assets/firmware-xiao_esp32c6.bin
docker rm "$cid"
# Same rename-bridge reasoning as the devkit step above.
cp /tmp/release-assets/firmware-xiao_esp32c6.bin /tmp/release-assets/firmware-xiao.bin
# NOTE: this build is expected to FAIL until
# firmware/components/epd13in3e's panel driver is ported from
# vendor demo code (see that component's own top-of-file comment
# -- a deliberate #error, not a bug here). `continue-on-error` so
# this known, tracked gap doesn't block publishing the devkit/xiao
# release (those boards work today and shouldn't wait on ee02) --
# this step's own status still shows failed/red individually in
# the run's step list, it just doesn't fail the overall job. Once
# epd13in3e's driver is real, a build failure here becomes a
# genuine regression again -- remove `continue-on-error` at that
# point so it goes back to failing the job like the other two
# builds do.
- name: Build (ee02 -- Seeed EE02, XIAO ESP32-S3 Plus + 13.3in panel)
continue-on-error: true
run: |
cid=$(docker create -w /workspace/firmware espressif/idf:release-v6.0 bash -c '
git config --global --add safe.directory /workspace &&
. "$IDF_PATH/export.sh" &&
./build_for_board.sh ee02 set-target esp32s3 &&
./build_for_board.sh ee02 build
')
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$cid"
docker cp "$cid:/workspace/firmware/build_ee02/espresso_frame.bin" /tmp/release-assets/firmware-ee02.bin
docker rm "$cid" docker rm "$cid"
# Plain stdlib urllib rather than `requests` -- not guaranteed to be # Plain stdlib urllib rather than `requests` -- not guaranteed to be
@@ -149,10 +186,25 @@ jobs:
existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])} existing_assets = {a["name"]: a["id"] for a in release.get("assets", [])}
assets = [ assets = [
("firmware-devkit_esp32c6.bin", "/tmp/release-assets/firmware-devkit_esp32c6.bin"),
("firmware-xiao_esp32c6.bin", "/tmp/release-assets/firmware-xiao_esp32c6.bin"),
("firmware-ee02.bin", "/tmp/release-assets/firmware-ee02.bin"),
# Rename-bridge duplicates for devices still on old firmware
# reporting the bare "devkit"/"xiao" board names -- see the
# build steps above. Safe to remove once no fielded device
# reports the bare name anymore.
("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"), ("firmware-devkit.bin", "/tmp/release-assets/firmware-devkit.bin"),
("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"), ("firmware-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
] ]
for name, path in assets: for name, path in assets:
if not os.path.exists(path):
# Expected for firmware-ee02.bin while that build is
# still allowed to fail (continue-on-error, see the
# build step's own comment) -- publish whatever boards
# did build rather than crashing the whole release over
# a known, tracked gap.
print(f"Skipping {name}: build did not produce {path}")
continue
if name in existing_assets: if name in existing_assets:
del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}") del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
print(f"Removed existing asset {name} (status {del_status})") print(f"Removed existing asset {name} (status {del_status})")
+3
View File
@@ -10,6 +10,9 @@ firmware/dependencies.lock
firmware/build_xiao/ firmware/build_xiao/
firmware/sdkconfig.xiao_local firmware/sdkconfig.xiao_local
firmware/sdkconfig.xiao_local.old firmware/sdkconfig.xiao_local.old
firmware/build_ee02/
firmware/sdkconfig.ee02_local
firmware/sdkconfig.ee02_local.old
# Python server # Python server
server/__pycache__/ server/__pycache__/
+58
View File
@@ -152,3 +152,61 @@ 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 (driver not yet functional)
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`) but **the firmware driver for it does not work yet**
-- `firmware/components/epd13in3e`'s panel init/LUT/refresh register
sequence hasn't been ported from Waveshare's/Seeed's own reference code
(which wasn't available at the time this was scaffolded), so a build for
this board deliberately fails to compile (`#error`) rather than risk
sending wrong register/LUT/timing values to real hardware. Confirmed so
far, from Waveshare's/Seeed's public product pages and a community
ESPHome integration (not an official vendor reference driver):
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
1600x1200, 270.40x202.80mm, same 6-ink Spectra family as the 7.3"
panel. Full refresh ~19s.
- 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): 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.
| 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) isn't confirmed, and the
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
`firmware/main/Kconfig.projbuild`) still range-limit to GPIO 0-7 --
the ESP32-C6's deep-sleep-wakeup-capable set, not yet verified against
the ESP32-S3's. SPI clock is reportedly reliable only up to 2MHz on
this panel/board (vs. epd7in3e's 4MHz default) -- see
`firmware/sdkconfig.ee02`.
See `firmware/components/epd13in3e/epd13in3e.c`'s own top comment for
what's needed to actually make this board work.
+28 -1
View File
@@ -1,6 +1,10 @@
# ESPresso Frame Firmware # ESPresso Frame Firmware
ESP-IDF firmware for the ESP32-C6. On first boot it provisions itself over ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or
ESP32-S3 (ee02 board, 13.3" panel -- see
[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-not-yet-functional)
below; the driver for that panel doesn't work yet). 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,29 @@ 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 not yet functional)
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 will fail to compile.** `firmware/components/epd13in3e`'s panel
init/LUT/refresh register sequence hasn't been ported from vendor demo
code yet (see that file's own top comment and
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-driver-not-yet-functional))
-- a deliberate `#error`, not a bug in this build path. Everything
around it (target selection, Kconfig, partition table, sdkconfig
layering, `main/CMakeLists.txt`'s component selection) is in place and
exercised by CI (`.gitea/workflows/firmware-build-check.yml`/
`firmware-release-build.yml`, both with `continue-on-error` on this
board's step until the driver is real).
## Configuration (`idf.py menuconfig`) ## Configuration (`idf.py menuconfig`)
Under **ESPresso Frame Configuration**: Under **ESPresso Frame Configuration**:
+33 -14
View File
@@ -1,28 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds/flashes for a specific board variant. This project targets two: # Builds/flashes for a specific board variant. This project targets three:
# #
# devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is # devkit ESP32-C6-DevKitC-1 (8MB flash) -- the dev board. This is
# also the plain `idf.py` default (sdkconfig/build/), so this # also the plain `idf.py` default (sdkconfig/build/), so this
# script's devkit mode is mostly for symmetry -- normal # script's devkit mode is mostly for symmetry -- normal
# `idf.py build`/`flash` work fine too. # `idf.py build`/`flash` work fine too. Reports itself as
# "devkit_esp32c6" (see main/Kconfig.projbuild).
# xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board. # xiao Seeed XIAO ESP32-C6 (4MB flash) -- the production board.
# Reports itself as "xiao_esp32c6".
# ee02 Seeed EE02 (XIAO ESP32-S3 Plus, 16MB flash) + 13.3" Spectra 6
# panel -- a genuinely different chip target (esp32s3, not
# esp32c6), unlike xiao's same-chip Kconfig-only variant.
# Reports itself as "ee02". NOTE: the epd13in3e driver this
# board links (firmware/components/epd13in3e) doesn't actually
# work yet -- its panel init/LUT/refresh register sequence is
# still unported from vendor demo code (see that component's
# own top-of-file comment); building for ee02 will fail to
# compile until that lands, by design (a deliberate #error, not
# a bug in this script).
# #
# The two need different partition tables (the XIAO's 4MB doesn't fit # The three need different partition tables (each flash size needs its
# the dev board's two 2MB OTA app slots -- see partitions_xiao.csv, # own OTA app-slot sizing -- see partitions_xiao.csv/partitions_ee02.csv)
# 1.875MB slots instead) and a different flash-size Kconfig. Rather # and different flash-size Kconfig. Rather than hand-editing the shared
# than hand-editing the shared sdkconfig back and forth (fragile, easy # sdkconfig back and forth (fragile, easy to leave it in the wrong state
# to leave it in the wrong state for whichever board you flash next), # for whichever board you flash next), each board gets its own build
# each board gets its own build directory and its own generated # directory and its own generated sdkconfig, seeded from
# sdkconfig, seeded from sdkconfig.defaults (shared) with the board's # sdkconfig.defaults (shared) with the board's override file layered on
# override file layered on top via ESP-IDF's own SDKCONFIG_DEFAULTS # top via ESP-IDF's own SDKCONFIG_DEFAULTS mechanism. Switching boards is
# mechanism. Switching boards is just switching which one you invoke -- # just switching which one you invoke -- none ever touches another's
# neither ever touches the other's config or build output. # config or build output.
# #
# Usage: # Usage:
# ./build_for_board.sh xiao build # ./build_for_board.sh xiao build
# ./build_for_board.sh xiao flash -p /dev/ttyUSB0 # ./build_for_board.sh xiao flash -p /dev/ttyUSB0
# ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0 # ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0
# ./build_for_board.sh devkit build # ./build_for_board.sh devkit build
# ./build_for_board.sh ee02 set-target esp32s3 # first build only, see below
# ./build_for_board.sh ee02 build
# #
# Defaults to "build" if no idf.py subcommand is given. # Defaults to "build" if no idf.py subcommand is given.
@@ -32,7 +46,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$script_dir" cd "$script_dir"
if [ $# -lt 1 ]; then if [ $# -lt 1 ]; then
echo "Usage: $0 <devkit|xiao> [idf.py args...]" >&2 echo "Usage: $0 <devkit|xiao|ee02> [idf.py args...]" >&2
exit 1 exit 1
fi fi
board="$1" board="$1"
@@ -49,8 +63,13 @@ case "$board" in
sdkconfig_path="$script_dir/sdkconfig" sdkconfig_path="$script_dir/sdkconfig"
defaults="$script_dir/sdkconfig.defaults" defaults="$script_dir/sdkconfig.defaults"
;; ;;
ee02)
build_dir="$script_dir/build_ee02"
sdkconfig_path="$script_dir/sdkconfig.ee02_local"
defaults="$script_dir/sdkconfig.defaults;$script_dir/sdkconfig.ee02"
;;
*) *)
echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2 echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2
exit 1 exit 1
;; ;;
esac esac
@@ -0,0 +1,13 @@
# SRCS is conditional on which board's panel this build targets -- see
# epd7in3e/CMakeLists.txt's identical comment (the two components mirror
# each other: exactly one contributes actual object files/symbols to any
# given build, the other is required but empty).
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "epd13in3e.c")
else()
set(srcs "")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+65
View File
@@ -0,0 +1,65 @@
menu "E-Paper Display (epd13in3e) Configuration"
config EPD_PIN_CLK
int "SPI CLK (SCLK) GPIO"
default 7
help
Defaults sourced from a community-verified ESPHome
integration for this exact board
(github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
official Waveshare/Seeed reference driver (see
firmware/components/epd13in3e/epd13in3e.c's top comment,
which is about the still-unknown panel init/LUT/refresh
register sequence, a separate and larger unknown than this
pinout). Override if your own board wiring differs.
config EPD_PIN_MOSI
int "SPI MOSI (DIN) GPIO"
default 9
config EPD_PIN_CS_MASTER
int "SPI CS (master half) GPIO"
default 44
help
Unlike epd7in3e's single-CS interface, this panel is driven
as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
two independent chip-selects (master/slave) -- confirmed by
the same community ESPHome integration, not yet by this
component's own driver code (still unimplemented, see
epd13in3e.c).
config EPD_PIN_CS_SLAVE
int "SPI CS (slave half) GPIO"
default 41
config EPD_PIN_DC
int "Data/Command GPIO"
default 10
config EPD_PIN_RST
int "Reset GPIO"
default 38
config EPD_PIN_BUSY
int "Busy GPIO"
default 4
config EPD_PIN_POWER_EN
int "Panel power-enable GPIO"
default 43
help
No equivalent pin on epd7in3e's board -- the EE02 apparently
gates the panel's own power rail separately from the ESP32-S3
module's. Source: same community integration as the other
pins above.
config EPD_SPI_CLOCK_HZ
int "SPI clock speed (Hz)"
default 2000000
help
2MHz, not epd7in3e's 4MHz default -- the same community
integration notes higher rates were unreliable on this
panel/board combo. Revisit once wiring is confirmed on real
hardware.
endmenu
+292
View File
@@ -0,0 +1,292 @@
#include <string.h>
#include "driver/gpio.h"
#include "driver/spi_master.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"
/* This component only ever gets compiled in when CONFIG_FRAME_PANEL_EE02_
* 13IN3=y selects it as main/CMakeLists.txt's linked EPD driver (see
* main/epd_board.h) -- i.e. only when someone deliberately builds for the
* EE02 board. epd7in3e.c's own top comment explains why its exact
* command bytes/register values are trustworthy: they're a line-for-line
* transcription of Waveshare's own reference driver, since this class of
* panel controller has no public datasheet. No equivalent reference
* driver for the 13.3" panel + EE02 exists in this tree yet, and guessing
* at register/LUT/timing values is not a safe substitute -- wrong values
* can under-refresh (ghosting) or over-drive a real panel. Get Waveshare's
* or Seeed's official demo/reference code for this exact panel+board
* combo, port its command sequences the same way epd7in3e.c's were
* ported, and remove this #error as part of that. The SPI/GPIO plumbing
* below (bus init, busy-wait, chunked writes, the streaming/CRC contract)
* is NOT panel-specific and should carry over unchanged once that
* happens -- only the register sequences inside epd_init()/
* epd_turn_on_display()/epd_sleep() need real vendor values.
*
* One more thing the real driver logic will need to account for, beyond
* epd7in3e.c's shape: this panel is driven as two halves sharing one
* CLK/MOSI/DC/RST/BUSY bus but with two independent chip-selects
* (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE, see Kconfig) -- epd_send_command/
* epd_send_data below still only assert CS_MASTER, which is wrong for
* whichever commands/data need to go to the slave half instead. Source
* for the dual-CS pinout itself (not the command sequence) is a
* community-verified ESPHome integration for this exact board
* (github.com/rkaramandi/esphome-seeed-ee02), not an official Seeed/
* Waveshare reference -- treat it as a reasonable starting point, not
* gospel, until confirmed against real hardware. */
#error "epd13in3e: panel init/LUT/refresh register sequence not yet ported from vendor demo code -- see this file's top comment"
#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)
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 convention as epd7in3e.c;
* confirm against the vendor demo code once it exists (some EPD
* controllers invert this). See epd7in3e.c's own comment for why this
* polls in >= 1 FreeRTOS tick increments rather than a tight spin. */
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;
}
static esp_err_t epd_send_command(uint8_t cmd)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_spi_write(&cmd, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
return err;
}
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);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_spi_write(data, len);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
return err;
}
static esp_err_t epd_send_data_byte(uint8_t data)
{
return epd_send_data(&data, 1);
}
static void epd_reset(void)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(20);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(2);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(20);
}
/* TODO(epd13in3e): power-on/refresh/power-off register sequence -- see
* this file's top #error. epd7in3e.c's epd_turn_on_display() is the
* shape to mirror once the real command bytes are known. */
esp_err_t epd_turn_on_display(void)
{
(void)epd_send_command;
(void)epd_send_data;
(void)epd_send_data_byte;
(void)epd_wait_busy;
return ESP_ERR_NOT_SUPPORTED;
}
esp_err_t epd_init(void)
{
/* CS_SLAVE and POWER_EN are configured as outputs here (safe,
* mechanical) but not yet driven anywhere below -- the dual-CS
* command routing and whatever power-on-vs-reset sequencing
* POWER_EN needs are both part of the still-unported vendor
* register sequence (see this file's top comment), not something
* to guess at. */
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_CS_MASTER, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
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,
.queue_size = 1,
};
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
epd_reset();
epd_wait_busy();
epd_delay_ms(30);
/* TODO(epd13in3e): panel-specific power-on register sequence goes
* here, see this file's top #error. */
ESP_LOGE(TAG, "epd_init: panel register sequence not yet ported, EPD will not actually work");
return ESP_ERR_NOT_SUPPORTED;
}
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");
EPD_CHECK(epd_send_command(0x10));
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
/* Static rather than a stack local -- see epd7in3e.c's identical
* comment on why (default main task stack is smaller than this
* chunk buffer alone). */
static uint8_t chunk[EPD_SPI_CHUNK_SIZE];
size_t total = 0;
uint32_t crc = 0;
size_t n;
esp_err_t err = ESP_OK;
while ((n = read_fn(chunk, sizeof(chunk), ctx)) > 0) {
err = epd_spi_write(chunk, n);
if (err != ESP_OK) {
break;
}
crc = esp_rom_crc32_le(crc, chunk, n);
total += n;
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
EPD_CHECK(err);
if (total != EPD_FRAME_BYTES) {
/* Same invariant as epd7in3e.c: never trigger a refresh on a
* short/wrong-size stream. */
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
return ESP_ERR_INVALID_SIZE;
}
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);
}
/* TODO(epd13in3e): power-off/deep-sleep register sequence -- see this
* file's top #error. */
esp_err_t epd_sleep(void)
{
return ESP_ERR_NOT_SUPPORTED;
}
@@ -0,0 +1,89 @@
#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): 1600x1200, 4 bits/pixel packed
* 2-pixels-per-byte -- same packing convention and public function
* shapes as epd7in3e.h, just a different resolution, so main's own
* sources don't need to branch on which panel is active beyond
* main/epd_board.h's
* header selection. Confirmed from Waveshare's/Seeed's public product
* pages (270.40x202.80mm, 1600x1200px). NOT yet confirmed against the
* vendor's own reference driver code, which doesn't exist in this tree
* yet -- see epd13in3e.c's top comment and epd_init()'s stub. The panel
* is also driven as two halves over a shared bus with two independent
* chip-selects (see Kconfig's EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE), unlike
* epd7in3e's single-CS interface -- that's an implementation detail of
* epd13in3e.c, not something callers of this header need to know about. */
#define EPD_WIDTH 1600
#define EPD_HEIGHT 1200
#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, so the same 6 named
* colors -- but whether this panel's controller uses the SAME nibble
* values as epd7in3e.h's epd_color_t is UNCONFIRMED (see this file's own
* top comment). Left identical to epd7in3e.h's values as the working
* assumption; correct these against the vendor demo code once it exists,
* alongside server/app/image_pipeline.py's PANEL_CODES if they turn out
* to differ (see that file's own comment on PANEL_CODES). */
typedef enum {
EPD_COLOR_BLACK = 0x0,
EPD_COLOR_WHITE = 0x1,
EPD_COLOR_YELLOW = 0x2,
EPD_COLOR_RED = 0x3,
EPD_COLOR_BLUE = 0x5,
EPD_COLOR_GREEN = 0x6,
} epd_color_t;
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
esp_err_t epd_init(void);
/** Fills the whole panel with a single color and refreshes. */
esp_err_t epd_clear(epd_color_t color);
/**
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
* into chunk. Must return the number of bytes written, or 0 once exhausted.
*/
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
/**
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
* panel via read_fn and refreshes. Pulling from a caller-supplied source
* instead of a single buffer lets callers feed the panel directly from an
* HTTP response without holding the whole ~960KB frame in RAM.
*/
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
/**
* Like epd_display_stream(), but writes the frame into the panel's
* internal buffer over SPI WITHOUT triggering the physical refresh (the
* visible flash/flicker) -- call epd_turn_on_display() separately to make
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
* nothing is refreshed, so the visible screen is left untouched on
* error.
*
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
* lets a caller compare against the last-displayed frame's CRC and skip
* the refresh entirely when nothing actually changed (e.g. redisplaying
* the same photo after a reboot).
*/
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
/**
* Triggers the panel's physical refresh cycle (power on, refresh, power
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
* to make the written buffer visible.
*/
esp_err_t epd_turn_on_display(void);
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
esp_err_t epd_sleep(void);
+13 -1
View File
@@ -1,3 +1,15 @@
idf_component_register(SRCS "epd7in3e.c" # SRCS is conditional on which board's panel this build targets (see
# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
# can't be) -- an ee02 build still always requires this component (so
# its Kconfig menu/include dir exist), but contributes zero object
# files/symbols to it, since epd13in3e.c provides the real epd_init()
# etc. for that board instead.
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "")
else()
set(srcs "epd7in3e.c")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio) PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+13 -1
View File
@@ -1,3 +1,15 @@
# Both EPD driver components are always REQUIRED (REQUIRES/PRIV_REQUIRES
# can't itself depend on a Kconfig value -- ESP-IDF resolves the
# component dependency graph in an early pass that runs BEFORE Kconfig
# is generated, so a CONFIG_* check here would silently see an empty
# value every time; confirmed the hard way, see git history if this
# comment ever seems suspicious). Which one actually compiles anything
# is decided inside each component's own CMakeLists.txt (conditional
# SRCS, evaluated in the later, Kconfig-aware pass -- that's fine, it's
# only REQUIRES itself that has the early-pass restriction), keyed off
# the same CONFIG_FRAME_PANEL_EE02_13IN3 that main/epd_board.h uses to
# pick which header every source file sees -- exactly one of the two
# ever contributes actual object files/symbols to a given build.
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e epd13in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html) EMBED_FILES root.html)
+31 -8
View File
@@ -2,18 +2,29 @@ menu "ESPresso Frame Configuration"
config FRAME_BOARD_NAME config FRAME_BOARD_NAME
string "Board variant name, reported to the server" string "Board variant name, reported to the server"
default "devkit" default "devkit_esp32c6"
help help
Sent as the X-Frame-Board request header on every Sent as the X-Frame-Board request header on every
GET /frame/config poll, so the server can learn which board GET /frame/config poll, so the server can learn which board
this device is and automatically fetch the right OTA build this device is and automatically fetch the right OTA build
from a configured Gitea repo's releases -- no manual "which from a configured Gitea repo's releases, and (see
board" picker in the web UI. Must match one of the asset routers/device.py's BOARD_PANEL_MAP) which EPD panel it
names .gitea/workflows/firmware-release-build.yml publishes drives -- no manual "which board/panel" picker in the web
(firmware-<name>.bin): "devkit" (this default, for the UI. Must match one of the asset names
plain ESP32-C6-DevKitC-1 build) or "xiao" (set via .gitea/workflows/firmware-release-build.yml publishes
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see (firmware-<name>.bin): "devkit_esp32c6" (this default, for
build_for_board.sh). the plain ESP32-C6-DevKitC-1 build), "xiao_esp32c6" (set via
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build), or "ee02"
(set via sdkconfig.ee02 for the Seeed EE02/XIAO ESP32-S3
Plus + 13.3" panel build) -- see build_for_board.sh.
Chip-qualified rather than plain "devkit"/"xiao": the EE02
board also sockets a XIAO module (an ESP32-S3 one), so
"xiao" alone stopped disambiguating hardware once EE02
existed. The server keeps accepting the old bare
"devkit"/"xiao" names indefinitely too, since already-
flashed devices report whatever name their current firmware
was built with and can't be retroactively renamed.
config FRAME_XIAO_ANTENNA_INIT config FRAME_XIAO_ANTENNA_INIT
bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)" bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)"
@@ -33,6 +44,18 @@ menu "ESPresso Frame Configuration"
by default in sdkconfig.xiao; leave off for the DevKitC-1 by default in sdkconfig.xiao; leave off for the DevKitC-1
dev board, which has no such switch. dev board, which has no such switch.
config FRAME_PANEL_EE02_13IN3
bool "Build for the EE02 board + 13.3in Spectra 6 panel (ESP32-S3), not the 7.3in panel"
default n
help
Selects the epd13in3e driver component (13.3", 1600x1200)
instead of epd7in3e (7.3", 800x480) as main/epd_board.h's
target -- see firmware/components/epd13in3e. Firmware only
ever links one EPD driver at a time, same as the
devkit/xiao split links exactly one board's pin config.
Enabled by default in sdkconfig.ee02; leave off for the
ESP32-C6 boards (devkit/xiao), which drive the 7.3" panel.
config ESP_AP_SSID config ESP_AP_SSID
string "Provisioning softAP SSID prefix" string "Provisioning softAP SSID prefix"
default "ESPRESSO" default "ESPRESSO"
+17
View File
@@ -0,0 +1,17 @@
#pragma once
/* Which EPD driver component this binary is built against -- exactly one,
* selected at compile time by CONFIG_FRAME_PANEL_EE02_13IN3 (see
* main/Kconfig.projbuild and main/CMakeLists.txt's matching PRIV_REQUIRES
* selection). Every file that used to `#include "epd7in3e.h"` directly
* includes this instead, so a build for the other board picks up the
* right EPD_WIDTH/EPD_HEIGHT/EPD_FRAME_BYTES/epd_color_t/epd_init() etc.
* with no other source change -- both driver components expose the same
* function/macro names (see epd13in3e.h), just sized for their own
* panel. */
#if CONFIG_FRAME_PANEL_EE02_13IN3
#include "epd13in3e.h"
#else
#include "epd7in3e.h"
#endif
+2 -2
View File
@@ -3,10 +3,10 @@
#include <stddef.h> #include <stddef.h>
#include <stdint.h> #include <stdint.h>
#include "epd7in3e.h" #include "epd_board.h"
#include "fonts.h" #include "fonts.h"
/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd7in3e.h). */ /** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd_board.h). */
void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color); void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color);
/** /**
+9 -8
View File
@@ -14,7 +14,7 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h" #include "freertos/event_groups.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "status_screen.h" #include "status_screen.h"
#include "combo_button.h" #include "combo_button.h"
#include "ota_update.h" #include "ota_update.h"
@@ -460,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";
@@ -687,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
+1 -1
View File
@@ -4,7 +4,7 @@
#include "esp_check.h" #include "esp_check.h"
#include "esp_log.h" #include "esp_log.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "epd_draw.h" #include "epd_draw.h"
#include "fonts.h" #include "fonts.h"
#include "qrcodegen.h" #include "qrcodegen.h"
+1 -1
View File
@@ -3,7 +3,7 @@
#include "esp_check.h" #include "esp_check.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "epd_draw.h" #include "epd_draw.h"
#include "fonts.h" #include "fonts.h"
#include "wifi_provisioning.h" #include "wifi_provisioning.h"
+1 -1
View File
@@ -19,7 +19,7 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "epd7in3e.h" #include "epd_board.h"
#include "qr_onboarding.h" #include "qr_onboarding.h"
#include "wifi_provisioning.h" #include "wifi_provisioning.h"
#include "board_antenna.h" #include "board_antenna.h"
+13
View File
@@ -0,0 +1,13 @@
# Name, Type, SubType, Offset, Size, Flags
# Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
# the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
# 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
# own sizing note) without needing to trim anything the way the 4MB xiao
# table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
# revisit sizing once a real build's actual footprint and any EE02-
# specific storage needs (if ever) are known.
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
ota_0, app, ota_0, 0x10000, 0x200000,
otadata, data, ota, 0x210000, 0x2000,
ota_1, app, ota_1, 0x220000, 0x200000,
1 # Name, Type, SubType, Offset, Size, Flags
2 # Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
3 # the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
4 # 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
5 # own sizing note) without needing to trim anything the way the 4MB xiao
6 # table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
7 # revisit sizing once a real build's actual footprint and any EE02-
8 # specific storage needs (if ever) are known.
9 nvs, data, nvs, 0x9000, 0x6000,
10 phy_init, data, phy, 0xf000, 0x1000,
11 ota_0, app, ota_0, 0x10000, 0x200000,
12 otadata, data, ota, 0x210000, 0x2000,
13 ota_1, app, ota_1, 0x220000, 0x200000,
+45
View File
@@ -0,0 +1,45 @@
# 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: (1) which physical button maps to
# which logical role (next/back/combo) isn't confirmed, and (2) more
# importantly, those Kconfig options' `range -1 7`/`range -1 6`
# constraints (main/Kconfig.projbuild) are hardcoded to the ESP32-C6's
# deep-sleep-wakeup-capable GPIO set -- NOT verified against which pins
# can actually wake the ESP32-S3 from deep sleep, which is a different
# set on a different chip. Confirm both before wiring this up; widen/
# adjust those Kconfig ranges if the real wakeup-capable pins for
# whichever GPIOs EE02's buttons land on fall outside them.
+1 -1
View File
@@ -8,7 +8,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv" CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv" CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv"
CONFIG_FRAME_BOARD_NAME="xiao" CONFIG_FRAME_BOARD_NAME="xiao_esp32c6"
# Powers the XIAO's RF switch and selects its onboard antenna -- without # Powers the XIAO's RF switch and selects its onboard antenna -- without
# this the softAP/STA radio doesn't reliably reach the antenna at all. # this the softAP/STA radio doesn't reliably reach the antenna at all.
+20 -15
View File
@@ -29,6 +29,8 @@ from PIL import Image, ImageDraw, ImageFont
from . import panel_style from . import panel_style
from .image_pipeline import ( from .image_pipeline import (
DEFAULT_PALETTE_RGB, DEFAULT_PALETTE_RGB,
EPD_HEIGHT,
EPD_WIDTH,
_apply_manage_overlay, _apply_manage_overlay,
_quantize, _quantize,
_transpose_and_pack, _transpose_and_pack,
@@ -802,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)
@@ -822,11 +824,12 @@ 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, font_scale: float = 1.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, font_scale) 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)
@@ -858,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)
@@ -870,11 +874,12 @@ def render_tasks(tasks: list[dict], orientation: str, palette_rgb: list | None,
def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None, def render_tasks_preview_png(tasks: list[dict], orientation: str, palette_rgb: list | None,
manage: dict | None = None, title: str = "Tasks", font_scale: float = 1.0) -> 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, font_scale=font_scale) 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)
+4 -3
View File
@@ -15,7 +15,7 @@ import io
from PIL import Image, ImageOps from PIL import Image, ImageOps
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _has_bounding_box, _placement_transform, logical_render_size
# Not a memory constraint anymore (the overlay renders server-side now, # Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A # not malloc'd per-label on the device) -- purely a legibility cap. A
@@ -25,7 +25,8 @@ MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str, def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape", region: tuple[int, int, int, int] | None = None) -> list[dict]: orientation: str = "landscape", region: tuple[int, int, int, int] | None = None,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the point -- manage_overlay.compose() draws these directly onto the
@@ -56,7 +57,7 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
return [] return []
if region is None: if region is None:
logical_w, logical_h = logical_render_size(orientation) logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h region_x0, region_y0, target_w, target_h = 0, 0, logical_w, logical_h
else: else:
region_x0, region_y0, target_w, target_h = region region_x0, region_y0, target_w, target_h = region
+4 -3
View File
@@ -433,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)
+99 -35
View File
@@ -10,6 +10,40 @@ 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 -- confirmed from
# Waveshare's/Seeed's public product pages, not yet from real
# hardware or vendor demo code (see firmware/components/epd13in3e's
# own docstring once it exists -- the init/LUT/refresh sequence and
# whether this panel's nibble color codes actually match PANEL_CODES
# below are still unconfirmed pending that).
"epd13in3e": (1600, 1200),
}
# 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 +180,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 +245,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 -- unverified whether the 13.3" panel's
# driver (once it exists, see PANEL_SPECS["epd13in3e"]) uses the same
# codes; if not, this needs to become a PANEL_CODE_TABLES dict keyed like
# PANEL_SPECS, with _transpose_and_pack taking the right one as a param.
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 +469,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:
@@ -464,17 +507,22 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes: def _transpose_and_pack(quantized: Image.Image, orientation: str) -> 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."""
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)
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 +549,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,8 +579,14 @@ 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)
@@ -547,7 +601,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 +641,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))
@@ -607,13 +668,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 +685,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 +695,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
+13
View File
@@ -1157,6 +1157,18 @@ def _migration_41(conn) -> None:
conn.execute(text("ALTER TABLE frames_new RENAME TO 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),
@@ -1199,6 +1211,7 @@ MIGRATIONS = [
(39, _migration_39), (39, _migration_39),
(40, _migration_40), (40, _migration_40),
(41, _migration_41), (41, _migration_41),
(42, _migration_42),
] ]
+7
View File
@@ -151,6 +151,13 @@ class Frame(Base):
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")
orientation: Mapped[str] = mapped_column(String, default="landscape") orientation: Mapped[str] = mapped_column(String, default="landscape")
# 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
+19 -8
View File
@@ -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,
@@ -783,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")
@@ -891,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
@@ -906,6 +908,7 @@ def api_widget_preview_calendar(
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,
@@ -913,6 +916,7 @@ def api_widget_preview_calendar(
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, font_scale=widget.font_scale, 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")
@@ -936,15 +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) 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) 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")
@@ -1196,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")
@@ -1254,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)
@@ -1266,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")
@@ -1285,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")
@@ -1405,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,
@@ -1415,6 +1426,6 @@ def api_widget_preview_whiteboard(
else: else:
png = render_preview_png( png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox", display_mode="letterbox", panel_type=frame.panel_type,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
+2 -2
View File
@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard from .. import caldav_client, calendar_feed, grid, quiet_hours, weather, whiteboard
from ..db import widget_locked from ..db import widget_locked
from ..image_pipeline import logical_render_size from ..image_pipeline import logical_render_size, panel_size
from ..immich_client import ImmichClient from ..immich_client import ImmichClient
from ..models import ( from ..models import (
BatteryLog, BatteryLog,
@@ -514,7 +514,7 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets): if any(db.get(PhotoWidgetConfig, w.id).current_asset_id for w in photo_widgets):
content["share_url"] = f"{base}/frame/share/{frame.manage_token}" content["share_url"] = f"{base}/frame/share/{frame.manage_token}"
panel_w, panel_h = logical_render_size(frame.orientation) panel_w, panel_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
face_labels: list[dict] = [] face_labels: list[dict] = []
for widget in photo_widgets: for widget in photo_widgets:
cfg = db.get(PhotoWidgetConfig, widget.id) cfg = db.get(PhotoWidgetConfig, widget.id)
+38 -4
View File
@@ -27,7 +27,14 @@ from ..auth import get_server_settings, require_device
from ..db import SessionLocal, frame_locked, get_db from ..db import SessionLocal, frame_locked, get_db
from ..firmware import firmware_path from ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color from ..image_pipeline import (
draw_widget_border,
logical_render_size,
panel_size,
render_panel,
render_placeholder,
resolve_border_color,
)
from ..models import BatteryLog, Frame, FrameButtonAction, Widget from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES from ..widgets import WIDGET_TYPES
from .common import ( from .common import (
@@ -62,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
if frame.owner_user_id is None: if frame.owner_user_id is None:
return render_placeholder( return render_placeholder(
@@ -71,6 +79,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
return render_placeholder( return render_placeholder(
["Almost there!", "Add a widget for this frame at", base], ["Almost there!", "Add a widget for this frame at", base],
@@ -80,6 +89,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage, manage=manage,
as_png=as_png, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
@@ -141,7 +151,7 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
all_widgets = db.scalars( all_widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all() ).all()
panel_w, panel_h = logical_render_size(frame.orientation) panel_w, panel_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
regions = [] regions = []
if all_widgets: if all_widgets:
with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool: with ThreadPoolExecutor(max_workers=min(len(all_widgets), 8)) as pool:
@@ -160,7 +170,7 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength, manage=manage, as_png=as_png, dither_strength=frame.dither_strength, manage=manage, as_png=as_png,
capture_snapshot=capture_snapshot, capture_snapshot=capture_snapshot, panel_type=frame.panel_type,
) )
@@ -185,6 +195,7 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
return render_placeholder( return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, ["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
manage=manage, as_png=as_png, capture_snapshot=capture_snapshot, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
) )
return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot) return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
@@ -251,6 +262,23 @@ def _run_global_action(db: Session, frame: Frame, button: str) -> None:
logger.exception("Global hold action %r failed for frame %d", action, frame.id) logger.exception("Global hold action %r failed for frame %d", action, frame.id)
# Maps a device's self-reported board (X-Frame-Board, CONFIG_FRAME_BOARD_
# NAME) to which EPD panel it drives -- the panel type is a property of
# the board's firmware, not something a person picks in the UI (see
# Frame.panel_type). Includes both the legacy bare names ("devkit",
# "xiao") already baked into fielded firmware and the current chip-
# qualified names ("devkit_esp32c6", "xiao_esp32c6") -- keep both
# indefinitely, since already-flashed devices can't be retroactively
# renamed and there's no cost to accepting either.
BOARD_PANEL_MAP = {
"devkit": "epd7in3e",
"xiao": "epd7in3e",
"devkit_esp32c6": "epd7in3e",
"xiao_esp32c6": "epd7in3e",
"ee02": "epd13in3e",
}
@router.get("/frame/config") @router.get("/frame/config")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)): def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Device-facing settings, polled by the frame alongside its """Device-facing settings, polled by the frame alongside its
@@ -259,7 +287,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
signal. Also captures the device's running firmware version and board signal. Also captures the device's running firmware version and board
variant (X-Frame-Version/X-Frame-Board headers) and advertises the variant (X-Frame-Version/X-Frame-Board headers) and advertises the
available OTA image's version, so the device's update check costs available OTA image's version, so the device's update check costs
zero extra round trips.""" zero extra round trips. The reported board also auto-sets
Frame.panel_type (see BOARD_PANEL_MAP) -- which EPD panel a frame
renders for is derived from what the hardware reports, never a manual
setting."""
reported_version = request.headers.get("X-Frame-Version", "") reported_version = request.headers.get("X-Frame-Version", "")
reported_board = request.headers.get("X-Frame-Board", "") reported_board = request.headers.get("X-Frame-Board", "")
with frame_locked(db, frame.id) as locked: with frame_locked(db, frame.id) as locked:
@@ -272,6 +303,9 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
locked.device_firmware_version = reported_version locked.device_firmware_version = reported_version
if reported_board: if reported_board:
locked.device_board_variant = reported_board locked.device_board_variant = reported_board
mapped_panel = BOARD_PANEL_MAP.get(reported_board)
if mapped_panel and mapped_panel != locked.panel_type:
locked.panel_type = mapped_panel
response = { response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked), "refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
+2
View File
@@ -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,
+3
View File
@@ -562,6 +562,8 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
users_by_id = {u.id: u for u in users} users_by_id = {u.id: u for u in users}
for link in links: for link in links:
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id]) links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
from ..image_pipeline import PANEL_LABELS
ctx = shell_context(request, db, admin, active_nav="admin") ctx = shell_context(request, db, admin, active_nav="admin")
ctx.update({ ctx.update({
"users": users, "users": users,
@@ -571,6 +573,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
"notice": notice, "notice": notice,
"error": error, "error": error,
"active_admin_tab": "main", "active_admin_tab": "main",
"panel_labels": PANEL_LABELS,
}) })
return templates.TemplateResponse("admin.html", ctx) return templates.TemplateResponse("admin.html", ctx)
+1
View File
@@ -108,6 +108,7 @@
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }} owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br> &middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }}) firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }} &middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
</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">
+4
View File
@@ -95,6 +95,10 @@
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }} {% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %} {% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p> </p>
<p class="sub" id="firmware-panel">
{% if frame.device_board_variant %}Panel: {{ panel_labels.get(frame.panel_type, frame.panel_type) }}
{% else %}Panel not detected yet -- determined automatically from the frame's board.{% endif %}
</p>
<p class="sub" id="firmware-available"> <p class="sub" id="firmware-available">
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame {% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %} updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
+4 -3
View File
@@ -34,7 +34,7 @@ from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from . import panel_style from . import panel_style
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size from .image_pipeline import EPD_HEIGHT, EPD_WIDTH, _apply_manage_overlay, _quantize, draw_text, logical_render_size
# MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not # MARGIN carries panel_style.CONTENT_MARGIN's value unchanged (not
# re-tuned -- every column-width/icon-size calc below was measured # re-tuned -- every column-width/icon-size calc below was measured
@@ -421,10 +421,11 @@ def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | Non
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None, def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", manage: dict | None = None, units: str = "fahrenheit", manage: dict | None = None,
city_label: str = "", interval_hours: int = 4) -> bytes: city_label: str = "", interval_hours: int = 4,
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> bytes:
"""Same pipeline as calendar_render.render_tasks_preview_png -- a """Same pipeline as calendar_render.render_tasks_preview_png -- a
normal browser-viewable PNG in logical (upright) orientation.""" normal browser-viewable PNG in logical (upright) orientation."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours) img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
img = _apply_manage_overlay(img, manage) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
+2 -2
View File
@@ -21,7 +21,7 @@ from PIL import Image, ImageDraw
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import panel_style from .. import panel_style
from ..image_pipeline import _quantize, draw_text, logical_render_size from ..image_pipeline import _quantize, draw_text, logical_render_size, panel_size
from ..models import BatteryWidgetConfig, Frame, Widget from ..models import BatteryWidgetConfig, Frame, Widget
from ..routers.common import battery_estimate_s from ..routers.common import battery_estimate_s
from ._shared import placeholder_image from ._shared import placeholder_image
@@ -135,7 +135,7 @@ def render_preview_png(db: Session, frame: Frame, widget: Widget, orientation: s
"""A normal browser-viewable PNG at full logical panel size -- same """A normal browser-viewable PNG at full logical panel size -- same
"dialog preview always renders at the frame's full size, not the "dialog preview always renders at the frame's full size, not the
widget's actual grid box" convention as text.py's render_preview_png.""" widget's actual grid box" convention as text.py's render_preview_png."""
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, *panel_size(frame.panel_type))
img = render(db, frame, widget, target_w, target_h) img = render(db, frame, widget, target_w, target_h)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+4 -3
View File
@@ -25,7 +25,7 @@ from PIL import Image, ImageDraw
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import theme_tokens from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size from ..image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text from ..text_content import has_text
from ._shared import placeholder_image from ._shared import placeholder_image
@@ -212,7 +212,8 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i
def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None, def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None,
theme_name: str | None = None) -> bytes: theme_name: str | None = None, panel_w: int = EPD_WIDTH,
panel_h: int = EPD_HEIGHT) -> bytes:
"""A normal browser-viewable PNG at full logical panel size -- """A normal browser-viewable PNG at full logical panel size --
mirrors calendar_render.render_tasks_preview_png's relationship to mirrors calendar_render.render_tasks_preview_png's relationship to
render_tasks (the dialog's own preview endpoint always renders at render_tasks (the dialog's own preview endpoint always renders at
@@ -220,7 +221,7 @@ def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: lis
convention every other widget type's preview endpoint follows).""" convention every other widget type's preview endpoint follows)."""
import io import io
target_w, target_h = logical_render_size(orientation) target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name) img = _render_dispatch(cfg, target_w, target_h, palette_rgb, theme_name)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+80
View File
@@ -0,0 +1,80 @@
"""GET /frame/config auto-deriving Frame.panel_type from the device's
self-reported board (X-Frame-Board header) -- see routers/device.py's
BOARD_PANEL_MAP. Panel type is a property of the hardware, never a user
setting, so this mapping is the only thing that's allowed to change it."""
from __future__ import annotations
from app.models import Frame
from .conftest import claim_device
def test_new_chip_qualified_board_names_map_to_the_right_panel(client, db_session):
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "ee02"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "ee02"
assert frame.panel_type == "epd13in3e"
def test_legacy_bare_board_names_still_map_correctly(client, db_session):
"""Already-flashed devices that haven't been OTA'd past the
devkit/xiao -> devkit_esp32c6/xiao_esp32c6 rename must keep reporting
their old bare name and still get mapped to the right panel -- fielded
firmware can't be retroactively renamed."""
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "xiao"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "xiao"
assert frame.panel_type == "epd7in3e"
def test_renamed_chip_qualified_board_names_map_correctly(client, db_session):
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "devkit_esp32c6"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "devkit_esp32c6"
assert frame.panel_type == "epd7in3e"
def test_unrecognized_board_name_leaves_panel_type_unchanged(client, db_session):
"""An unrecognized board string still gets recorded (same as today's
device_board_variant behavior) but must never blow away whatever
panel_type is already set -- an unknown value is more likely a typo
or a not-yet-supported board than evidence the frame's actual panel
changed."""
frame = db_session.get(Frame, 1)
frame.panel_type = "epd13in3e"
db_session.commit()
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}", headers={"X-Frame-Board": "some_future_board"})
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.device_board_variant == "some_future_board"
assert frame.panel_type == "epd13in3e"
def test_no_board_header_leaves_panel_type_at_its_default(client, db_session):
frame = db_session.get(Frame, 1)
creds = claim_device(db_session, frame)
resp = client.get(f"/frame/config?{creds}")
assert resp.status_code == 200
db_session.refresh(frame)
assert frame.panel_type == "epd7in3e"
+18
View File
@@ -458,6 +458,24 @@ def test_migration_40_adds_font_scale_to_an_existing_database(db_session):
assert widget.font_scale == 1.0 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
@@ -161,3 +161,83 @@ 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