4 Commits
Author SHA1 Message Date
tfaour c0fefc19f1 Fix ee02 button-wakeup build: ext1 fallback for ESP32-S3
Firmware build check / build-check (push) Successful in 2m44s
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown() only exists on
ESP32-C6 (SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP), so the ee02
(ESP32-S3) build failed with implicit-declaration errors in
{back,next,combo}_button.c once the epd13in3e driver's #error stopped
masking it.

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

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

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

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

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

Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real
chip-target change, not just a same-chip Kconfig variant like xiao) and
a new epd13in3e driver component skeleton. The actual panel init/LUT/
refresh register sequence isn't ported from vendor demo code yet (none
was available), so that component deliberately fails to compile
(#error) rather than risk sending unverified register values to real
hardware -- devkit/xiao are unaffected and build identically to before.
CI's ee02 build step is continue-on-error for the same reason.
2026-08-04 20:08:22 +00:00
51 changed files with 1760 additions and 221 deletions
+25 -21
View File
@@ -1,10 +1,11 @@
---
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
Docker -- CI's `firmware-build-check.yml`/`firmware-release-build.yml`
Compiles `firmware/` (ESP-IDF, targeting ESP32-C6 for devkit/xiao and
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
**this sandbox cannot run containers at all**: `docker.io` installs and
`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
the IDF version CI's Docker image pins. Only clones once; re-running
`setup.sh` never touches an existing checkout.
- The esp32c6 toolchain + Python venv, via ESP-IDF's own
`./install.sh esp32c6` -- scoped to just this project's one target
(see `firmware/README.md`'s board table), not every chip ESP-IDF
supports, to keep the download/disk footprint down. `install.sh` is
already idempotent on its own, so `setup.sh` always calls it rather
than duplicating that check -- a re-run costs a few seconds once
everything's cached.
- The esp32c6+esp32s3 toolchains + Python venv, via ESP-IDF's own
`./install.sh esp32c6,esp32s3` -- scoped to just this project's two
chip targets (see `firmware/README.md`'s board table: devkit/xiao are
esp32c6, ee02 is esp32s3), not every chip ESP-IDF supports, to keep
the download/disk footprint down. `install.sh` is already idempotent
on its own, so `setup.sh` always calls it rather than duplicating
that check -- a re-run costs a few seconds once everything's cached.
Takes a few minutes on a cold run (mostly `install.sh`'s own pip/tool
downloads), well under a minute on a re-run. Needs real root (`apt-get
@@ -63,15 +64,17 @@ checkout itself). Confirmed working with as little as ~7GB free.
```bash
bash .claude/skills/build-firmware/build.sh # devkit (default)
bash .claude/skills/build-firmware/build.sh xiao
bash .claude/skills/build-firmware/build.sh both # both variants
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
`firmware/build_for_board.sh`'s own comment) -- building one never
disturbs the other. `build.sh` auto-runs `set-target esp32c6` the very
first time a board is built (no generated sdkconfig yet); later builds
skip straight to `idf.py build`. Extra arguments pass straight through
to `idf.py`, e.g.:
disturbs the other. `build.sh` auto-runs `set-target` (esp32c6 for
devkit/xiao, esp32s3 for ee02) the very first time a board is built (no
generated sdkconfig yet); later builds skip straight to `idf.py build`.
Extra arguments pass straight through to `idf.py`, e.g.:
```bash
bash .claude/skills/build-firmware/build.sh xiao flash -p /dev/ttyUSB0
@@ -83,15 +86,16 @@ somewhere hardware is actually plugged in (a real dev machine, or a
differently-configured environment with device passthrough).
A clean build of one board takes ~30s once the target's already been
configured (~1,000 build steps total split across both boards, most of
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`
and `firmware/components/*` sources are a small fraction of that and
compile in a few seconds). Output lands at
`firmware/build/espresso_frame.bin` (devkit) or
`firmware/build_xiao/espresso_frame.bin` (xiao) -- both paths are
gitignored (`firmware/.gitignore`... actually the repo root
`.gitignore`'s "ESP-IDF firmware build output" section), so nothing
here needs cleaning up before a commit.
`firmware/build/espresso_frame.bin` (devkit),
`firmware/build_xiao/espresso_frame.bin` (xiao), or
`firmware/build_ee02/espresso_frame.bin` (ee02, once its driver actually
compiles -- see the note above) -- all three paths are gitignored (the
repo root `.gitignore`'s "ESP-IDF firmware build output" section), so
nothing here needs cleaning up before a commit.
## Verified
+35 -17
View File
@@ -2,16 +2,25 @@
# Builds (or flashes/monitors, if a serial port is actually attached)
# the espresso_frame firmware for one board variant, via the project's
# own firmware/build_for_board.sh -- this script just sources the
# ESP-IDF environment first and auto-runs `set-target esp32c6` on a
# board's very first build (a fresh clone has no generated sdkconfig
# yet, same reasoning as CI's own build steps -- see firmware/README.md's
# "Building for the Seeed XIAO ESP32-C6" section).
# ESP-IDF environment first and auto-runs `set-target` (esp32c6 for
# devkit/xiao, esp32s3 for ee02) on a board's very first build (a fresh
# clone has no generated sdkconfig yet, same reasoning as CI's own build
# steps -- see firmware/README.md's "Building for the Seeed XIAO
# ESP32-C6" section).
#
# 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:
# build.sh # build devkit (default)
# build.sh devkit
# 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
set -euo pipefail
@@ -33,16 +42,17 @@ cd "$firmware_dir"
build_one() {
local board="$1"
shift
local sdkconfig
local sdkconfig target
case "$board" in
devkit) sdkconfig="sdkconfig" ;;
xiao) sdkconfig="sdkconfig.xiao_local" ;;
*) echo "Unknown board '$board' -- expected 'devkit' or 'xiao'" >&2; exit 1 ;;
devkit) sdkconfig="sdkconfig"; target="esp32c6" ;;
xiao) sdkconfig="sdkconfig.xiao_local"; target="esp32c6" ;;
ee02) sdkconfig="sdkconfig.ee02_local"; target="esp32s3" ;;
*) echo "Unknown board '$board' -- expected 'devkit', 'xiao', or 'ee02'" >&2; exit 1 ;;
esac
if [ ! -f "$sdkconfig" ]; then
echo "==> $board: no generated sdkconfig yet, setting target esp32c6"
./build_for_board.sh "$board" set-target esp32c6
echo "==> $board: no generated sdkconfig yet, setting target $target"
./build_for_board.sh "$board" set-target "$target"
fi
local args=("$@")
@@ -55,9 +65,17 @@ build_one() {
board="${1:-devkit}"
shift || true
if [ "$board" = "both" ]; then
build_one devkit "$@"
build_one xiao "$@"
else
build_one "$board" "$@"
fi
case "$board" in
both)
build_one devkit "$@"
build_one xiao "$@"
;;
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"
fi
# 3. Toolchain + Python virtualenv, scoped to esp32c6 only -- this
# project's one target (see firmware/README.md's board table). Scoping
# avoids downloading toolchains for every chip ESP-IDF supports, which
# matters given this container's disk headroom. install.sh is already
# 3. Toolchain + Python virtualenv, scoped to esp32c6+esp32s3 only --
# this project's two chip targets (see firmware/README.md's board
# table: devkit/xiao are esp32c6, ee02 is esp32s3). Scoping avoids
# downloading toolchains for every chip ESP-IDF supports, which matters
# given this container's disk headroom. install.sh is already
# idempotent on its own (checks what's present and skips it), so this
# always calls it rather than trying to duplicate that check here --
# a re-run only costs a few seconds once everything's cached.
echo "running esp-idf install.sh esp32c6 (fast if already installed) ..."
(cd "$IDF_DIR" && ./install.sh esp32c6)
echo "running esp-idf install.sh esp32c6,esp32s3 (fast if already installed) ..."
(cd "$IDF_DIR" && ./install.sh esp32c6,esp32s3)
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
# firmware-release-build.yml (which only builds+publishes when
# firmware/version.txt itself is bumped -- the "cut a release" signal).
# This just verifies both board variants still compile; nothing else in
# CI catches a firmware/** push that breaks the build until someone
# happens to bump the version next.
# This just verifies every board variant still compiles (or, for ee02,
# that everything up to its known/tracked #error still compiles --
# 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:
push:
branches: [main]
@@ -49,3 +51,26 @@ jobs:
docker cp "$PWD/." "$cid:/workspace"
docker start -a "$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
# the job specifies, so checkout fails immediately with "node: not
# 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
# 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-
@@ -33,11 +33,13 @@ jobs:
id: version
run: echo "version=$(tr -d '[:space:]' < firmware/version.txt)" >> "$GITHUB_OUTPUT"
# Two board variants, two partition tables/flash sizes (see
# firmware/README.md's "Building for the Seeed XIAO ESP32-C6"
# section) -- 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
# Three board variants: devkit/xiao (ESP32-C6, different partition
# tables/flash sizes -- see firmware/README.md's "Building for the
# Seeed XIAO ESP32-C6" section) and ee02 (ESP32-S3 + 13.3" panel,
# a genuinely different chip target, not just a Kconfig variant).
# 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).
# safe.directory guards against git's "dubious ownership" check,
# since the container runs as root over content owned by a
@@ -64,8 +66,15 @@ jobs:
')
docker cp "$PWD/." "$cid:/workspace"
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"
# 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)
run: |
@@ -77,7 +86,35 @@ jobs:
')
docker cp "$PWD/." "$cid:/workspace"
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"
# 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", [])}
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-xiao.bin", "/tmp/release-assets/firmware-xiao.bin"),
]
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:
del_status, _ = req("DELETE", f"/releases/{release_id}/assets/{existing_assets[name]}")
print(f"Removed existing asset {name} (status {del_status})")
+3
View File
@@ -10,6 +10,9 @@ firmware/dependencies.lock
firmware/build_xiao/
firmware/sdkconfig.xiao_local
firmware/sdkconfig.xiao_local.old
firmware/build_ee02/
firmware/sdkconfig.ee02_local
firmware/sdkconfig.ee02_local.old
# Python server
server/__pycache__/
+18 -8
View File
@@ -1,9 +1,12 @@
# ESPresso Frame
A DIY e-ink photo frame: an ESP32-C6 pulls photos from your
[Immich](https://immich.app) library and displays them on a 7.3" full-color
A DIY e-ink photo frame: an ESP32 board pulls photos from your
[Immich](https://immich.app) library and displays them on a full-color
e-paper panel, waking on a timer to refresh and spending the rest of its
time in deep sleep.
time in deep sleep. The original build is a 7.3" panel on an ESP32-C6;
a larger 13.3" panel on Seeed's EE02 (ESP32-S3) is supported
server-side, but its firmware driver isn't working yet -- see
[`docs/hardware.md`](docs/hardware.md).
- **No cables to a computer, no SD card shuffling.** Provisioning is a
captive portal with a QR code drawn on the panel itself -- scan, join,
@@ -12,7 +15,9 @@ time in deep sleep.
all the work (pulling from Immich, cropping, dithering, packing into
the panel's exact pixel format) and hands the device a stream it can
write straight to SPI. The ESP32-C6 has no PSRAM and not much SRAM to
spare -- keeping it a dumb display client is what makes that workable.
spare -- keeping it a dumb display client is what makes that workable
(the same design carries over to the ESP32-S3 board even though it
does have PSRAM, for consistency).
- **Crops toward faces, not just the center**, using face bounding boxes
Immich already computed for its own People feature -- no bundled face
detector.
@@ -21,8 +26,13 @@ time in deep sleep.
## Hardware
- ESP32-C6 dev board (8MB flash)
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI
- ESP32-C6 dev board (8MB flash), or Seeed's XIAO ESP32-C6 (production
board) -- both drive the panel below.
- [Waveshare 7.3" E Ink Spectra 6 (E6)](https://www.waveshare.com/7.3inch-e-paper-hat-e.htm) panel -- 800x480, 6-color, SPI.
- Experimental, not yet working: [Waveshare 13.3" E Ink Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm)
(1600x1200) on [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html)
(ESP32-S3) -- server-side support exists, but the firmware driver's
panel init sequence isn't ported from vendor code yet.
See [`docs/hardware.md`](docs/hardware.md) for wiring,
[`docs/architecture.md`](docs/architecture.md) for how the two halves talk
@@ -34,14 +44,14 @@ placeable photos/calendar/whiteboard widget system.
1. **[`server/`](server/)** -- run the FastAPI server first (Docker
Compose, points at your Immich instance). See
[`server/README.md`](server/README.md).
2. **[`firmware/`](firmware/)** -- build and flash the ESP32-C6, then
2. **[`firmware/`](firmware/)** -- build and flash the board, then
scan the QR codes it draws on first boot to provision it. See
[`firmware/README.md`](firmware/README.md).
## Repo layout
```
firmware/ ESP-IDF project for the ESP32-C6
firmware/ ESP-IDF project (ESP32-C6 devkit/xiao boards, ESP32-S3 ee02)
server/ FastAPI server: Immich -> crop/dither/pack -> the frame
docs/ Wiring and architecture notes
```
+11 -7
View File
@@ -3,14 +3,15 @@
Two independent pieces talk over HTTP or HTTPS (the server itself always
speaks plain HTTP; HTTPS means a reverse proxy in front of it, see
[`firmware/README.md`](../firmware/README.md#http-vs-https)) on the local
network: the ESP32-C6 firmware, and a small FastAPI server that sits
between it and Immich.
network: the ESP32 firmware (ESP32-C6 for the devkit/xiao boards,
ESP32-S3 for ee02 -- see [`docs/hardware.md`](hardware.md)), and a small
FastAPI server that sits between it and Immich.
```mermaid
sequenceDiagram
participant Immich
participant Server as ESPresso Frame Server
participant Frame as ESP32-C6 Frame
participant Frame as ESP32 Frame
Note over Frame: First boot / never provisioned
Frame->>Frame: Generate AP SSID/password, draw QR + config QR on panel
@@ -33,7 +34,7 @@ sequenceDiagram
Server->>Immich: List album assets / download preview / faces<br/>(once per photo widget on the panel)
Immich-->>Server: JPEG + face bounding boxes
Server->>Server: Composite every widget's region onto one canvas,<br/>then enhance/overlay/quantize (dither)/pack 4bpp once
Server-->>Frame: 192,000 raw bytes, streamed
Server-->>Frame: packed 4bpp bytes, streamed<br/>(192,000 for the 7.3" panel; sized to whichever<br/>panel this frame's device reports, see Frame.panel_type)
Frame->>Frame: Write to panel SPI buffer, compute CRC32
alt CRC unchanged since last physical refresh
Frame->>Frame: Skip refresh (nothing visually changed)
@@ -83,8 +84,9 @@ placement grid, and button-action dispatch.
once).
- Fetch the frame and write it into the panel's SPI buffer
(`epd_write_frame()`), computing a CRC32 as it streams -- never
buffering the full ~192KB frame in RAM. The panel driver refuses to
write a short/wrong-size response into the buffer at all, so a
buffering the full packed frame in RAM (~192KB for the 7.3" panel;
proportionally more for the 13.3" panel). The panel driver refuses
to write a short/wrong-size response into the buffer at all, so a
truncated fetch can't corrupt what's already there.
- Compare the new CRC32 against the last one that was actually
refreshed onto the panel (persisted in NVS). If it matches -- the
@@ -117,7 +119,9 @@ git history). Decoding a JPEG, then resizing/dithering/quantizing it to
the panel's 6-color palette, would be expensive on-device in both memory
and battery. Instead, the server does all of that with Pillow and hands
the frame a pre-packed, ready-to-stream buffer -- the device never
decodes an image at all.
decodes an image at all. The ee02 board's ESP32-S3 does have PSRAM, but
the same server-side design applies there too, for consistency and
battery reasons rather than because the C6's memory limit forces it.
## Why face detection isn't run on-device (or even on the server)
+120
View File
@@ -152,3 +152,123 @@ photo. A full-color refresh on this panel takes 15-30+ seconds and draws
more current than deep sleep by a wide margin -- expect battery life (if
not running from USB power) to be dominated by refresh frequency, not
sleep current.
## Board identifiers
Each board reports a name to the server (`X-Frame-Board`,
`CONFIG_FRAME_BOARD_NAME`) that's chip-qualified rather than the plain
`devkit`/`xiao` older firmware used -- `devkit_esp32c6`, `xiao_esp32c6`,
`ee02` (see below). This changed once a second XIAO-based board (EE02,
an ESP32-S3) existed and "xiao" alone stopped disambiguating hardware.
The server keeps accepting the old bare names indefinitely, since
already-flashed devices can't be retroactively renamed.
## 13.3" Spectra 6 panel on Seeed's EE02 board (panel driver ported, `ee02` builds end-to-end; unverified on real hardware)
A second panel size is supported server-side (the web UI shows a
read-only "Panel: 13.3\" Spectra 6" once a frame's device reports
itself as `ee02`), and **the panel driver itself is now real and
compiles clean** -- `firmware/components/epd13in3e`'s init/LUT/refresh
register sequence is a line-for-line port of Waveshare's own reference
drivers for this exact panel+controller, confirmed identically across
three independent vendor sources (Waveshare's RaspberryPi/c and ESP32
drivers for this panel, plus Waveshare's own ESP-IDF example for their
ESP32-S3-ePaper-13.3E6 driver board -- a different carrier than EE02,
but the same panel/controller, hence the same command bytes). See that
component's own top comment for details, and
`server/app/image_pipeline.py`'s `PANEL_WIRE_TRANSPOSE` for a load-bearing
correction that came with it: the panel's SPI wire raster is a *native
1200x1600 (portrait)* raster, rotated 90 degrees from the panel's
1600x1200 landscape mount/marketing size -- getting that backwards
doesn't just rotate the image, it shreds it (1600x1200 and 1200x1600
don't share a row stride).
**A full `ee02` build now succeeds** (verified locally with a native,
non-Docker ESP-IDF v6.0 install -- see
`.claude/skills/build-firmware/SKILL.md`); `firmware/main/{back,next,combo}_button.c`
used to call `esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown()`, an
ESP32-C6-only deep-sleep GPIO-wakeup API (gated by
`SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`, which ESP32-S3's
`soc_caps.h` doesn't define) with no ESP32-S3 fallback path. Each of the
three button files now branches on that same capability macro: the
ESP32-C6 path (devkit/xiao) is untouched, and a new ESP32-S3 path uses
`esp_sleep_enable_ext1_wakeup_io()` (not the non-`_io()`
`esp_sleep_enable_ext1_wakeup()`, which resets any previously-registered
mask -- the `_io()` variant is additive, confirmed by reading
`esp_hw_support/sleep_modes.c`, so the three button files can each keep
registering their own GPIO independently, no combined-mask coordination
needed) plus `esp_sleep_get_ext1_wakeup_status()` for the wake-cause
check. The original C6 EXT1 attempt was rejected on hardware because
its pull resistor didn't hold across the RTC_PERIPH power-down (see
`firmware/main/next_button.c`'s `next_button_init()` comment) -- tracing
the same code path for ESP32-S3 shows `gpio_config()`'s `pull_up_en`
(already used by all three button files) delegates to
`rtc_gpio_pullup_en()` for RTC-capable pins on every non-original-ESP32
target (confirmed in `esp_driver_gpio/gpio.c`: `GPIO_RTCIO_ARE_INDEPENDENT`
is 1 for both C6 and S3, meaning the digital and RTC pull registers are
independent hardware and `gpio_config()` already sets the RTC one), so
the pull-up should already survive the same power-down on ESP32-S3
without any extra `rtc_gpio_*` calls. That reasoning is verified against
IDF source, **not against real EE02 hardware** -- a clean compile
confirms the code builds and links, not that it's actually
spurious-wakeup-free on a real board. CI's
`firmware-build-check.yml`/`firmware-release-build.yml`
`continue-on-error` on this board's step is intentionally still in place
until that hardware verification happens.
Confirmed so far:
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
1600x1200 mount size, 270.40x202.80mm, same 6-ink Spectra family as
the 7.3" panel (and, now vendor-confirmed, the identical 4-bit nibble
color codes). Full refresh ~19s. SPI wire raster is 1200x1600 (see
above).
- Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) --
a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated
driver PCB, one reset + three user buttons, JST 2.0mm battery
connector with built-in charging IC.
- Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic --
treat as a starting point, confirm before relying on it; Waveshare's
own ESP32-S3-ePaper-13.3E6 example uses different GPIO numbers, but
that's for Waveshare's own driver board, a different carrier than
EE02, so it doesn't apply here). Unlike epd7in3e's single chip-select,
this panel is driven as two halves sharing one CLK/MOSI/DC/RST/BUSY bus
with independent chip-selects -- now confirmed by the real driver code
too (master = left half, slave = right half of each row).
| Signal | GPIO | Kconfig option |
| --- | --- | --- |
| CLK | 7 | `EPD_PIN_CLK` |
| MOSI | 9 | `EPD_PIN_MOSI` |
| CS (master half) | 44 | `EPD_PIN_CS_MASTER` |
| CS (slave half) | 41 | `EPD_PIN_CS_SLAVE` |
| DC | 10 | `EPD_PIN_DC` |
| RST | 38 | `EPD_PIN_RST` |
| BUSY | 4 | `EPD_PIN_BUSY` |
| Panel power-enable | 43 | `EPD_PIN_POWER_EN` |
User buttons are reportedly at GPIO 2/3/5, but which physical button
maps to which logical role (next/back/menu) still isn't confirmed. The
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
`firmware/main/Kconfig.projbuild`) now range to GPIO -1 to 21 under
`IDF_TARGET_ESP32S3` (the ESP32-S3's own ext1-wakeup-capable RTC-IO
range) instead of the ESP32-C6-shaped -1 to 7, so GPIO 2/3/5 fit
regardless -- but `firmware/sdkconfig.ee02` still deliberately doesn't
override the defaults inherited from the C6 boards (GPIO 2/0/1) until
the role mapping above is confirmed.
SPI clock is reportedly reliable only up to 2MHz on this
panel/board per the community ESPHome integration (vs. epd7in3e's 4MHz
default) -- see `firmware/sdkconfig.ee02`. Waveshare's own
ESP32-S3-ePaper-13.3E6 example defaults to 10MHz, but that's a
different carrier board, so it's a data point to try once real EE02
hardware exists, not a reason to bump the current conservative default
blind.
Remaining unknowns before trusting this on real hardware: whether the
ESP32-S3 button-wakeup path above actually avoids a spurious-instant-wakeup
on a real board (not just compiles), the button-to-role mapping, the
wiring table (community-sourced, not official), and
`PANEL_WIRE_TRANSPOSE`'s rotation *direction* (`ROTATE_90` vs
`ROTATE_270` -- a physical-assembly fact no vendor driver encodes, see
that dict's own comment in `image_pipeline.py`).
+7 -1
View File
@@ -160,7 +160,13 @@ Calendar widgets pick from discrete size tiers (`calendar_render.py`'s
`_SIZE_TIERS`) for font size/margins/row heights based on their actual
grid footprint, rather than continuously scaling constants tuned for a
full ~800x480 canvas -- falls back to agenda view if a widget is too small
for month view to stay legible.
for month view to stay legible. These tiers are pixel-size constants
tuned against the 7.3" panel specifically; they aren't re-tuned or
verified yet for the 13.3" panel's larger native resolution (see
`docs/hardware.md`'s EE02 section) -- a widget's *grid footprint* (cell
count) works the same on either panel, but its rendered legibility at
that footprint's actual pixel size hasn't been checked on the bigger
panel.
### "Modern" render style (experimental)
+39 -1
View File
@@ -1,6 +1,10 @@
# ESPresso Frame Firmware
ESP-IDF firmware for the ESP32-C6. On first boot it provisions itself over
ESP-IDF firmware for the ESP32-C6 (devkit/xiao boards, 7.3" panel) or
ESP32-S3 (ee02 board, 13.3" panel -- see
[Building for Seeed's EE02](#building-for-seeeds-ee02-esp32-s3--133-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
below; it builds end-to-end now, but is still unverified on real EE02
hardware). On first boot it provisions itself over
a WiFi captive portal; after that it wakes on a timer, fetches an
already-processed frame from the [server](../server/), streams it straight
to the panel over SPI, and goes back to deep sleep.
@@ -48,6 +52,40 @@ never clobbers the other:
(`./build_for_board.sh devkit ...` does the same for the dev board --
equivalent to a plain `idf.py`, just consistent with the XIAO invocation.)
### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver ported; `ee02` builds end-to-end, unverified on real hardware)
EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target
esp32s3` instead of `esp32c6`, and its own partition table/flash-size
Kconfig sized for its 16MB flash
([`partitions_ee02.csv`](partitions_ee02.csv)):
```
./build_for_board.sh ee02 set-target esp32s3
./build_for_board.sh ee02 build
```
**This now compiles and links clean end-to-end**, verified locally with
a native, non-Docker ESP-IDF v6.0 install (see
`.claude/skills/build-firmware/SKILL.md`).
`firmware/components/epd13in3e`'s panel init/LUT/refresh register
sequence is a real, vendor-confirmed port (see that component's own top
comment and
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-panel-driver-ported-ee02-builds-end-to-end-unverified-on-real-hardware)
for the vendor sources and the load-bearing native-raster-orientation
correction that came with it). `main/{back,next,combo}_button.c` used to
call an ESP32-C6-only deep-sleep GPIO-wakeup API with no ESP32-S3
fallback; each now branches on `SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP`
to keep the ESP32-C6 path (devkit/xiao) untouched while using
`esp_sleep_enable_ext1_wakeup_io()` on ESP32-S3 -- see `docs/hardware.md`'s
same section for why the additive `_io()` variant needs no combined-mask
coordination across the three button files, and why the pull-resistor
concern that ruled out EXT1 wakeup on ESP32-C6 doesn't apply the same
way here. That reasoning is confirmed against ESP-IDF source, **not
against real EE02 hardware** -- CI's
(`.gitea/workflows/firmware-build-check.yml`/`firmware-release-build.yml`)
`continue-on-error` on this board's step is intentionally still in place
until it is.
## Configuration (`idf.py menuconfig`)
Under **ESPresso Frame Configuration**:
+33 -14
View File
@@ -1,28 +1,42 @@
#!/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
# also the plain `idf.py` default (sdkconfig/build/), so this
# 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.
# 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 dev board's two 2MB OTA app slots -- see partitions_xiao.csv,
# 1.875MB slots instead) and a different flash-size Kconfig. Rather
# than hand-editing the shared sdkconfig back and forth (fragile, easy
# to leave it in the wrong state for whichever board you flash next),
# each board gets its own build directory and its own generated
# sdkconfig, seeded from sdkconfig.defaults (shared) with the board's
# override file layered on top via ESP-IDF's own SDKCONFIG_DEFAULTS
# mechanism. Switching boards is just switching which one you invoke --
# neither ever touches the other's config or build output.
# The three need different partition tables (each flash size needs its
# own OTA app-slot sizing -- see partitions_xiao.csv/partitions_ee02.csv)
# and different flash-size Kconfig. Rather than hand-editing the shared
# sdkconfig back and forth (fragile, easy to leave it in the wrong state
# for whichever board you flash next), each board gets its own build
# directory and its own generated sdkconfig, seeded from
# sdkconfig.defaults (shared) with the board's override file layered on
# top via ESP-IDF's own SDKCONFIG_DEFAULTS mechanism. Switching boards is
# just switching which one you invoke -- none ever touches another's
# config or build output.
#
# Usage:
# ./build_for_board.sh xiao build
# ./build_for_board.sh xiao flash -p /dev/ttyUSB0
# ./build_for_board.sh xiao flash monitor -p /dev/ttyUSB0
# ./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.
@@ -32,7 +46,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$script_dir"
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
fi
board="$1"
@@ -49,8 +63,13 @@ case "$board" in
sdkconfig_path="$script_dir/sdkconfig"
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
;;
esac
@@ -0,0 +1,13 @@
# SRCS is conditional on which board's panel this build targets -- see
# epd7in3e/CMakeLists.txt's identical comment (the two components mirror
# each other: exactly one contributes actual object files/symbols to any
# given build, the other is required but empty).
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "epd13in3e.c")
else()
set(srcs "")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
+65
View File
@@ -0,0 +1,65 @@
menu "E-Paper Display (epd13in3e) Configuration"
config EPD_PIN_CLK
int "SPI CLK (SCLK) GPIO"
default 7
help
Defaults sourced from a community-verified ESPHome
integration for this exact board
(github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
official Waveshare/Seeed reference driver (see
firmware/components/epd13in3e/epd13in3e.c's top comment,
which is about the still-unknown panel init/LUT/refresh
register sequence, a separate and larger unknown than this
pinout). Override if your own board wiring differs.
config EPD_PIN_MOSI
int "SPI MOSI (DIN) GPIO"
default 9
config EPD_PIN_CS_MASTER
int "SPI CS (master half) GPIO"
default 44
help
Unlike epd7in3e's single-CS interface, this panel is driven
as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
two independent chip-selects (master/slave) -- confirmed by
the same community ESPHome integration, not yet by this
component's own driver code (still unimplemented, see
epd13in3e.c).
config EPD_PIN_CS_SLAVE
int "SPI CS (slave half) GPIO"
default 41
config EPD_PIN_DC
int "Data/Command GPIO"
default 10
config EPD_PIN_RST
int "Reset GPIO"
default 38
config EPD_PIN_BUSY
int "Busy GPIO"
default 4
config EPD_PIN_POWER_EN
int "Panel power-enable GPIO"
default 43
help
No equivalent pin on epd7in3e's board -- the EE02 apparently
gates the panel's own power rail separately from the ESP32-S3
module's. Source: same community integration as the other
pins above.
config EPD_SPI_CLOCK_HZ
int "SPI clock speed (Hz)"
default 2000000
help
2MHz, not epd7in3e's 4MHz default -- the same community
integration notes higher rates were unreliable on this
panel/board combo. Revisit once wiring is confirmed on real
hardware.
endmenu
+438
View File
@@ -0,0 +1,438 @@
#include <string.h>
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_rom_crc.h"
#include "epd13in3e.h"
/* Command bytes/register values below are a line-for-line transcription of
* Waveshare's official reference drivers for this exact panel+controller --
* confirmed identical across three independent sources (RaspberryPi/c,
* ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example; see this
* component's header for repo paths). Same "don't clean these up" rule as
* epd7in3e.c: this class of panel controller has no public datasheet, so
* the vendor driver is the source of truth for every byte.
*
* Unlike epd7in3e's single chip-select, this panel is driven as two
* independent controllers sharing one CLK/MOSI/DC/RST/BUSY bus but with
* separate chip-selects (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE) -- most init
* commands broadcast to both (CS_ALL), a handful of power/boost commands
* go only to the master (which owns the shared analog rails), and actual
* frame data is split per-row into a left half (master) and right half
* (slave), 300 bytes each out of each 600-byte row. That split is why
* epd_write_frame below buffers the whole frame in PSRAM before sending
* anything (every row needs slicing in half before either half can go out),
* unlike epd7in3e.c's straight single-CS passthrough streaming.
*
* Pin numbers themselves are NOT from this vendor code -- Waveshare's
* ESP32-S3-ePaper-13.3E6 example targets Waveshare's own driver board, a
* different carrier than Seeed's EE02 this project actually uses, so its
* GPIO numbers don't apply here. EE02's pins remain sourced from a
* community-verified ESPHome integration (see this component's Kconfig),
* not an official reference. */
#define EPD_SPI_HOST SPI2_HOST
#define EPD_SPI_CHUNK_SIZE 4096
static const char *TAG = "epd13in3e";
#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
/* --- panel command opcodes --- */
#define PSR 0x00
#define PWR 0x01
#define POF 0x02
#define PON 0x04
#define BTST_N 0x05
#define BTST_P 0x06
#define DTM 0x10 /* data transfer (frame data) */
#define DRF 0x12 /* display refresh */
#define CDI 0x50
#define TCON 0x60
#define TRES 0x61
#define AN_TM 0x74
#define AGID 0x86
#define BUCK_BOOST_VDDN 0xB0
#define TFT_VCOM_POWER 0xB1
#define EN_BUF 0xB6
#define BOOST_VDDP_EN 0xB7
#define CCSET 0xE0
#define PWS 0xE3
#define CMD66 0xF0
#define DEEP_SLEEP 0x07
/* --- canned init parameter blobs (do NOT edit -- see top comment) --- */
static const uint8_t PSR_V[] = {0xDF, 0x69};
static const uint8_t PWR_V[] = {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38};
static const uint8_t POF_V[] = {0x00};
static const uint8_t DRF_V[] = {0x00};
static const uint8_t CDI_V[] = {0xF7};
static const uint8_t TCON_V[] = {0x03, 0x03};
static const uint8_t TRES_V[] = {0x04, 0xB0, 0x03, 0x20};
static const uint8_t CMD66_V[] = {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10};
static const uint8_t EN_BUF_V[] = {0x07};
static const uint8_t CCSET_V[] = {0x01};
static const uint8_t PWS_V[] = {0x22};
static const uint8_t AN_TM_V[] = {0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55};
static const uint8_t AGID_V[] = {0x10};
static const uint8_t BTST_P_V[] = {0xE8, 0x28};
static const uint8_t BOOST_VDDP_EN_V[] = {0x01};
static const uint8_t BTST_N_V[] = {0xE8, 0x28};
static const uint8_t BUCK_BOOST_VDDN_V[] = {0x01};
static const uint8_t TFT_VCOM_POWER_V[] = {0x02};
static spi_device_handle_t s_spi;
static void epd_delay_ms(uint32_t ms)
{
vTaskDelay(pdMS_TO_TICKS(ms));
}
/* BUSY: LOW = busy, HIGH = idle -- same polarity/poll-interval reasoning
* as epd7in3e.c's identical comment (a tight 1ms-rounds-to-0-ticks poll
* starves the idle task badly enough to trip the watchdog). */
static void epd_wait_busy(void)
{
while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
epd_delay_ms(20);
}
}
static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
{
while (len > 0) {
size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
spi_transaction_t t = {
.length = n * 8,
.tx_buffer = data,
};
EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
data += n;
len -= n;
}
return ESP_OK;
}
/* Unlike epd7in3e.c's send_command/send_data, these do NOT touch CS --
* this panel's two independent chip-selects (and the "broadcast to both"
* vs "master only" split the init sequence needs) mean CS bracketing has
* to be the caller's decision, not baked into the byte-send primitive. */
static esp_err_t epd_send_command(uint8_t cmd)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
return epd_spi_write(&cmd, 1);
}
static esp_err_t epd_send_data(const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
return epd_spi_write(data, len);
}
static esp_err_t epd_send_data_byte(uint8_t data)
{
return epd_send_data(&data, 1);
}
static void epd_cs_both(int level)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, level);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, level);
}
/* Sends `cmd` + its data blob to both controllers at once (most of the
* init sequence -- shared display-timing/power registers). */
static esp_err_t epd_cmd_both(uint8_t cmd, const uint8_t *data, size_t len)
{
epd_cs_both(0);
esp_err_t err = epd_send_command(cmd);
if (err == ESP_OK && data != NULL) {
err = epd_send_data(data, len);
}
epd_cs_both(1);
return err;
}
/* Sends `cmd` + its data blob to the master controller only -- the boost/
* VCOM power registers the master alone owns. */
static esp_err_t epd_cmd_master(uint8_t cmd, const uint8_t *data, size_t len)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_send_command(cmd);
if (err == ESP_OK && data != NULL) {
err = epd_send_data(data, len);
}
epd_cs_both(1);
return err;
}
/* 5-edge reset sequence (30ms each) -- per-vendor-source exact, more edges
* than epd7in3e.c's 3-edge/20ms reset. */
static void epd_reset(void)
{
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
epd_delay_ms(30);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_delay_ms(30);
}
/* Power on, refresh, power off -- mirrors EPD_TurnOnDisplay()/
* EPD_13IN3E_TurnOnDisplay() in the reference drivers. */
esp_err_t epd_turn_on_display(void)
{
EPD_CHECK(epd_cmd_both(PON, NULL, 0));
epd_wait_busy();
epd_delay_ms(50);
EPD_CHECK(epd_cmd_both(DRF, DRF_V, sizeof(DRF_V)));
epd_wait_busy();
epd_delay_ms(50);
EPD_CHECK(epd_cmd_both(POF, POF_V, sizeof(POF_V)));
/* No busy-wait after POF -- matches every reference driver. */
return ESP_OK;
}
esp_err_t epd_init(void)
{
gpio_config_t out_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
(1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
(1ULL << CONFIG_EPD_PIN_POWER_EN),
.mode = GPIO_MODE_OUTPUT,
};
EPD_CHECK(gpio_config(&out_cfg));
gpio_config_t busy_cfg = {
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
.mode = GPIO_MODE_INPUT,
};
EPD_CHECK(gpio_config(&busy_cfg));
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
epd_cs_both(1);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
spi_bus_config_t bus_cfg = {
.mosi_io_num = CONFIG_EPD_PIN_MOSI,
.miso_io_num = -1,
.sclk_io_num = CONFIG_EPD_PIN_CLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = EPD_SPI_CHUNK_SIZE,
};
EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
.mode = 0,
.spics_io_num = -1, /* both chip-selects are bit-banged by hand above */
.queue_size = 1,
};
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
/* Panel power-enable rail (no equivalent on epd7in3e's board -- EE02
* gates it separately from the ESP32-S3 module's own supply). */
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 1);
epd_delay_ms(10);
epd_reset();
epd_wait_busy();
/* Master-only: shared analog-timing register. */
EPD_CHECK(epd_cmd_master(AN_TM, AN_TM_V, sizeof(AN_TM_V)));
/* Broadcast: display-timing/power-sequencing registers both
* controllers need identically. */
EPD_CHECK(epd_cmd_both(CMD66, CMD66_V, sizeof(CMD66_V)));
EPD_CHECK(epd_cmd_both(PSR, PSR_V, sizeof(PSR_V)));
EPD_CHECK(epd_cmd_both(CDI, CDI_V, sizeof(CDI_V)));
EPD_CHECK(epd_cmd_both(TCON, TCON_V, sizeof(TCON_V)));
EPD_CHECK(epd_cmd_both(AGID, AGID_V, sizeof(AGID_V)));
EPD_CHECK(epd_cmd_both(PWS, PWS_V, sizeof(PWS_V)));
EPD_CHECK(epd_cmd_both(CCSET, CCSET_V, sizeof(CCSET_V)));
EPD_CHECK(epd_cmd_both(TRES, TRES_V, sizeof(TRES_V)));
/* Master-only: boost/VCOM power programming. */
EPD_CHECK(epd_cmd_master(PWR, PWR_V, sizeof(PWR_V)));
EPD_CHECK(epd_cmd_master(EN_BUF, EN_BUF_V, sizeof(EN_BUF_V)));
EPD_CHECK(epd_cmd_master(BTST_P, BTST_P_V, sizeof(BTST_P_V)));
EPD_CHECK(epd_cmd_master(BOOST_VDDP_EN, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V)));
EPD_CHECK(epd_cmd_master(BTST_N, BTST_N_V, sizeof(BTST_N_V)));
EPD_CHECK(epd_cmd_master(BUCK_BOOST_VDDN, BUCK_BOOST_VDDN_V, sizeof(BUCK_BOOST_VDDN_V)));
EPD_CHECK(epd_cmd_master(TFT_VCOM_POWER, TFT_VCOM_POWER_V, sizeof(TFT_VCOM_POWER_V)));
ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS_M=%d CS_S=%d DC=%d RST=%d BUSY=%d PWR_EN=%d)",
CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS_MASTER,
CONFIG_EPD_PIN_CS_SLAVE, CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST,
CONFIG_EPD_PIN_BUSY, CONFIG_EPD_PIN_POWER_EN);
return ESP_OK;
}
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
{
ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
/* Every row has to be sliced into a left (master) and right (slave)
* half before either half can go out over SPI, so -- unlike
* epd7in3e.c's single-CS passthrough -- bytes can't be forwarded to
* the wire as they arrive. Buffer the whole ~938KB frame in PSRAM
* first (EE02's XIAO ESP32-S3 Plus has 8MB of it). */
uint8_t *frame = heap_caps_malloc(EPD_FRAME_BYTES, MALLOC_CAP_SPIRAM);
if (frame == NULL) {
ESP_LOGE(TAG, "OOM allocating %u-byte frame buffer", (unsigned)EPD_FRAME_BYTES);
return ESP_ERR_NO_MEM;
}
size_t total = 0;
uint32_t crc = 0;
size_t n;
while (total < EPD_FRAME_BYTES &&
(n = read_fn(frame + total, EPD_FRAME_BYTES - total, ctx)) > 0) {
crc = esp_rom_crc32_le(crc, frame + total, n);
total += n;
}
if (total != EPD_FRAME_BYTES) {
/* Same invariant as epd7in3e.c: never touch the panel on a
* short/wrong-size stream -- the visible screen is left exactly
* as it was. */
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
free(frame);
return ESP_ERR_INVALID_SIZE;
}
/* De-interleave into one half-buffer at a time and DMA it out as a
* single contiguous transfer (chunked internally by epd_spi_write) --
* far fewer, far larger SPI transactions than sending 1600 separate
* 300-byte rows per side. */
const size_t HALF_ROW = EPD_BYTES_PER_ROW / 2; /* 300 */
const size_t HALF_BUF = HALF_ROW * EPD_HEIGHT; /* 480000 */
uint8_t *half = heap_caps_malloc(HALF_BUF, MALLOC_CAP_SPIRAM);
if (half == NULL) {
ESP_LOGE(TAG, "OOM allocating %u-byte half-frame scratch buffer", (unsigned)HALF_BUF);
free(frame);
return ESP_ERR_NO_MEM;
}
for (size_t r = 0; r < EPD_HEIGHT; r++) {
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW, HALF_ROW);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
esp_err_t err = epd_send_command(DTM);
if (err == ESP_OK) {
err = epd_send_data(half, HALF_BUF);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
if (err == ESP_OK) {
for (size_t r = 0; r < EPD_HEIGHT; r++) {
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW + HALF_ROW, HALF_ROW);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 0);
err = epd_send_command(DTM);
if (err == ESP_OK) {
err = epd_send_data(half, HALF_BUF);
}
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
}
free(half);
free(frame);
EPD_CHECK(err);
if (out_crc32 != NULL) {
*out_crc32 = crc;
}
return ESP_OK;
}
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
{
esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
if (err != ESP_OK) {
return err;
}
return epd_turn_on_display();
}
typedef struct {
const uint8_t *data;
size_t len;
size_t pos;
} epd_buf_ctx_t;
static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
size_t remaining = c->len - c->pos;
size_t n = remaining < chunk_size ? remaining : chunk_size;
if (n == 0) {
return 0;
}
memcpy(chunk, c->data + c->pos, n);
c->pos += n;
return n;
}
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
{
epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
return epd_display_stream(epd_buf_read, &buf_ctx);
}
typedef struct {
uint8_t fill_byte;
size_t remaining;
} epd_fill_ctx_t;
static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
if (n == 0) {
return 0;
}
memset(chunk, c->fill_byte, n);
c->remaining -= n;
return n;
}
esp_err_t epd_clear(epd_color_t color)
{
epd_fill_ctx_t fill_ctx = {
.fill_byte = (uint8_t)((color << 4) | color),
.remaining = EPD_FRAME_BYTES,
};
return epd_display_stream(epd_fill_read, &fill_ctx);
}
esp_err_t epd_sleep(void)
{
epd_cs_both(0);
EPD_CHECK(epd_send_command(DEEP_SLEEP));
EPD_CHECK(epd_send_data_byte(0xA5)); /* magic deep-sleep arg per every reference driver */
epd_cs_both(1);
epd_delay_ms(100);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
return ESP_OK;
}
@@ -0,0 +1,100 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
* board (XIAO ESP32-S3 Plus). The panel is marketed/mounted as a
* 1600x1200 landscape rectangle (270.40x202.80mm), but its SPI
* controller addresses a native raster of 1200 columns x 1600 rows --
* i.e. the wire format is portrait, rotated 90 degrees from how the
* panel physically hangs. Confirmed identically across three independent
* vendor sources: Waveshare's RaspberryPi/c and ESP32 reference drivers
* for this exact panel (E-paper_Separate_Program/13.3inch_e-Paper_E in
* waveshare/e-Paper), and Waveshare's own ESP-IDF example for their
* ESP32-S3-ePaper-13.3E6 driver board (a *different* carrier board than
* Seeed's EE02, but the same panel+controller, hence the same command
* bytes/geometry -- only the GPIO numbers differ, and those come from
* EE02-specific sources, see this component's Kconfig). All three define
* EPD_WIDTH=1200/EPD_HEIGHT=1600 and split each row into two 600-byte
* (300px) halves sent to independent chip-selects: EPD_PIN_CS_MASTER
* gets the left half, EPD_PIN_CS_SLAVE the right -- see epd13in3e.c.
*
* Getting this backwards (assuming the wire raster matches the
* 1600x1200 mount/marketing size) doesn't just rotate the image -- 1600
* and 1200 don't share a row stride with 1200 and 1600 the other way
* (800 bytes/row x 1200 rows vs 600 bytes/row x 1600 rows), so a mismatch
* here slices real image rows at the wrong byte offsets and shreds the
* picture into a repeating diagonal garble, not a clean rotation.
* server/app/image_pipeline.py's PANEL_WIRE_TRANSPOSE handles the
* corresponding rotation server-side before packing bytes for this
* panel_type -- this header and that dict must agree on which axis is
* native. */
#define EPD_WIDTH 1200
#define EPD_HEIGHT 1600
#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
/* Same 6-ink Spectra family as the 7.3" panel, and (now confirmed by the
* same three vendor sources as the geometry above) the same 4-bit nibble
* codes as epd7in3e.h's epd_color_t -- matches
* server/app/image_pipeline.py's PANEL_CODES unconditionally, no
* panel-specific table needed there. */
typedef enum {
EPD_COLOR_BLACK = 0x0,
EPD_COLOR_WHITE = 0x1,
EPD_COLOR_YELLOW = 0x2,
EPD_COLOR_RED = 0x3,
EPD_COLOR_BLUE = 0x5,
EPD_COLOR_GREEN = 0x6,
} epd_color_t;
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
esp_err_t epd_init(void);
/** Fills the whole panel with a single color and refreshes. */
esp_err_t epd_clear(epd_color_t color);
/**
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
* into chunk. Must return the number of bytes written, or 0 once exhausted.
*/
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
/**
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
* panel via read_fn and refreshes. Pulling from a caller-supplied source
* instead of a single buffer lets callers feed the panel directly from an
* HTTP response without holding the whole ~960KB frame in RAM.
*/
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
/**
* Like epd_display_stream(), but writes the frame into the panel's
* internal buffer over SPI WITHOUT triggering the physical refresh (the
* visible flash/flicker) -- call epd_turn_on_display() separately to make
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
* nothing is refreshed, so the visible screen is left untouched on
* error.
*
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
* lets a caller compare against the last-displayed frame's CRC and skip
* the refresh entirely when nothing actually changed (e.g. redisplaying
* the same photo after a reboot).
*/
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
/**
* Triggers the panel's physical refresh cycle (power on, refresh, power
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
* to make the written buffer visible.
*/
esp_err_t epd_turn_on_display(void);
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
esp_err_t epd_sleep(void);
+13 -1
View File
@@ -1,3 +1,15 @@
idf_component_register(SRCS "epd7in3e.c"
# SRCS is conditional on which board's panel this build targets (see
# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
# can't be) -- an ee02 build still always requires this component (so
# its Kconfig menu/include dir exist), but contributes zero object
# files/symbols to it, since epd13in3e.c provides the real epd_init()
# etc. for that board instead.
if(CONFIG_FRAME_PANEL_EE02_13IN3)
set(srcs "")
else()
set(srcs "epd7in3e.c")
endif()
idf_component_register(SRCS ${srcs}
INCLUDE_DIRS "include"
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
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)
+43 -15
View File
@@ -2,18 +2,29 @@ menu "ESPresso Frame Configuration"
config FRAME_BOARD_NAME
string "Board variant name, reported to the server"
default "devkit"
default "devkit_esp32c6"
help
Sent as the X-Frame-Board request header on every
GET /frame/config poll, so the server can learn which board
this device is and automatically fetch the right OTA build
from a configured Gitea repo's releases -- no manual "which
board" picker in the web UI. Must match one of the asset
names .gitea/workflows/firmware-release-build.yml publishes
(firmware-<name>.bin): "devkit" (this default, for the
plain ESP32-C6-DevKitC-1 build) or "xiao" (set via
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see
build_for_board.sh).
from a configured Gitea repo's releases, and (see
routers/device.py's BOARD_PANEL_MAP) which EPD panel it
drives -- no manual "which board/panel" picker in the web
UI. Must match one of the asset names
.gitea/workflows/firmware-release-build.yml publishes
(firmware-<name>.bin): "devkit_esp32c6" (this default, for
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
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
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
string "Provisioning softAP SSID prefix"
default "ESPRESSO"
@@ -119,6 +142,7 @@ menu "ESPresso Frame Configuration"
config FRAME_NEXT_BUTTON_GPIO
int "Next-photo button GPIO (-1 to disable)"
default 2
range -1 21 if IDF_TARGET_ESP32S3
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
@@ -126,14 +150,17 @@ menu "ESPresso Frame Configuration"
Pressing it wakes the device (if asleep), forces the server to
advance to the next photo immediately (POST /frame/advance)
regardless of the configured refresh interval, and displays
it. Must be GPIO 0-7 -- the only pins the ESP32-C6 can use as
a deep-sleep GPIO wakeup source, which is what lets a press
wake the device promptly instead of only being noticed during
its brief awake windows. Set to -1 to disable the feature.
it. Must be a deep-sleep-wakeup-capable GPIO: 0-7 on the
ESP32-C6, 0-21 on the ESP32-S3 (RTC-IO pins reachable by
esp_sleep_enable_ext1_wakeup_io()) -- required so a press
wakes the device promptly instead of only being noticed
during its brief awake windows. Set to -1 to disable the
feature.
config FRAME_BACK_BUTTON_GPIO
int "Back-photo button GPIO (-1 to disable)"
default 0
range -1 21 if IDF_TARGET_ESP32S3
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
@@ -142,13 +169,14 @@ menu "ESPresso Frame Configuration"
to return to the previously-current photo immediately
(POST /frame/back), and displays it. Pressing next
afterwards returns to where you were before pressing back.
Must be GPIO 0-7 for the same deep-sleep-wakeup reason as
Must be a deep-sleep-wakeup-capable GPIO, same range as
FRAME_NEXT_BUTTON_GPIO above; defaults to a different pin
than the other buttons. Set to -1 to disable the feature.
config FRAME_COMBO_BUTTON_GPIO
int "Menu/reset button GPIO (-1 to disable)"
default 1
range -1 21 if IDF_TARGET_ESP32S3
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
@@ -159,8 +187,8 @@ menu "ESPresso Frame Configuration"
then releasing shows the management menu; holding it all the
way to FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored
config and restarts into provisioning, regardless of whether
it's released yet. Must be GPIO 0-7 for the same deep-sleep-
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
it's released yet. Must be a deep-sleep-wakeup-capable GPIO,
same range as FRAME_NEXT_BUTTON_GPIO above; defaults to
a different pin than the other buttons. Set to -1 to
disable the feature entirely (also disables the management
menu, both reset tiers, and factory-reset-via-button --
+12
View File
@@ -3,6 +3,7 @@
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -35,10 +36,17 @@ void back_button_init(void)
};
gpio_config(&io_conf);
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* See next_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* See next_button.c for why ext1 is safe here on targets without the
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
* coordination. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << BACK_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
}
back_button_result_t back_button_check(void)
@@ -49,7 +57,11 @@ back_button_result_t back_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
#endif
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
+12
View File
@@ -1,6 +1,7 @@
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -31,10 +32,17 @@ void combo_button_init(void)
};
gpio_config(&io_conf);
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* See next_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << COMBO_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* See next_button.c for why ext1 is safe here on targets without the
* API above (e.g. ESP32-S3), and why _io() needs no cross-file mask
* coordination. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << COMBO_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
}
bool combo_button_check(void)
@@ -48,7 +56,11 @@ bool combo_button_check(void)
* caused the wake even if it's since been released -- in which case
* the poll loop below simply measures 0ms held, correctly resolving
* to a quick press rather than "not pressed at all." */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
#endif
if (!caused_wake && gpio_get_level(COMBO_BUTTON_GPIO) != 0) {
return false; /* not pressed, and didn't cause this wake either */
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
/* Which EPD driver component this binary is built against -- exactly one,
* selected at compile time by CONFIG_FRAME_PANEL_EE02_13IN3 (see
* main/Kconfig.projbuild and main/CMakeLists.txt's matching PRIV_REQUIRES
* selection). Every file that used to `#include "epd7in3e.h"` directly
* includes this instead, so a build for the other board picks up the
* right EPD_WIDTH/EPD_HEIGHT/EPD_FRAME_BYTES/epd_color_t/epd_init() etc.
* with no other source change -- both driver components expose the same
* function/macro names (see epd13in3e.h), just sized for their own
* panel. */
#if CONFIG_FRAME_PANEL_EE02_13IN3
#include "epd13in3e.h"
#else
#include "epd7in3e.h"
#endif
+2 -2
View File
@@ -3,10 +3,10 @@
#include <stddef.h>
#include <stdint.h>
#include "epd7in3e.h"
#include "epd_board.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);
/**
+9 -8
View File
@@ -14,7 +14,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "status_screen.h"
#include "combo_button.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
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen
* exactly as it was. */
* (see the active EPD driver component, main/epd_board.h) refuses to
* trigger a physical refresh on a short/wrong-size stream, so a failure
* 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)
{
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);
if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a
* failed/short/wrong-size stream (see epd7in3e.c), so the
* visible screen is guaranteed untouched here -- always safe
* to show what went wrong instead of leaving stale content
* with no indication anything failed. */
* failed/short/wrong-size stream (see the active EPD driver
* component, main/epd_board.h), so the visible screen is
* guaranteed untouched here -- always safe to show what went
* 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));
/* Covers the fast-connect cache's blind spot: WiFi can report
* a successful connection (cached static IP "worked" at the
+42 -6
View File
@@ -3,6 +3,7 @@
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "soc/soc_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -36,13 +37,44 @@ void next_button_init(void)
};
gpio_config(&io_conf);
/* Not esp_sleep_enable_ext1_wakeup_io(): its internal pull resistors
* don't hold once the RTC_PERIPH domain powers down for deep sleep, so
* the pin floats and reads spuriously low, waking the device instantly
* on every sleep entry (confirmed on hardware). This GPIO-wakeup
* variant manages the pull resistor itself across the sleep
* transition. */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
/* Not esp_sleep_enable_ext1_wakeup_io() on its own: on a target
* without RTC-independent digital pull registers, ext1's internal
* pull resistors don't hold once the RTC_PERIPH domain powers down
* for deep sleep, so the pin floats and reads spuriously low, waking
* the device instantly on every sleep entry (confirmed on hardware,
* ESP32-C6). This GPIO-wakeup variant manages the pull resistor
* itself across the sleep transition, sidestepping the issue
* entirely -- but it only exists on chips with this capability
* (currently just ESP32-C6; see the #else below for other targets,
* e.g. ESP32-S3). */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
#else
/* No HP-periph-powerdown wakeup API here (e.g. ESP32-S3) -- fall
* back to ext1, but NOT naively: on every non-original-ESP32 target
* (ESP32-S3 included), gpio_pullup_en() -- which the gpio_config()
* call above invokes via pull_up_en -- delegates to
* rtc_gpio_pullup_en() for RTC-capable pins (confirmed in
* esp_driver_gpio's gpio.c: GPIO_RTCIO_ARE_INDEPENDENT is 1 for
* every target except the original ESP32, meaning digital and RTC
* pull registers are independent hardware and gpio_config() already
* routes the pull-up through the RTC pad's own register for these
* pins, not just the digital one). That's exactly what was missing
* in the ext1 attempt that failed on hardware above -- so on this
* target the pull-up already survives the RTC_PERIPH power-down
* ext1 wakeup requires, without needing a separate rtc_gpio_*_en()
* call here. _io() (not the bare esp_sleep_enable_ext1_wakeup(),
* which resets any previously-configured mask) is additive across
* this file's, back_button.c's, and combo_button.c's independent
* init calls -- confirmed in esp_hw_support's sleep_modes.c -- so no
* shared-mask coordination between the three button files is
* needed. Still unconfirmed on real EE02 hardware: this avoids the
* *documented* failure mode of the earlier ext1 attempt, but that
* attempt was never root-caused beyond "confirmed spurious wakeup on
* hardware" -- treat this as untested until it's actually run on an
* EE02 board. */
ESP_ERROR_CHECK(esp_sleep_enable_ext1_wakeup_io(1ULL << NEXT_BUTTON_GPIO, ESP_EXT1_WAKEUP_ANY_LOW));
#endif
}
next_button_result_t next_button_check(void)
@@ -53,7 +85,11 @@ next_button_result_t next_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
#if SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
#else
bool caused_wake = esp_sleep_get_ext1_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
#endif
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
+1 -1
View File
@@ -4,7 +4,7 @@
#include "esp_check.h"
#include "esp_log.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
+1 -1
View File
@@ -3,7 +3,7 @@
#include "esp_check.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "epd_draw.h"
#include "fonts.h"
#include "wifi_provisioning.h"
+1 -1
View File
@@ -19,7 +19,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "epd7in3e.h"
#include "epd_board.h"
#include "qr_onboarding.h"
#include "wifi_provisioning.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,
+50
View File
@@ -0,0 +1,50 @@
# Board-specific overrides for Seeed's EE02 (XIAO ESP32-S3 Plus + 13.3"
# Spectra 6 panel), layered on top of sdkconfig.defaults via
# SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the supported
# way to build with this file. Don't set this via a plain `idf.py
# menuconfig` on the default build; that writes straight into the shared
# sdkconfig, not this file.
#
# Unlike xiao (a same-chip Kconfig-only variant of the ESP32-C6 dev
# board), EE02 is a genuinely different chip target (ESP32-S3) --
# build_for_board.sh runs `set-target esp32s3` for this board before
# building, same as it runs `set-target esp32c6` for devkit/xiao.
CONFIG_FRAME_BOARD_NAME="ee02"
# Selects the epd13in3e driver component (13.3", 1600x1200) instead of
# epd7in3e -- see main/Kconfig.projbuild and main/CMakeLists.txt.
CONFIG_FRAME_PANEL_EE02_13IN3=y
# XIAO ESP32-S3 Plus: 16MB flash, 8MB PSRAM (vs. the plain XIAO ESP32-S3's
# 8MB/8MB) -- see partitions_ee02.csv, sized generously against this,
# not yet trimmed/tuned against a real build's actual footprint.
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ee02.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions_ee02.csv"
# EE02's e-paper interface pin defaults live in
# firmware/components/epd13in3e/Kconfig instead of being overridden here
# (mirrors how epd7in3e's Kconfig defaults are devkit-shaped and
# sdkconfig.xiao only overrides the ones that actually differ) -- EE02's
# pins are a different Kconfig menu entirely (EPD_PIN_CS_MASTER/CS_SLAVE/
# POWER_EN don't exist on epd7in3e's board at all), not a same-menu
# override, so there's nothing to set here beyond selecting the component
# above.
#
# Deliberately NOT overriding FRAME_NEXT_BUTTON_GPIO/FRAME_BACK_BUTTON_
# GPIO/FRAME_COMBO_BUTTON_GPIO/FRAME_BATTERY_ADC_GPIO here, even though
# the same community source that gave the epd13in3e pinout also reports
# EE02 has 3 user buttons at GPIO2/3/5: which physical button maps to
# which logical role (next/back/combo) still isn't confirmed. The
# button GPIOs' `range -1 7` constraint (main/Kconfig.projbuild) -- which
# used to be hardcoded to the ESP32-C6's deep-sleep-wakeup-capable GPIO
# set -- now widens to `range -1 21` under IDF_TARGET_ESP32S3 (the
# ESP32-S3's own ext1-wakeup-capable RTC-IO range), so GPIO2/3/5 fit
# either way and nothing here needs adjusting on that front. What's
# still unconfirmed: (1) the button-to-role mapping above, and (2)
# whether the S3 button-wakeup path itself (ext1 + RTC pull-up, see
# main/next_button.c) actually avoids the spurious-instant-wakeup bug
# that ruled out ext1 on the ESP32-C6 -- that needs real EE02 hardware,
# not just a clean compile. FRAME_BATTERY_ADC_GPIO's `range -1 6` is a
# separate, still-unwidened concern -- it's the ESP32-C6's ADC-capable
# pin set, not a deep-sleep-wakeup range, and out of scope here.
+1 -1
View File
@@ -8,7 +8,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
CONFIG_PARTITION_TABLE_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
# this the softAP/STA radio doesn't reliably reach the antenna at all.
+24 -16
View File
@@ -43,17 +43,21 @@ algorithm itself -- it just streams the response straight to the panel.
server, before any admin account exists.
6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
changes on `main`. In a frame's **Configuration** tab, set the
**Gitea repo URL**; if the repo is private, also set
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`.
Which board's build to fetch is learned from the frame itself (its
`X-Frame-Board` header) -- nothing to pick by hand. The server then
periodically checks for a newer release and either shows an "Update
frame" button or, with **Automatically apply updates** checked,
stages it itself -- either way the frame only actually updates on
its own next wake.
builds every supported board and publishes them as release assets
(`firmware-devkit_esp32c6.bin`/`firmware-xiao_esp32c6.bin`/
`firmware-ee02.bin`, plus `firmware-devkit.bin`/`firmware-xiao.bin`
duplicates for devices still on pre-rename firmware) whenever
`firmware/version.txt` changes on `main`. In a frame's
**Configuration** tab, set the **Gitea repo URL**; if the repo is
private, also set `GITEA_FIRMWARE_TOKEN` (a read-only PAT) in
`docker-compose.yml`. Which board's build to fetch is learned from the
frame itself (its `X-Frame-Board` header) -- nothing to pick by hand.
The same header also determines which EPD panel the frame renders for
(`Frame.panel_type`, see `docs/hardware.md`'s board identifiers
section) -- also never a manual setting. The server then periodically
checks for a newer release and either shows an "Update frame" button
or, with **Automatically apply updates** checked, stages it itself --
either way the frame only actually updates on its own next wake.
## Users, frames, and control
@@ -106,9 +110,12 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
- `GET /frame/image` -- the frame's current image, pre-processed into
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
by default: it only actually advances once `refresh_interval_s` has
the panel's raw 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`) -- 800x480/exactly 192,000 bytes for the
original 7.3" panel, 1600x1200/exactly 960,000 bytes for the 13.3"
panel (see `Frame.panel_type`/`image_pipeline.PANEL_SPECS`; a given
device's byte count is fixed by which firmware/panel it actually is).
**Side-effect-free** by default: it only actually advances once `refresh_interval_s` has
elapsed since the current photo was set, so an unexpected reboot just
redisplays the same photo. An unclaimed or not-yet-configured frame
gets a rendered instruction placeholder (with a claim QR) instead of
@@ -131,8 +138,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
*this* frame and 302s to it. Authenticated by the frame's own
`manage_token` (see the manage QR below), not device credentials -- a
phone scanning the QR has no way to supply `?id=`/`?token=`.
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
- `GET /frame/face-labels` -- up to 4 named faces with positions in the
frame's own panel space (800x480 for the 7.3" panel, 1600x1200 for the
13.3"), flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser.
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
history (feeds the runtime estimate) plus a permanent per-frame
+20 -15
View File
@@ -29,6 +29,8 @@ from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
EPD_HEIGHT,
EPD_WIDTH,
_apply_manage_overlay,
_quantize,
_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,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
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
format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
invariant every other renderer honors. weather_cities is
routers/common.py's get_or_refresh_weather() cache, or None/[] to
omit the weather strip entirely (also always omitted for view ==
"month")."""
target_w, target_h = logical_render_size(orientation)
format. Returns exactly panel_w*panel_h/2 bytes (see
image_pipeline.panel_size), same invariant every other renderer
honors. weather_cities is routers/common.py's get_or_refresh_weather()
cache, or None/[] to omit the weather strip entirely (also always
omitted for view == "month")."""
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,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset)
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,
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
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
PNG in logical (upright) orientation -- mirrors
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,
palette_rgb, weather_cities, weather_units, week_days, week_layout, week_start_offset, font_scale)
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,
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.
Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant
every other renderer honors."""
target_w, target_h = logical_render_size(orientation)
Returns exactly panel_w*panel_h/2 bytes, same invariant every other
renderer honors."""
target_w, target_h = logical_render_size(orientation, panel_w, panel_h)
img = _build_tasks(tasks, target_w, target_h, palette_rgb, title)
img = _apply_manage_overlay(img, manage)
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,
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
in logical (upright) orientation -- mirrors render_calendar_preview_
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 = _apply_manage_overlay(img, manage)
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 .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 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,
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
logical (pre-rotation) frame space at each named face's bottom-center
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 []
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
else:
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,
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
-- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see
ordered_dither), so the final _quantize pass here is a no-op on it,
same reasoning as the module docstring's compositing story."""
from .image_pipeline import _quantize, _png_bytes, logical_render_size
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)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)
+145 -39
View File
@@ -10,6 +10,72 @@ from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
EPD_WIDTH = 800
EPD_HEIGHT = 480
# Registry of every supported panel's native pixel size, keyed by
# Frame.panel_type. New entries get added here as a new EPD driver
# component is supported firmware-side (see firmware/components/) --
# geometry lives in exactly one place rather than as new module-level
# globals per panel.
DEFAULT_PANEL_TYPE = "epd7in3e"
PANEL_SPECS: dict[str, tuple[int, int]] = {
"epd7in3e": (EPD_WIDTH, EPD_HEIGHT),
# Waveshare's 13.3" e-Paper (E) Spectra 6 panel (270.40x202.80mm,
# 1600x1200px, 4:3) driven by Seeed's EE02 board. This is the panel's
# MOUNT/marketing size, not its SPI wire raster -- the controller
# itself addresses a native 1200x1600 (portrait) raster, rotated 90
# degrees from how the panel physically hangs. Both facts are now
# vendor-confirmed (see firmware/components/epd13in3e's own docstring)
# -- PANEL_SPECS stays in mount/logical terms like the 7.3" panel's
# entry (everything upstream of packing -- composition, the widget
# grid, face-label placement -- reasons in this space); the wire-raster
# rotation is applied only at pack time, see PANEL_WIRE_TRANSPOSE.
"epd13in3e": (1600, 1200),
}
# Panels whose SPI wire raster is rotated 90 degrees from PANEL_SPECS's
# mount/logical size (see that dict's own comment on epd13in3e). None =
# wire raster already matches the logical size, no extra rotation (true
# for the 7.3" panel). Applied in _transpose_and_pack AFTER the
# user-selected ORIENTATION_TRANSPOSE -- these are two independent
# rotations for two independent reasons (how the frame is hung vs. a fixed
# fact about this panel's controller wiring) and must not be conflated.
#
# Getting this wrong doesn't just rotate the output image: 1600x1200 and
# 1200x1600 don't share a row stride (800 bytes/row x 1200 rows vs 600
# bytes/row x 1600 rows), so packing at the wrong one slices real image
# rows at the wrong byte offsets and shreds the picture into a repeating
# diagonal garble on the real panel, not a clean rotation -- see
# test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride in
# tests/test_render_size_invariants.py, which catches exactly that
# regression without needing real hardware.
#
# Direction (ROTATE_90 vs ROTATE_270) is a physical-assembly fact this
# code can't derive from vendor driver bytes -- it depends on which edge
# of the panel ends up "up" in this project's frame housing. Picked
# ROTATE_90 as a documented placeholder; confirm/flip against real
# hardware once the EE02 firmware target is actually flashed and
# displaying (a wrong direction shows a rotated/mirrored image, not
# corruption, so it's safe to ship pending that check).
PANEL_WIRE_TRANSPOSE: dict[str, "Image.Transpose | None"] = {
"epd7in3e": None,
"epd13in3e": Image.Transpose.ROTATE_90,
}
# Human-readable label per PANEL_SPECS key, for the frame settings page's
# read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how
# a frame's panel_type actually gets set -- this is display-only).
PANEL_LABELS: dict[str, str] = {
"epd7in3e": '7.3" Spectra 6',
"epd13in3e": '13.3" Spectra 6',
}
def panel_size(panel_type: str) -> tuple[int, int]:
"""(width, height) native pixel size for a Frame.panel_type key.
Unknown/blank panel_type (e.g. a frame created before this field
existed) falls back to the original 7.3" panel this project shipped
with, never raises."""
return PANEL_SPECS.get(panel_type, PANEL_SPECS[DEFAULT_PANEL_TYPE])
# PIL's TrueType rendering antialiases by default (graduated gray edge
# pixels). Those survive straight into _quantize's Floyd-Steinberg
# dithering, which -- confirmed visually -- turns them into scattered
@@ -146,21 +212,24 @@ ORIENTATION_TRANSPOSE = {
}
def logical_render_size(orientation: str) -> tuple[int, int]:
def logical_render_size(orientation: str, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
"""(width, height) the photo is composed/cropped at for this
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"):
return EPD_HEIGHT, EPD_WIDTH
return EPD_WIDTH, EPD_HEIGHT
return panel_h, panel_w
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
800x480 panel space, applying the same rotation ORIENTATION_TRANSPOSE
applies to the pixels -- anything positioned in logical coordinates
(e.g. face labels) needs this to stay attached to the rotated
content. PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
logical_w, logical_h = logical_render_size(orientation)
panel space, applying the same rotation ORIENTATION_TRANSPOSE applies
to the pixels -- anything positioned in logical coordinates (e.g.
face labels) needs this to stay attached to the rotated content.
PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
logical_w, logical_h = logical_render_size(orientation, panel_w, panel_h)
if orientation == "landscape_flipped":
return int(logical_w - 1 - x), int(logical_h - 1 - y)
if orientation == "portrait": # ROTATE_90 (CCW)
@@ -208,10 +277,14 @@ CALIBRATED_SPECTRA6_RGB = [
(0x35, 0x56, 0x3A), # GREEN
]
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
# hardware protocol, never user-configurable. 0x4 is intentionally unused
# upstream.
# The 7.3" panel's actual 4-bit color codes (see
# firmware/components/epd7in3e), in the same order as DEFAULT_PALETTE_RGB/
# PALETTE_LABELS -- fixed by the hardware protocol, never user-
# configurable. 0x4 is intentionally unused upstream. Used unconditionally
# for every panel_type today -- confirmed (not just assumed) that the
# 13.3" panel's controller uses the identical codes, from the same vendor
# driver sources as PANEL_SPECS["epd13in3e"]'s own comment, so no
# panel-specific table is needed here.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
# Per-widget optional border (models.Widget.border_style, see
@@ -428,11 +501,13 @@ def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, t
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
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
image at logical_render_size(orientation), before enhancement or
quantization. See render_frame for what each display_mode does."""
return compose_into(source, faces, *logical_render_size(orientation), display_mode)
image at logical_render_size(orientation, panel_w, panel_h), before
enhancement or quantization. See render_frame for what each
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:
@@ -462,19 +537,34 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
def _transpose_and_pack(quantized: Image.Image, orientation: str,
panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Rotates a logical-space quantized image into native panel space
and packs it 2 pixels/byte the way epd7in3e.c expects. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
and packs it 2 pixels/byte the way the panel's EPD driver expects
(see firmware/components/epd7in3e). Returns exactly width*height/2
bytes for whatever native size `quantized` actually is post-rotation
-- the canvas was already built at the calling frame's own panel size
(see panel_size()), so this derives dimensions from the image itself
rather than a fixed global.
Two independent rotations happen here, in order: ORIENTATION_TRANSPOSE
(how the frame is physically hung -- a per-frame user choice), then
PANEL_WIRE_TRANSPOSE (a fixed fact about this panel_type's SPI wire
raster vs. its mount size -- see that dict's own comment). Most panels
need only the first; epd13in3e needs both."""
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
wire_transpose = PANEL_WIRE_TRANSPOSE.get(panel_type)
if wire_transpose is not None:
quantized = quantized.transpose(wire_transpose)
pixels = quantized.load()
w, h = quantized.size
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
out = bytearray(w * h // 2)
i = 0
for y in range(EPD_HEIGHT):
for x in range(0, EPD_WIDTH, 2):
for y in range(h):
for x in range(0, w, 2):
left = PANEL_CODES[pixels[x, y]]
right = PANEL_CODES[pixels[x + 1, y]]
out[i] = (left << 4) | right
@@ -501,11 +591,11 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: 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
enhancement, quantizes it to the 6-color palette, and packs 2
pixels/byte the way epd7in3e.c expects. Always returns exactly
EPD_WIDTH*EPD_HEIGHT/2 bytes.
pixels/byte the way the target panel_type's EPD driver expects.
Returns exactly width*height/2 bytes for that panel (see panel_size).
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
is reconciled with the panel's: crop_fill (center-crop to fill,
@@ -531,11 +621,17 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
which callers pass this straight through from. Applied after
enhancement, before quantization, so the overlay's pure black/white
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)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
return _transpose_and_pack(quantized, orientation, panel_type)
def _png_bytes(img: Image.Image) -> bytes:
@@ -547,7 +643,8 @@ def _png_bytes(img: Image.Image) -> bytes:
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
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,
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
(paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run
@@ -586,8 +683,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
from the same already-quantized canvas, so a device-facing render can
also persist a browser-viewable copy (see routers/device.py's
_record_last_displayed) without re-running composition/quantization a
second time."""
logical_w, logical_h = logical_render_size(orientation)
second time.
`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)
for (x, y, w, h), region_img in regions:
canvas.paste(region_img.convert("RGB"), (x, y))
@@ -597,7 +700,7 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png:
return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation)
packed = _transpose_and_pack(quantized, orientation, panel_type)
if capture_snapshot:
return packed, _png_bytes(quantized)
return packed
@@ -607,13 +710,15 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: 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
render_frame, but returned as a normal browser-viewable PNG in
logical (upright, as-the-frame-actually-hangs) orientation rather
than packed native-panel bytes and rotation -- what the web UI's
"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)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _png_bytes(quantized)
@@ -622,7 +727,8 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
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
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
@@ -631,9 +737,9 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
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
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))
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
@@ -696,7 +802,7 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png:
return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation)
packed = _transpose_and_pack(quantized, orientation, panel_type)
if capture_snapshot:
return packed, _png_bytes(quantized)
return packed
+13
View File
@@ -1157,6 +1157,18 @@ def _migration_41(conn) -> None:
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 = [
(1, _migration_1),
(2, _migration_2),
@@ -1199,6 +1211,7 @@ MIGRATIONS = [
(39, _migration_39),
(40, _migration_40),
(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")
timezone: Mapped[str] = mapped_column(String, default="UTC")
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/
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
# 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,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
panel_size,
render_preview_png,
compose_into,
_enhance,
@@ -783,6 +784,7 @@ def api_widget_preview_rendered(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=pcfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
panel_type=frame.panel_type,
)
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)
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":
from zoneinfo import ZoneInfo
@@ -906,6 +908,7 @@ def api_widget_preview_calendar(
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
native_w, native_h = panel_size(frame.panel_type)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
@@ -913,6 +916,7 @@ def api_widget_preview_calendar(
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
panel_w=native_w, panel_h=native_h,
)
return Response(content=png, media_type="image/png")
@@ -936,15 +940,17 @@ def api_widget_preview_tasks(
if tcfg.render_style == "modern":
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,
widget.font_scale)
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
png = _png_bytes(quantized)
else:
native_w, native_h = panel_size(frame.panel_type)
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
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")
@@ -1196,14 +1202,17 @@ def api_widget_preview_weather(
# Same local-import reasoning as widgets/weather.py's render().
from .. import html_render
native_w, native_h = panel_size(frame.panel_type)
png = html_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", theme_name=frame.theme,
city_label=wcfg.city_label or "", theme_name=frame.theme, panel_w=native_w, panel_h=native_h,
)
else:
native_w, native_h = panel_size(frame.panel_type)
png = weather_render.render_weather_preview_png(
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
panel_w=native_w, panel_h=native_h,
)
return Response(content=png, media_type="image/png")
@@ -1254,7 +1263,7 @@ def api_widget_preview_static(
if scfg.render_style == "modern":
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,
display_mode=scfg.display_mode)
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,
display_mode=scfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
panel_type=frame.panel_type,
)
return Response(content=png, media_type="image/png")
@@ -1285,8 +1295,9 @@ def api_widget_preview_text(
xcfg = db.get(TextWidgetConfig, widget.id)
if not has_text(xcfg.content):
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,
theme_name=frame.theme)
theme_name=frame.theme, panel_w=native_w, panel_h=native_h)
return Response(content=png, media_type="image/png")
@@ -1405,7 +1416,7 @@ def api_widget_preview_whiteboard(
if wcfg.render_style == "modern":
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,
display_mode="letterbox")
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:
png = render_preview_png(
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")
+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 ..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 ..models import (
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):
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] = []
for widget in photo_widgets:
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 ..firmware import firmware_path
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 ..widgets import WIDGET_TYPES
from .common import (
@@ -62,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
)
if frame.owner_user_id is None:
return render_placeholder(
@@ -71,6 +79,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
manage=manage,
as_png=as_png,
capture_snapshot=capture_snapshot,
panel_type=frame.panel_type,
)
return render_placeholder(
["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,
as_png=as_png,
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(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).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 = []
if all_widgets:
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,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
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(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
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)
@@ -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)
# 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")
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""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
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
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_board = request.headers.get("X-Frame-Board", "")
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
if 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 = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
+2
View File
@@ -31,6 +31,7 @@ from ..image_pipeline import (
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
PANEL_LABELS,
STATIC_DISPLAY_MODES,
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",
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
panel_labels=PANEL_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
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}
for link in links:
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.update({
"users": users,
@@ -571,6 +573,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
"notice": notice,
"error": error,
"active_admin_tab": "main",
"panel_labels": PANEL_LABELS,
})
return templates.TemplateResponse("admin.html", ctx)
+1
View File
@@ -108,6 +108,7 @@
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>
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" }}
</p>
<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 }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</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">
{% 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 %}
+4 -3
View File
@@ -34,7 +34,7 @@ from datetime import date, datetime
from PIL import Image, ImageDraw, ImageFont
from . import panel_style
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
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
# 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,
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
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 = _apply_manage_overlay(img, manage)
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 .. 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 ..routers.common import battery_estimate_s
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
"dialog preview always renders at the frame's full size, not the
widget's actual grid box" convention as text.py's render_preview_png."""
target_w, target_h = logical_render_size(orientation)
target_w, target_h = logical_render_size(orientation, *panel_size(frame.panel_type))
img = render(db, frame, widget, target_w, target_h)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
+4 -3
View File
@@ -25,7 +25,7 @@ from PIL import Image, ImageDraw
from sqlalchemy.orm import Session
from .. import theme_tokens
from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..image_pipeline import EPD_HEIGHT, EPD_WIDTH, _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text
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,
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 --
mirrors calendar_render.render_tasks_preview_png's relationship to
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)."""
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)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
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
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):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
+134
View File
@@ -161,3 +161,137 @@ def test_render_panel_backfilled_full_panel_widget_matches_grid_full_panel_rect(
region = Image.new("RGB", px[2:], (10, 20, 30))
data = render_panel([(px, region)], orientation="landscape")
assert len(data) == EXPECTED_BYTES
# --- a second, synthetic panel size (proves the packing path is genuinely
# resolution-agnostic, ahead of the real 13.3" panel's numbers existing --
# see image_pipeline._transpose_and_pack, which derives its output size
# from the quantized image itself rather than a hardcoded EPD_WIDTH/
# EPD_HEIGHT global) ---
@pytest.fixture
def synthetic_panel(monkeypatch):
"""Registers a second PANEL_SPECS entry, a different size than the
real 7.3" panel, without needing the real 13.3" panel's confirmed
resolution to exist yet."""
from app import image_pipeline
monkeypatch.setitem(image_pipeline.PANEL_SPECS, "test_panel", (600, 400))
return "test_panel", 600, 400
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_panel_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation):
from app.image_pipeline import logical_render_size
panel_type, panel_w, panel_h = synthetic_panel
w, h = logical_render_size(orientation, panel_w, panel_h)
region = Image.new("RGB", (w, h), (200, 0, 0))
data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type=panel_type)
assert len(data) == panel_w * panel_h // 2
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_placeholder_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation):
panel_type, panel_w, panel_h = synthetic_panel
data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type=panel_type)
assert len(data) == panel_w * panel_h // 2
def test_render_panel_default_panel_type_is_unaffected_by_a_new_registry_entry(synthetic_panel):
"""A second PANEL_SPECS entry existing must never change what an
ordinary (no panel_type passed) render produces -- every existing
7.3" frame's output stays byte-identical regardless of what other
panels get registered."""
data = render_placeholder(["Not configured yet"], orientation="landscape")
assert len(data) == EXPECTED_BYTES
def test_panel_size_falls_back_to_the_original_panel_for_unknown_types():
from app.image_pipeline import panel_size
assert panel_size("nonexistent") == (EPD_WIDTH, EPD_HEIGHT)
assert panel_size("") == (EPD_WIDTH, EPD_HEIGHT)
# --- the real (not synthetic) 13.3" Spectra 6 / EE02 panel geometry,
# confirmed from Waveshare's/Seeed's public product pages -- the vendor
# init/LUT/refresh sequence firmware-side is still unconfirmed (see
# image_pipeline.PANEL_SPECS's own comment), but the geometry itself is
# real, not a placeholder, so it gets the same coverage as the 7.3" panel
# rather than just the synthetic-panel tests above. ---
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_panel_size_for_the_real_13in3_panel(orientation):
from app.image_pipeline import PANEL_SPECS, logical_render_size
panel_w, panel_h = PANEL_SPECS["epd13in3e"]
w, h = logical_render_size(orientation, panel_w, panel_h)
region = Image.new("RGB", (w, h), (200, 0, 0))
data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type="epd13in3e")
assert len(data) == panel_w * panel_h // 2
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_render_placeholder_size_for_the_real_13in3_panel(orientation):
from app.image_pipeline import PANEL_SPECS
panel_w, panel_h = PANEL_SPECS["epd13in3e"]
data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type="epd13in3e")
assert len(data) == panel_w * panel_h // 2
def test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride():
"""Regression guard for a corruption bug, not just a rotation bug: the
13.3" panel's SPI controller addresses a native 1200x1600 raster (600
bytes/row x 1600 rows), rotated 90 degrees from PANEL_SPECS's
1600x1200 mount/marketing size (800 bytes/row x 1200 rows) -- see
PANEL_WIRE_TRANSPOSE's own comment. Both shapes pack to the identical
960000-byte total, so a regression here wouldn't fail a plain length
assertion -- it would ship a driver that slices real image rows at the
wrong byte offsets and shreds the picture on a real panel.
This probes stride, not rotation direction: a vertical stripe (values
constant along the *mount* image's y-axis) stays constant along
whichever axis absorbs that constancy under ANY 90-degree-multiple
rotation, so this holds regardless of which direction
PANEL_WIRE_TRANSPOSE ends up using -- only the true 600-byte wire row
stride makes each decoded row uniform; decoding at the wrong (800-byte
mount) stride would slice across real row boundaries and mix both
colors into every "row"."""
from PIL import ImageDraw
from app.image_pipeline import (
DEFAULT_PALETTE_RGB,
PANEL_SPECS,
_build_palette_image,
_transpose_and_pack,
)
mount_w, mount_h = PANEL_SPECS["epd13in3e"] # (1600, 1200)
wire_w, wire_h = mount_h, mount_w # (1200, 1600) -- the true SPI wire raster
img = Image.new("RGB", (mount_w, mount_h), (255, 255, 255))
ImageDraw.Draw(img).rectangle([0, 0, mount_w // 2 - 1, mount_h - 1], fill=(0, 0, 0))
quantized = img.quantize(palette=_build_palette_image(DEFAULT_PALETTE_RGB))
packed = _transpose_and_pack(quantized, "landscape", panel_type="epd13in3e")
assert len(packed) == wire_w * wire_h // 2
row_bytes = wire_w // 2 # 600 -- the true wire row stride
first_row = packed[0:row_bytes]
last_row = packed[(wire_h - 1) * row_bytes: wire_h * row_bytes]
def nibbles(row_bytes_slice):
vals = set()
for b in row_bytes_slice:
vals.add(b >> 4)
vals.add(b & 0x0F)
return vals
first_nibbles, last_nibbles = nibbles(first_row), nibbles(last_row)
assert len(first_nibbles) == 1, "first wire row should be a single color at the true 600-byte stride"
assert len(last_nibbles) == 1, "last wire row should be a single color at the true 600-byte stride"
assert first_nibbles != last_nibbles, "the black/white split should still show up across wire rows"