Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0fefc19f1 | ||
|
|
454c03586e | ||
|
|
fe5a2df074 | ||
|
|
474b92a282 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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__/
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -0,0 +1,65 @@
|
||||
menu "E-Paper Display (epd13in3e) Configuration"
|
||||
|
||||
config EPD_PIN_CLK
|
||||
int "SPI CLK (SCLK) GPIO"
|
||||
default 7
|
||||
help
|
||||
Defaults sourced from a community-verified ESPHome
|
||||
integration for this exact board
|
||||
(github.com/rkaramandi/esphome-seeed-ee02) -- NOT an
|
||||
official Waveshare/Seeed reference driver (see
|
||||
firmware/components/epd13in3e/epd13in3e.c's top comment,
|
||||
which is about the still-unknown panel init/LUT/refresh
|
||||
register sequence, a separate and larger unknown than this
|
||||
pinout). Override if your own board wiring differs.
|
||||
|
||||
config EPD_PIN_MOSI
|
||||
int "SPI MOSI (DIN) GPIO"
|
||||
default 9
|
||||
|
||||
config EPD_PIN_CS_MASTER
|
||||
int "SPI CS (master half) GPIO"
|
||||
default 44
|
||||
help
|
||||
Unlike epd7in3e's single-CS interface, this panel is driven
|
||||
as two halves over one shared CLK/MOSI/DC/RST/BUSY bus with
|
||||
two independent chip-selects (master/slave) -- confirmed by
|
||||
the same community ESPHome integration, not yet by this
|
||||
component's own driver code (still unimplemented, see
|
||||
epd13in3e.c).
|
||||
|
||||
config EPD_PIN_CS_SLAVE
|
||||
int "SPI CS (slave half) GPIO"
|
||||
default 41
|
||||
|
||||
config EPD_PIN_DC
|
||||
int "Data/Command GPIO"
|
||||
default 10
|
||||
|
||||
config EPD_PIN_RST
|
||||
int "Reset GPIO"
|
||||
default 38
|
||||
|
||||
config EPD_PIN_BUSY
|
||||
int "Busy GPIO"
|
||||
default 4
|
||||
|
||||
config EPD_PIN_POWER_EN
|
||||
int "Panel power-enable GPIO"
|
||||
default 43
|
||||
help
|
||||
No equivalent pin on epd7in3e's board -- the EE02 apparently
|
||||
gates the panel's own power rail separately from the ESP32-S3
|
||||
module's. Source: same community integration as the other
|
||||
pins above.
|
||||
|
||||
config EPD_SPI_CLOCK_HZ
|
||||
int "SPI clock speed (Hz)"
|
||||
default 2000000
|
||||
help
|
||||
2MHz, not epd7in3e's 4MHz default -- the same community
|
||||
integration notes higher rates were unreliable on this
|
||||
panel/board combo. Revisit once wiring is confirmed on real
|
||||
hardware.
|
||||
|
||||
endmenu
|
||||
@@ -0,0 +1,438 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_rom_crc.h"
|
||||
|
||||
#include "epd13in3e.h"
|
||||
|
||||
/* Command bytes/register values below are a line-for-line transcription of
|
||||
* Waveshare's official reference drivers for this exact panel+controller --
|
||||
* confirmed identical across three independent sources (RaspberryPi/c,
|
||||
* ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example; see this
|
||||
* component's header for repo paths). Same "don't clean these up" rule as
|
||||
* epd7in3e.c: this class of panel controller has no public datasheet, so
|
||||
* the vendor driver is the source of truth for every byte.
|
||||
*
|
||||
* Unlike epd7in3e's single chip-select, this panel is driven as two
|
||||
* independent controllers sharing one CLK/MOSI/DC/RST/BUSY bus but with
|
||||
* separate chip-selects (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE) -- most init
|
||||
* commands broadcast to both (CS_ALL), a handful of power/boost commands
|
||||
* go only to the master (which owns the shared analog rails), and actual
|
||||
* frame data is split per-row into a left half (master) and right half
|
||||
* (slave), 300 bytes each out of each 600-byte row. That split is why
|
||||
* epd_write_frame below buffers the whole frame in PSRAM before sending
|
||||
* anything (every row needs slicing in half before either half can go out),
|
||||
* unlike epd7in3e.c's straight single-CS passthrough streaming.
|
||||
*
|
||||
* Pin numbers themselves are NOT from this vendor code -- Waveshare's
|
||||
* ESP32-S3-ePaper-13.3E6 example targets Waveshare's own driver board, a
|
||||
* different carrier than Seeed's EE02 this project actually uses, so its
|
||||
* GPIO numbers don't apply here. EE02's pins remain sourced from a
|
||||
* community-verified ESPHome integration (see this component's Kconfig),
|
||||
* not an official reference. */
|
||||
|
||||
#define EPD_SPI_HOST SPI2_HOST
|
||||
#define EPD_SPI_CHUNK_SIZE 4096
|
||||
|
||||
static const char *TAG = "epd13in3e";
|
||||
|
||||
#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
|
||||
|
||||
/* --- panel command opcodes --- */
|
||||
#define PSR 0x00
|
||||
#define PWR 0x01
|
||||
#define POF 0x02
|
||||
#define PON 0x04
|
||||
#define BTST_N 0x05
|
||||
#define BTST_P 0x06
|
||||
#define DTM 0x10 /* data transfer (frame data) */
|
||||
#define DRF 0x12 /* display refresh */
|
||||
#define CDI 0x50
|
||||
#define TCON 0x60
|
||||
#define TRES 0x61
|
||||
#define AN_TM 0x74
|
||||
#define AGID 0x86
|
||||
#define BUCK_BOOST_VDDN 0xB0
|
||||
#define TFT_VCOM_POWER 0xB1
|
||||
#define EN_BUF 0xB6
|
||||
#define BOOST_VDDP_EN 0xB7
|
||||
#define CCSET 0xE0
|
||||
#define PWS 0xE3
|
||||
#define CMD66 0xF0
|
||||
#define DEEP_SLEEP 0x07
|
||||
|
||||
/* --- canned init parameter blobs (do NOT edit -- see top comment) --- */
|
||||
static const uint8_t PSR_V[] = {0xDF, 0x69};
|
||||
static const uint8_t PWR_V[] = {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38};
|
||||
static const uint8_t POF_V[] = {0x00};
|
||||
static const uint8_t DRF_V[] = {0x00};
|
||||
static const uint8_t CDI_V[] = {0xF7};
|
||||
static const uint8_t TCON_V[] = {0x03, 0x03};
|
||||
static const uint8_t TRES_V[] = {0x04, 0xB0, 0x03, 0x20};
|
||||
static const uint8_t CMD66_V[] = {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10};
|
||||
static const uint8_t EN_BUF_V[] = {0x07};
|
||||
static const uint8_t CCSET_V[] = {0x01};
|
||||
static const uint8_t PWS_V[] = {0x22};
|
||||
static const uint8_t AN_TM_V[] = {0xC0, 0x1C, 0x1C, 0xCC, 0xCC, 0xCC, 0x15, 0x15, 0x55};
|
||||
static const uint8_t AGID_V[] = {0x10};
|
||||
static const uint8_t BTST_P_V[] = {0xE8, 0x28};
|
||||
static const uint8_t BOOST_VDDP_EN_V[] = {0x01};
|
||||
static const uint8_t BTST_N_V[] = {0xE8, 0x28};
|
||||
static const uint8_t BUCK_BOOST_VDDN_V[] = {0x01};
|
||||
static const uint8_t TFT_VCOM_POWER_V[] = {0x02};
|
||||
|
||||
static spi_device_handle_t s_spi;
|
||||
|
||||
static void epd_delay_ms(uint32_t ms)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(ms));
|
||||
}
|
||||
|
||||
/* BUSY: LOW = busy, HIGH = idle -- same polarity/poll-interval reasoning
|
||||
* as epd7in3e.c's identical comment (a tight 1ms-rounds-to-0-ticks poll
|
||||
* starves the idle task badly enough to trip the watchdog). */
|
||||
static void epd_wait_busy(void)
|
||||
{
|
||||
while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
|
||||
epd_delay_ms(20);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
|
||||
{
|
||||
while (len > 0) {
|
||||
size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
|
||||
spi_transaction_t t = {
|
||||
.length = n * 8,
|
||||
.tx_buffer = data,
|
||||
};
|
||||
EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
|
||||
data += n;
|
||||
len -= n;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Unlike epd7in3e.c's send_command/send_data, these do NOT touch CS --
|
||||
* this panel's two independent chip-selects (and the "broadcast to both"
|
||||
* vs "master only" split the init sequence needs) mean CS bracketing has
|
||||
* to be the caller's decision, not baked into the byte-send primitive. */
|
||||
static esp_err_t epd_send_command(uint8_t cmd)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
|
||||
return epd_spi_write(&cmd, 1);
|
||||
}
|
||||
|
||||
static esp_err_t epd_send_data(const uint8_t *data, size_t len)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
|
||||
return epd_spi_write(data, len);
|
||||
}
|
||||
|
||||
static esp_err_t epd_send_data_byte(uint8_t data)
|
||||
{
|
||||
return epd_send_data(&data, 1);
|
||||
}
|
||||
|
||||
static void epd_cs_both(int level)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, level);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, level);
|
||||
}
|
||||
|
||||
/* Sends `cmd` + its data blob to both controllers at once (most of the
|
||||
* init sequence -- shared display-timing/power registers). */
|
||||
static esp_err_t epd_cmd_both(uint8_t cmd, const uint8_t *data, size_t len)
|
||||
{
|
||||
epd_cs_both(0);
|
||||
esp_err_t err = epd_send_command(cmd);
|
||||
if (err == ESP_OK && data != NULL) {
|
||||
err = epd_send_data(data, len);
|
||||
}
|
||||
epd_cs_both(1);
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Sends `cmd` + its data blob to the master controller only -- the boost/
|
||||
* VCOM power registers the master alone owns. */
|
||||
static esp_err_t epd_cmd_master(uint8_t cmd, const uint8_t *data, size_t len)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
esp_err_t err = epd_send_command(cmd);
|
||||
if (err == ESP_OK && data != NULL) {
|
||||
err = epd_send_data(data, len);
|
||||
}
|
||||
epd_cs_both(1);
|
||||
return err;
|
||||
}
|
||||
|
||||
/* 5-edge reset sequence (30ms each) -- per-vendor-source exact, more edges
|
||||
* than epd7in3e.c's 3-edge/20ms reset. */
|
||||
static void epd_reset(void)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
epd_delay_ms(30);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(30);
|
||||
}
|
||||
|
||||
/* Power on, refresh, power off -- mirrors EPD_TurnOnDisplay()/
|
||||
* EPD_13IN3E_TurnOnDisplay() in the reference drivers. */
|
||||
esp_err_t epd_turn_on_display(void)
|
||||
{
|
||||
EPD_CHECK(epd_cmd_both(PON, NULL, 0));
|
||||
epd_wait_busy();
|
||||
|
||||
epd_delay_ms(50);
|
||||
EPD_CHECK(epd_cmd_both(DRF, DRF_V, sizeof(DRF_V)));
|
||||
epd_wait_busy();
|
||||
|
||||
epd_delay_ms(50);
|
||||
EPD_CHECK(epd_cmd_both(POF, POF_V, sizeof(POF_V)));
|
||||
/* No busy-wait after POF -- matches every reference driver. */
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t epd_init(void)
|
||||
{
|
||||
gpio_config_t out_cfg = {
|
||||
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
|
||||
(1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
|
||||
(1ULL << CONFIG_EPD_PIN_POWER_EN),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
};
|
||||
EPD_CHECK(gpio_config(&out_cfg));
|
||||
|
||||
gpio_config_t busy_cfg = {
|
||||
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
};
|
||||
EPD_CHECK(gpio_config(&busy_cfg));
|
||||
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_cs_both(1);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
|
||||
|
||||
spi_bus_config_t bus_cfg = {
|
||||
.mosi_io_num = CONFIG_EPD_PIN_MOSI,
|
||||
.miso_io_num = -1,
|
||||
.sclk_io_num = CONFIG_EPD_PIN_CLK,
|
||||
.quadwp_io_num = -1,
|
||||
.quadhd_io_num = -1,
|
||||
.max_transfer_sz = EPD_SPI_CHUNK_SIZE,
|
||||
};
|
||||
EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
|
||||
|
||||
spi_device_interface_config_t dev_cfg = {
|
||||
.clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
|
||||
.mode = 0,
|
||||
.spics_io_num = -1, /* both chip-selects are bit-banged by hand above */
|
||||
.queue_size = 1,
|
||||
};
|
||||
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
|
||||
|
||||
/* Panel power-enable rail (no equivalent on epd7in3e's board -- EE02
|
||||
* gates it separately from the ESP32-S3 module's own supply). */
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 1);
|
||||
epd_delay_ms(10);
|
||||
|
||||
epd_reset();
|
||||
epd_wait_busy();
|
||||
|
||||
/* Master-only: shared analog-timing register. */
|
||||
EPD_CHECK(epd_cmd_master(AN_TM, AN_TM_V, sizeof(AN_TM_V)));
|
||||
|
||||
/* Broadcast: display-timing/power-sequencing registers both
|
||||
* controllers need identically. */
|
||||
EPD_CHECK(epd_cmd_both(CMD66, CMD66_V, sizeof(CMD66_V)));
|
||||
EPD_CHECK(epd_cmd_both(PSR, PSR_V, sizeof(PSR_V)));
|
||||
EPD_CHECK(epd_cmd_both(CDI, CDI_V, sizeof(CDI_V)));
|
||||
EPD_CHECK(epd_cmd_both(TCON, TCON_V, sizeof(TCON_V)));
|
||||
EPD_CHECK(epd_cmd_both(AGID, AGID_V, sizeof(AGID_V)));
|
||||
EPD_CHECK(epd_cmd_both(PWS, PWS_V, sizeof(PWS_V)));
|
||||
EPD_CHECK(epd_cmd_both(CCSET, CCSET_V, sizeof(CCSET_V)));
|
||||
EPD_CHECK(epd_cmd_both(TRES, TRES_V, sizeof(TRES_V)));
|
||||
|
||||
/* Master-only: boost/VCOM power programming. */
|
||||
EPD_CHECK(epd_cmd_master(PWR, PWR_V, sizeof(PWR_V)));
|
||||
EPD_CHECK(epd_cmd_master(EN_BUF, EN_BUF_V, sizeof(EN_BUF_V)));
|
||||
EPD_CHECK(epd_cmd_master(BTST_P, BTST_P_V, sizeof(BTST_P_V)));
|
||||
EPD_CHECK(epd_cmd_master(BOOST_VDDP_EN, BOOST_VDDP_EN_V, sizeof(BOOST_VDDP_EN_V)));
|
||||
EPD_CHECK(epd_cmd_master(BTST_N, BTST_N_V, sizeof(BTST_N_V)));
|
||||
EPD_CHECK(epd_cmd_master(BUCK_BOOST_VDDN, BUCK_BOOST_VDDN_V, sizeof(BUCK_BOOST_VDDN_V)));
|
||||
EPD_CHECK(epd_cmd_master(TFT_VCOM_POWER, TFT_VCOM_POWER_V, sizeof(TFT_VCOM_POWER_V)));
|
||||
|
||||
ESP_LOGI(TAG, "EPD initialized (CLK=%d MOSI=%d CS_M=%d CS_S=%d DC=%d RST=%d BUSY=%d PWR_EN=%d)",
|
||||
CONFIG_EPD_PIN_CLK, CONFIG_EPD_PIN_MOSI, CONFIG_EPD_PIN_CS_MASTER,
|
||||
CONFIG_EPD_PIN_CS_SLAVE, CONFIG_EPD_PIN_DC, CONFIG_EPD_PIN_RST,
|
||||
CONFIG_EPD_PIN_BUSY, CONFIG_EPD_PIN_POWER_EN);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
|
||||
{
|
||||
ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
|
||||
|
||||
/* Every row has to be sliced into a left (master) and right (slave)
|
||||
* half before either half can go out over SPI, so -- unlike
|
||||
* epd7in3e.c's single-CS passthrough -- bytes can't be forwarded to
|
||||
* the wire as they arrive. Buffer the whole ~938KB frame in PSRAM
|
||||
* first (EE02's XIAO ESP32-S3 Plus has 8MB of it). */
|
||||
uint8_t *frame = heap_caps_malloc(EPD_FRAME_BYTES, MALLOC_CAP_SPIRAM);
|
||||
if (frame == NULL) {
|
||||
ESP_LOGE(TAG, "OOM allocating %u-byte frame buffer", (unsigned)EPD_FRAME_BYTES);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
size_t total = 0;
|
||||
uint32_t crc = 0;
|
||||
size_t n;
|
||||
while (total < EPD_FRAME_BYTES &&
|
||||
(n = read_fn(frame + total, EPD_FRAME_BYTES - total, ctx)) > 0) {
|
||||
crc = esp_rom_crc32_le(crc, frame + total, n);
|
||||
total += n;
|
||||
}
|
||||
|
||||
if (total != EPD_FRAME_BYTES) {
|
||||
/* Same invariant as epd7in3e.c: never touch the panel on a
|
||||
* short/wrong-size stream -- the visible screen is left exactly
|
||||
* as it was. */
|
||||
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
|
||||
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
|
||||
free(frame);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
/* De-interleave into one half-buffer at a time and DMA it out as a
|
||||
* single contiguous transfer (chunked internally by epd_spi_write) --
|
||||
* far fewer, far larger SPI transactions than sending 1600 separate
|
||||
* 300-byte rows per side. */
|
||||
const size_t HALF_ROW = EPD_BYTES_PER_ROW / 2; /* 300 */
|
||||
const size_t HALF_BUF = HALF_ROW * EPD_HEIGHT; /* 480000 */
|
||||
uint8_t *half = heap_caps_malloc(HALF_BUF, MALLOC_CAP_SPIRAM);
|
||||
if (half == NULL) {
|
||||
ESP_LOGE(TAG, "OOM allocating %u-byte half-frame scratch buffer", (unsigned)HALF_BUF);
|
||||
free(frame);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
for (size_t r = 0; r < EPD_HEIGHT; r++) {
|
||||
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW, HALF_ROW);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
esp_err_t err = epd_send_command(DTM);
|
||||
if (err == ESP_OK) {
|
||||
err = epd_send_data(half, HALF_BUF);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
|
||||
|
||||
if (err == ESP_OK) {
|
||||
for (size_t r = 0; r < EPD_HEIGHT; r++) {
|
||||
memcpy(half + r * HALF_ROW, frame + r * EPD_BYTES_PER_ROW + HALF_ROW, HALF_ROW);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 0);
|
||||
err = epd_send_command(DTM);
|
||||
if (err == ESP_OK) {
|
||||
err = epd_send_data(half, HALF_BUF);
|
||||
}
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
|
||||
}
|
||||
|
||||
free(half);
|
||||
free(frame);
|
||||
EPD_CHECK(err);
|
||||
|
||||
if (out_crc32 != NULL) {
|
||||
*out_crc32 = crc;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
|
||||
{
|
||||
esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
return epd_turn_on_display();
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const uint8_t *data;
|
||||
size_t len;
|
||||
size_t pos;
|
||||
} epd_buf_ctx_t;
|
||||
|
||||
static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
||||
{
|
||||
epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
|
||||
size_t remaining = c->len - c->pos;
|
||||
size_t n = remaining < chunk_size ? remaining : chunk_size;
|
||||
if (n == 0) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(chunk, c->data + c->pos, n);
|
||||
c->pos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
|
||||
{
|
||||
epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
|
||||
return epd_display_stream(epd_buf_read, &buf_ctx);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
uint8_t fill_byte;
|
||||
size_t remaining;
|
||||
} epd_fill_ctx_t;
|
||||
|
||||
static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
||||
{
|
||||
epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
|
||||
size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
|
||||
if (n == 0) {
|
||||
return 0;
|
||||
}
|
||||
memset(chunk, c->fill_byte, n);
|
||||
c->remaining -= n;
|
||||
return n;
|
||||
}
|
||||
|
||||
esp_err_t epd_clear(epd_color_t color)
|
||||
{
|
||||
epd_fill_ctx_t fill_ctx = {
|
||||
.fill_byte = (uint8_t)((color << 4) | color),
|
||||
.remaining = EPD_FRAME_BYTES,
|
||||
};
|
||||
return epd_display_stream(epd_fill_read, &fill_ctx);
|
||||
}
|
||||
|
||||
esp_err_t epd_sleep(void)
|
||||
{
|
||||
epd_cs_both(0);
|
||||
EPD_CHECK(epd_send_command(DEEP_SLEEP));
|
||||
EPD_CHECK(epd_send_data_byte(0xA5)); /* magic deep-sleep arg per every reference driver */
|
||||
epd_cs_both(1);
|
||||
|
||||
epd_delay_ms(100);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_POWER_EN, 0);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
|
||||
* board (XIAO ESP32-S3 Plus). The panel is marketed/mounted as a
|
||||
* 1600x1200 landscape rectangle (270.40x202.80mm), but its SPI
|
||||
* controller addresses a native raster of 1200 columns x 1600 rows --
|
||||
* i.e. the wire format is portrait, rotated 90 degrees from how the
|
||||
* panel physically hangs. Confirmed identically across three independent
|
||||
* vendor sources: Waveshare's RaspberryPi/c and ESP32 reference drivers
|
||||
* for this exact panel (E-paper_Separate_Program/13.3inch_e-Paper_E in
|
||||
* waveshare/e-Paper), and Waveshare's own ESP-IDF example for their
|
||||
* ESP32-S3-ePaper-13.3E6 driver board (a *different* carrier board than
|
||||
* Seeed's EE02, but the same panel+controller, hence the same command
|
||||
* bytes/geometry -- only the GPIO numbers differ, and those come from
|
||||
* EE02-specific sources, see this component's Kconfig). All three define
|
||||
* EPD_WIDTH=1200/EPD_HEIGHT=1600 and split each row into two 600-byte
|
||||
* (300px) halves sent to independent chip-selects: EPD_PIN_CS_MASTER
|
||||
* gets the left half, EPD_PIN_CS_SLAVE the right -- see epd13in3e.c.
|
||||
*
|
||||
* Getting this backwards (assuming the wire raster matches the
|
||||
* 1600x1200 mount/marketing size) doesn't just rotate the image -- 1600
|
||||
* and 1200 don't share a row stride with 1200 and 1600 the other way
|
||||
* (800 bytes/row x 1200 rows vs 600 bytes/row x 1600 rows), so a mismatch
|
||||
* here slices real image rows at the wrong byte offsets and shreds the
|
||||
* picture into a repeating diagonal garble, not a clean rotation.
|
||||
* server/app/image_pipeline.py's PANEL_WIRE_TRANSPOSE handles the
|
||||
* corresponding rotation server-side before packing bytes for this
|
||||
* panel_type -- this header and that dict must agree on which axis is
|
||||
* native. */
|
||||
#define EPD_WIDTH 1200
|
||||
#define EPD_HEIGHT 1600
|
||||
#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
|
||||
#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
|
||||
|
||||
/* Same 6-ink Spectra family as the 7.3" panel, and (now confirmed by the
|
||||
* same three vendor sources as the geometry above) the same 4-bit nibble
|
||||
* codes as epd7in3e.h's epd_color_t -- matches
|
||||
* server/app/image_pipeline.py's PANEL_CODES unconditionally, no
|
||||
* panel-specific table needed there. */
|
||||
typedef enum {
|
||||
EPD_COLOR_BLACK = 0x0,
|
||||
EPD_COLOR_WHITE = 0x1,
|
||||
EPD_COLOR_YELLOW = 0x2,
|
||||
EPD_COLOR_RED = 0x3,
|
||||
EPD_COLOR_BLUE = 0x5,
|
||||
EPD_COLOR_GREEN = 0x6,
|
||||
} epd_color_t;
|
||||
|
||||
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
|
||||
esp_err_t epd_init(void);
|
||||
|
||||
/** Fills the whole panel with a single color and refreshes. */
|
||||
esp_err_t epd_clear(epd_color_t color);
|
||||
|
||||
/**
|
||||
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
|
||||
* into chunk. Must return the number of bytes written, or 0 once exhausted.
|
||||
*/
|
||||
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
|
||||
|
||||
/**
|
||||
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
|
||||
* panel via read_fn and refreshes. Pulling from a caller-supplied source
|
||||
* instead of a single buffer lets callers feed the panel directly from an
|
||||
* HTTP response without holding the whole ~960KB frame in RAM.
|
||||
*/
|
||||
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
|
||||
|
||||
/**
|
||||
* Like epd_display_stream(), but writes the frame into the panel's
|
||||
* internal buffer over SPI WITHOUT triggering the physical refresh (the
|
||||
* visible flash/flicker) -- call epd_turn_on_display() separately to make
|
||||
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
|
||||
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
|
||||
* nothing is refreshed, so the visible screen is left untouched on
|
||||
* error.
|
||||
*
|
||||
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
|
||||
* lets a caller compare against the last-displayed frame's CRC and skip
|
||||
* the refresh entirely when nothing actually changed (e.g. redisplaying
|
||||
* the same photo after a reboot).
|
||||
*/
|
||||
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
|
||||
|
||||
/**
|
||||
* Triggers the panel's physical refresh cycle (power on, refresh, power
|
||||
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
|
||||
* to make the written buffer visible.
|
||||
*/
|
||||
esp_err_t epd_turn_on_display(void);
|
||||
|
||||
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
|
||||
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
|
||||
|
||||
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
|
||||
esp_err_t epd_sleep(void);
|
||||
@@ -1,3 +1,15 @@
|
||||
idf_component_register(SRCS "epd7in3e.c"
|
||||
# SRCS is conditional on which board's panel this build targets (see
|
||||
# main/CMakeLists.txt's comment on why REQUIRES/PRIV_REQUIRES itself
|
||||
# can't be) -- an ee02 build still always requires this component (so
|
||||
# its Kconfig menu/include dir exist), but contributes zero object
|
||||
# files/symbols to it, since epd13in3e.c provides the real epd_init()
|
||||
# etc. for that board instead.
|
||||
if(CONFIG_FRAME_PANEL_EE02_13IN3)
|
||||
set(srcs "")
|
||||
else()
|
||||
set(srcs "epd7in3e.c")
|
||||
endif()
|
||||
|
||||
idf_component_register(SRCS ${srcs}
|
||||
INCLUDE_DIRS "include"
|
||||
PRIV_REQUIRES esp_driver_spi esp_driver_gpio)
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Both EPD driver components are always REQUIRED (REQUIRES/PRIV_REQUIRES
|
||||
# can't itself depend on a Kconfig value -- ESP-IDF resolves the
|
||||
# component dependency graph in an early pass that runs BEFORE Kconfig
|
||||
# is generated, so a CONFIG_* check here would silently see an empty
|
||||
# value every time; confirmed the hard way, see git history if this
|
||||
# comment ever seems suspicious). Which one actually compiles anything
|
||||
# is decided inside each component's own CMakeLists.txt (conditional
|
||||
# SRCS, evaluated in the later, Kconfig-aware pass -- that's fine, it's
|
||||
# only REQUIRES itself that has the early-pass restriction), keyed off
|
||||
# the same CONFIG_FRAME_PANEL_EE02_13IN3 that main/epd_board.h uses to
|
||||
# pick which header every source file sees -- exactly one of the two
|
||||
# ever contributes actual object files/symbols to a given build.
|
||||
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
|
||||
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)
|
||||
|
||||
@@ -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 --
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
/* Which EPD driver component this binary is built against -- exactly one,
|
||||
* selected at compile time by CONFIG_FRAME_PANEL_EE02_13IN3 (see
|
||||
* main/Kconfig.projbuild and main/CMakeLists.txt's matching PRIV_REQUIRES
|
||||
* selection). Every file that used to `#include "epd7in3e.h"` directly
|
||||
* includes this instead, so a build for the other board picks up the
|
||||
* right EPD_WIDTH/EPD_HEIGHT/EPD_FRAME_BYTES/epd_color_t/epd_init() etc.
|
||||
* with no other source change -- both driver components expose the same
|
||||
* function/macro names (see epd13in3e.h), just sized for their own
|
||||
* panel. */
|
||||
|
||||
#if CONFIG_FRAME_PANEL_EE02_13IN3
|
||||
#include "epd13in3e.h"
|
||||
#else
|
||||
#include "epd7in3e.h"
|
||||
#endif
|
||||
@@ -3,10 +3,10 @@
|
||||
#include <stddef.h>
|
||||
#include <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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
# Same OTA layout/offsets as partitions.csv (the 8MB dev-board table) --
|
||||
# the XIAO ESP32-S3 Plus's 16MB flash has plenty of room for the same
|
||||
# 2MB app slots (current firmware runs ~1.2MB, per partitions_xiao.csv's
|
||||
# own sizing note) without needing to trim anything the way the 4MB xiao
|
||||
# table did. Leaves ~12MB of the 16MB unused/unpartitioned for now --
|
||||
# revisit sizing once a real build's actual footprint and any EE02-
|
||||
# specific storage needs (if ever) are known.
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
phy_init, data, phy, 0xf000, 0x1000,
|
||||
ota_0, app, ota_0, 0x10000, 0x200000,
|
||||
otadata, data, ota, 0x210000, 0x2000,
|
||||
ota_1, app, ota_1, 0x220000, 0x200000,
|
||||
|
@@ -0,0 +1,50 @@
|
||||
# Board-specific overrides for Seeed's EE02 (XIAO ESP32-S3 Plus + 13.3"
|
||||
# Spectra 6 panel), layered on top of sdkconfig.defaults via
|
||||
# SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the supported
|
||||
# way to build with this file. Don't set this via a plain `idf.py
|
||||
# menuconfig` on the default build; that writes straight into the shared
|
||||
# sdkconfig, not this file.
|
||||
#
|
||||
# Unlike xiao (a same-chip Kconfig-only variant of the ESP32-C6 dev
|
||||
# board), EE02 is a genuinely different chip target (ESP32-S3) --
|
||||
# build_for_board.sh runs `set-target esp32s3` for this board before
|
||||
# building, same as it runs `set-target esp32c6` for devkit/xiao.
|
||||
CONFIG_FRAME_BOARD_NAME="ee02"
|
||||
|
||||
# Selects the epd13in3e driver component (13.3", 1600x1200) instead of
|
||||
# epd7in3e -- see main/Kconfig.projbuild and main/CMakeLists.txt.
|
||||
CONFIG_FRAME_PANEL_EE02_13IN3=y
|
||||
|
||||
# XIAO ESP32-S3 Plus: 16MB flash, 8MB PSRAM (vs. the plain XIAO ESP32-S3's
|
||||
# 8MB/8MB) -- see partitions_ee02.csv, sized generously against this,
|
||||
# not yet trimmed/tuned against a real build's actual footprint.
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ee02.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions_ee02.csv"
|
||||
|
||||
# EE02's e-paper interface pin defaults live in
|
||||
# firmware/components/epd13in3e/Kconfig instead of being overridden here
|
||||
# (mirrors how epd7in3e's Kconfig defaults are devkit-shaped and
|
||||
# sdkconfig.xiao only overrides the ones that actually differ) -- EE02's
|
||||
# pins are a different Kconfig menu entirely (EPD_PIN_CS_MASTER/CS_SLAVE/
|
||||
# POWER_EN don't exist on epd7in3e's board at all), not a same-menu
|
||||
# override, so there's nothing to set here beyond selecting the component
|
||||
# above.
|
||||
#
|
||||
# Deliberately NOT overriding FRAME_NEXT_BUTTON_GPIO/FRAME_BACK_BUTTON_
|
||||
# GPIO/FRAME_COMBO_BUTTON_GPIO/FRAME_BATTERY_ADC_GPIO here, even though
|
||||
# the same community source that gave the epd13in3e pinout also reports
|
||||
# EE02 has 3 user buttons at GPIO2/3/5: which physical button maps to
|
||||
# which logical role (next/back/combo) still isn't confirmed. The
|
||||
# button GPIOs' `range -1 7` constraint (main/Kconfig.projbuild) -- which
|
||||
# used to be hardcoded to the ESP32-C6's deep-sleep-wakeup-capable GPIO
|
||||
# set -- now widens to `range -1 21` under IDF_TARGET_ESP32S3 (the
|
||||
# ESP32-S3's own ext1-wakeup-capable RTC-IO range), so GPIO2/3/5 fit
|
||||
# either way and nothing here needs adjusting on that front. What's
|
||||
# still unconfirmed: (1) the button-to-role mapping above, and (2)
|
||||
# whether the S3 button-wakeup path itself (ext1 + RTC pull-up, see
|
||||
# main/next_button.c) actually avoids the spurious-instant-wakeup bug
|
||||
# that ruled out ext1 on the ESP32-C6 -- that needs real EE02 hardware,
|
||||
# not just a clean compile. FRAME_BATTERY_ADC_GPIO's `range -1 6` is a
|
||||
# separate, still-unwidened concern -- it's the ESP32-C6's ADC-capable
|
||||
# pin set, not a deep-sleep-wakeup range, and out of scope here.
|
||||
@@ -8,7 +8,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
|
||||
CONFIG_PARTITION_TABLE_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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
|
||||
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
||||
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
|
||||
· panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
|
||||
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
||||
</p>
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user