Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
This commit is contained in:
@@ -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
|
||||
case "$board" in
|
||||
both)
|
||||
build_one devkit "$@"
|
||||
build_one xiao "$@"
|
||||
else
|
||||
;;
|
||||
all)
|
||||
build_one devkit "$@"
|
||||
build_one xiao "$@"
|
||||
build_one ee02 "$@"
|
||||
;;
|
||||
*)
|
||||
build_one "$board" "$@"
|
||||
fi
|
||||
;;
|
||||
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__/
|
||||
|
||||
@@ -152,3 +152,61 @@ photo. A full-color refresh on this panel takes 15-30+ seconds and draws
|
||||
more current than deep sleep by a wide margin -- expect battery life (if
|
||||
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 (driver not yet functional)
|
||||
|
||||
A second panel size is supported server-side (the web UI shows a
|
||||
read-only "Panel: 13.3\" Spectra 6" once a frame's device reports
|
||||
itself as `ee02`) but **the firmware driver for it does not work yet**
|
||||
-- `firmware/components/epd13in3e`'s panel init/LUT/refresh register
|
||||
sequence hasn't been ported from Waveshare's/Seeed's own reference code
|
||||
(which wasn't available at the time this was scaffolded), so a build for
|
||||
this board deliberately fails to compile (`#error`) rather than risk
|
||||
sending wrong register/LUT/timing values to real hardware. Confirmed so
|
||||
far, from Waveshare's/Seeed's public product pages and a community
|
||||
ESPHome integration (not an official vendor reference driver):
|
||||
|
||||
- Panel: [Waveshare 13.3" e-Paper (E) Spectra 6](https://www.waveshare.com/13.3inch-e-paper-hat-plus-e.htm) --
|
||||
1600x1200, 270.40x202.80mm, same 6-ink Spectra family as the 7.3"
|
||||
panel. Full refresh ~19s.
|
||||
- Board: [Seeed's EE02](https://www.seeedstudio.com/XIAO-ePaper-DIY-Kit-EE02-for-13-3-Spectratm-6-E-Ink.html) --
|
||||
a XIAO ESP32-S3 Plus (16MB flash, 8MB PSRAM) socketed into a dedicated
|
||||
driver PCB, one reset + three user buttons, JST 2.0mm battery
|
||||
connector with built-in charging IC.
|
||||
- Wiring (source: [github.com/rkaramandi/esphome-seeed-ee02](https://github.com/rkaramandi/esphome-seeed-ee02), a community integration, not Seeed's own schematic --
|
||||
treat as a starting point, confirm before relying on it): unlike
|
||||
epd7in3e's single chip-select, this panel is driven as two halves
|
||||
sharing one CLK/MOSI/DC/RST/BUSY bus with independent chip-selects.
|
||||
|
||||
| Signal | GPIO | Kconfig option |
|
||||
| --- | --- | --- |
|
||||
| CLK | 7 | `EPD_PIN_CLK` |
|
||||
| MOSI | 9 | `EPD_PIN_MOSI` |
|
||||
| CS (master half) | 44 | `EPD_PIN_CS_MASTER` |
|
||||
| CS (slave half) | 41 | `EPD_PIN_CS_SLAVE` |
|
||||
| DC | 10 | `EPD_PIN_DC` |
|
||||
| RST | 38 | `EPD_PIN_RST` |
|
||||
| BUSY | 4 | `EPD_PIN_BUSY` |
|
||||
| Panel power-enable | 43 | `EPD_PIN_POWER_EN` |
|
||||
|
||||
User buttons are reportedly at GPIO 2/3/5, but which physical button
|
||||
maps to which logical role (next/back/menu) isn't confirmed, and the
|
||||
firmware's button Kconfig options (`FRAME_NEXT_BUTTON_GPIO` etc.,
|
||||
`firmware/main/Kconfig.projbuild`) still range-limit to GPIO 0-7 --
|
||||
the ESP32-C6's deep-sleep-wakeup-capable set, not yet verified against
|
||||
the ESP32-S3's. SPI clock is reportedly reliable only up to 2MHz on
|
||||
this panel/board (vs. epd7in3e's 4MHz default) -- see
|
||||
`firmware/sdkconfig.ee02`.
|
||||
|
||||
See `firmware/components/epd13in3e/epd13in3e.c`'s own top comment for
|
||||
what's needed to actually make this board work.
|
||||
|
||||
+28
-1
@@ -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-not-yet-functional)
|
||||
below; the driver for that panel doesn't work yet). On first boot it
|
||||
provisions itself over
|
||||
a WiFi captive portal; after that it wakes on a timer, fetches an
|
||||
already-processed frame from the [server](../server/), streams it straight
|
||||
to the panel over SPI, and goes back to deep sleep.
|
||||
@@ -48,6 +52,29 @@ never clobbers the other:
|
||||
(`./build_for_board.sh devkit ...` does the same for the dev board --
|
||||
equivalent to a plain `idf.py`, just consistent with the XIAO invocation.)
|
||||
|
||||
### Building for Seeed's EE02 (ESP32-S3 + 13.3" panel, driver not yet functional)
|
||||
|
||||
EE02 is a different chip (ESP32-S3, not C6), so it needs `set-target
|
||||
esp32s3` instead of `esp32c6`, and its own partition table/flash-size
|
||||
Kconfig sized for its 16MB flash
|
||||
([`partitions_ee02.csv`](partitions_ee02.csv)):
|
||||
|
||||
```
|
||||
./build_for_board.sh ee02 set-target esp32s3
|
||||
./build_for_board.sh ee02 build
|
||||
```
|
||||
|
||||
**This will fail to compile.** `firmware/components/epd13in3e`'s panel
|
||||
init/LUT/refresh register sequence hasn't been ported from vendor demo
|
||||
code yet (see that file's own top comment and
|
||||
[`docs/hardware.md`](../docs/hardware.md#133-spectra-6-panel-on-seeeds-ee02-board-driver-not-yet-functional))
|
||||
-- a deliberate `#error`, not a bug in this build path. Everything
|
||||
around it (target selection, Kconfig, partition table, sdkconfig
|
||||
layering, `main/CMakeLists.txt`'s component selection) is in place and
|
||||
exercised by CI (`.gitea/workflows/firmware-build-check.yml`/
|
||||
`firmware-release-build.yml`, both with `continue-on-error` on this
|
||||
board's step until the driver is real).
|
||||
|
||||
## Configuration (`idf.py menuconfig`)
|
||||
|
||||
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,292 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_rom_crc.h"
|
||||
|
||||
#include "epd13in3e.h"
|
||||
|
||||
/* This component only ever gets compiled in when CONFIG_FRAME_PANEL_EE02_
|
||||
* 13IN3=y selects it as main/CMakeLists.txt's linked EPD driver (see
|
||||
* main/epd_board.h) -- i.e. only when someone deliberately builds for the
|
||||
* EE02 board. epd7in3e.c's own top comment explains why its exact
|
||||
* command bytes/register values are trustworthy: they're a line-for-line
|
||||
* transcription of Waveshare's own reference driver, since this class of
|
||||
* panel controller has no public datasheet. No equivalent reference
|
||||
* driver for the 13.3" panel + EE02 exists in this tree yet, and guessing
|
||||
* at register/LUT/timing values is not a safe substitute -- wrong values
|
||||
* can under-refresh (ghosting) or over-drive a real panel. Get Waveshare's
|
||||
* or Seeed's official demo/reference code for this exact panel+board
|
||||
* combo, port its command sequences the same way epd7in3e.c's were
|
||||
* ported, and remove this #error as part of that. The SPI/GPIO plumbing
|
||||
* below (bus init, busy-wait, chunked writes, the streaming/CRC contract)
|
||||
* is NOT panel-specific and should carry over unchanged once that
|
||||
* happens -- only the register sequences inside epd_init()/
|
||||
* epd_turn_on_display()/epd_sleep() need real vendor values.
|
||||
*
|
||||
* One more thing the real driver logic will need to account for, beyond
|
||||
* epd7in3e.c's shape: this panel is driven as two halves sharing one
|
||||
* CLK/MOSI/DC/RST/BUSY bus but with two independent chip-selects
|
||||
* (EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE, see Kconfig) -- epd_send_command/
|
||||
* epd_send_data below still only assert CS_MASTER, which is wrong for
|
||||
* whichever commands/data need to go to the slave half instead. Source
|
||||
* for the dual-CS pinout itself (not the command sequence) is a
|
||||
* community-verified ESPHome integration for this exact board
|
||||
* (github.com/rkaramandi/esphome-seeed-ee02), not an official Seeed/
|
||||
* Waveshare reference -- treat it as a reasonable starting point, not
|
||||
* gospel, until confirmed against real hardware. */
|
||||
#error "epd13in3e: panel init/LUT/refresh register sequence not yet ported from vendor demo code -- see this file's top comment"
|
||||
|
||||
#define EPD_SPI_HOST SPI2_HOST
|
||||
#define EPD_SPI_CHUNK_SIZE 4096
|
||||
|
||||
static const char *TAG = "epd13in3e";
|
||||
|
||||
#define EPD_CHECK(expr) ESP_RETURN_ON_ERROR((expr), TAG, #expr)
|
||||
|
||||
static spi_device_handle_t s_spi;
|
||||
|
||||
static void epd_delay_ms(uint32_t ms)
|
||||
{
|
||||
vTaskDelay(pdMS_TO_TICKS(ms));
|
||||
}
|
||||
|
||||
/* BUSY: LOW = busy, HIGH = idle -- same polarity convention as epd7in3e.c;
|
||||
* confirm against the vendor demo code once it exists (some EPD
|
||||
* controllers invert this). See epd7in3e.c's own comment for why this
|
||||
* polls in >= 1 FreeRTOS tick increments rather than a tight spin. */
|
||||
static void epd_wait_busy(void)
|
||||
{
|
||||
while (gpio_get_level((gpio_num_t)CONFIG_EPD_PIN_BUSY) == 0) {
|
||||
epd_delay_ms(20);
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t epd_spi_write(const uint8_t *data, size_t len)
|
||||
{
|
||||
while (len > 0) {
|
||||
size_t n = len > EPD_SPI_CHUNK_SIZE ? EPD_SPI_CHUNK_SIZE : len;
|
||||
spi_transaction_t t = {
|
||||
.length = n * 8,
|
||||
.tx_buffer = data,
|
||||
};
|
||||
EPD_CHECK(spi_device_polling_transmit(s_spi, &t));
|
||||
data += n;
|
||||
len -= n;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t epd_send_command(uint8_t cmd)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 0);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
esp_err_t err = epd_spi_write(&cmd, 1);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t epd_send_data(const uint8_t *data, size_t len)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
esp_err_t err = epd_spi_write(data, len);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t epd_send_data_byte(uint8_t data)
|
||||
{
|
||||
return epd_send_data(&data, 1);
|
||||
}
|
||||
|
||||
static void epd_reset(void)
|
||||
{
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(20);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 0);
|
||||
epd_delay_ms(2);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_RST, 1);
|
||||
epd_delay_ms(20);
|
||||
}
|
||||
|
||||
/* TODO(epd13in3e): power-on/refresh/power-off register sequence -- see
|
||||
* this file's top #error. epd7in3e.c's epd_turn_on_display() is the
|
||||
* shape to mirror once the real command bytes are known. */
|
||||
esp_err_t epd_turn_on_display(void)
|
||||
{
|
||||
(void)epd_send_command;
|
||||
(void)epd_send_data;
|
||||
(void)epd_send_data_byte;
|
||||
(void)epd_wait_busy;
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
esp_err_t epd_init(void)
|
||||
{
|
||||
/* CS_SLAVE and POWER_EN are configured as outputs here (safe,
|
||||
* mechanical) but not yet driven anywhere below -- the dual-CS
|
||||
* command routing and whatever power-on-vs-reset sequencing
|
||||
* POWER_EN needs are both part of the still-unported vendor
|
||||
* register sequence (see this file's top comment), not something
|
||||
* to guess at. */
|
||||
gpio_config_t out_cfg = {
|
||||
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_DC) | (1ULL << CONFIG_EPD_PIN_RST) |
|
||||
(1ULL << CONFIG_EPD_PIN_CS_MASTER) | (1ULL << CONFIG_EPD_PIN_CS_SLAVE) |
|
||||
(1ULL << CONFIG_EPD_PIN_POWER_EN),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
};
|
||||
EPD_CHECK(gpio_config(&out_cfg));
|
||||
|
||||
gpio_config_t busy_cfg = {
|
||||
.pin_bit_mask = (1ULL << CONFIG_EPD_PIN_BUSY),
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
};
|
||||
EPD_CHECK(gpio_config(&busy_cfg));
|
||||
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_SLAVE, 1);
|
||||
|
||||
spi_bus_config_t bus_cfg = {
|
||||
.mosi_io_num = CONFIG_EPD_PIN_MOSI,
|
||||
.miso_io_num = -1,
|
||||
.sclk_io_num = CONFIG_EPD_PIN_CLK,
|
||||
.quadwp_io_num = -1,
|
||||
.quadhd_io_num = -1,
|
||||
.max_transfer_sz = EPD_SPI_CHUNK_SIZE,
|
||||
};
|
||||
EPD_CHECK(spi_bus_initialize(EPD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO));
|
||||
|
||||
spi_device_interface_config_t dev_cfg = {
|
||||
.clock_speed_hz = CONFIG_EPD_SPI_CLOCK_HZ,
|
||||
.mode = 0,
|
||||
.spics_io_num = -1,
|
||||
.queue_size = 1,
|
||||
};
|
||||
EPD_CHECK(spi_bus_add_device(EPD_SPI_HOST, &dev_cfg, &s_spi));
|
||||
|
||||
epd_reset();
|
||||
epd_wait_busy();
|
||||
epd_delay_ms(30);
|
||||
|
||||
/* TODO(epd13in3e): panel-specific power-on register sequence goes
|
||||
* here, see this file's top #error. */
|
||||
ESP_LOGE(TAG, "epd_init: panel register sequence not yet ported, EPD will not actually work");
|
||||
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32)
|
||||
{
|
||||
ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required");
|
||||
|
||||
EPD_CHECK(epd_send_command(0x10));
|
||||
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_DC, 1);
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 0);
|
||||
|
||||
/* Static rather than a stack local -- see epd7in3e.c's identical
|
||||
* comment on why (default main task stack is smaller than this
|
||||
* chunk buffer alone). */
|
||||
static uint8_t chunk[EPD_SPI_CHUNK_SIZE];
|
||||
size_t total = 0;
|
||||
uint32_t crc = 0;
|
||||
size_t n;
|
||||
esp_err_t err = ESP_OK;
|
||||
while ((n = read_fn(chunk, sizeof(chunk), ctx)) > 0) {
|
||||
err = epd_spi_write(chunk, n);
|
||||
if (err != ESP_OK) {
|
||||
break;
|
||||
}
|
||||
crc = esp_rom_crc32_le(crc, chunk, n);
|
||||
total += n;
|
||||
}
|
||||
|
||||
gpio_set_level((gpio_num_t)CONFIG_EPD_PIN_CS_MASTER, 1);
|
||||
EPD_CHECK(err);
|
||||
|
||||
if (total != EPD_FRAME_BYTES) {
|
||||
/* Same invariant as epd7in3e.c: never trigger a refresh on a
|
||||
* short/wrong-size stream. */
|
||||
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
|
||||
(unsigned)total, (unsigned)EPD_FRAME_BYTES);
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
if (out_crc32 != NULL) {
|
||||
*out_crc32 = crc;
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx)
|
||||
{
|
||||
esp_err_t err = epd_write_frame(read_fn, ctx, NULL);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
return epd_turn_on_display();
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const uint8_t *data;
|
||||
size_t len;
|
||||
size_t pos;
|
||||
} epd_buf_ctx_t;
|
||||
|
||||
static size_t epd_buf_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
||||
{
|
||||
epd_buf_ctx_t *c = (epd_buf_ctx_t *)ctx_;
|
||||
size_t remaining = c->len - c->pos;
|
||||
size_t n = remaining < chunk_size ? remaining : chunk_size;
|
||||
if (n == 0) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(chunk, c->data + c->pos, n);
|
||||
c->pos += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len)
|
||||
{
|
||||
epd_buf_ctx_t buf_ctx = { .data = frame, .len = len, .pos = 0 };
|
||||
return epd_display_stream(epd_buf_read, &buf_ctx);
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
uint8_t fill_byte;
|
||||
size_t remaining;
|
||||
} epd_fill_ctx_t;
|
||||
|
||||
static size_t epd_fill_read(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
||||
{
|
||||
epd_fill_ctx_t *c = (epd_fill_ctx_t *)ctx_;
|
||||
size_t n = c->remaining < chunk_size ? c->remaining : chunk_size;
|
||||
if (n == 0) {
|
||||
return 0;
|
||||
}
|
||||
memset(chunk, c->fill_byte, n);
|
||||
c->remaining -= n;
|
||||
return n;
|
||||
}
|
||||
|
||||
esp_err_t epd_clear(epd_color_t color)
|
||||
{
|
||||
epd_fill_ctx_t fill_ctx = {
|
||||
.fill_byte = (uint8_t)((color << 4) | color),
|
||||
.remaining = EPD_FRAME_BYTES,
|
||||
};
|
||||
return epd_display_stream(epd_fill_read, &fill_ctx);
|
||||
}
|
||||
|
||||
/* TODO(epd13in3e): power-off/deep-sleep register sequence -- see this
|
||||
* file's top #error. */
|
||||
esp_err_t epd_sleep(void)
|
||||
{
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
/* Waveshare 13.3" e-Paper (E) Spectra 6 panel, driven by Seeed's EE02
|
||||
* board (XIAO ESP32-S3 Plus): 1600x1200, 4 bits/pixel packed
|
||||
* 2-pixels-per-byte -- same packing convention and public function
|
||||
* shapes as epd7in3e.h, just a different resolution, so main's own
|
||||
* sources don't need to branch on which panel is active beyond
|
||||
* main/epd_board.h's
|
||||
* header selection. Confirmed from Waveshare's/Seeed's public product
|
||||
* pages (270.40x202.80mm, 1600x1200px). NOT yet confirmed against the
|
||||
* vendor's own reference driver code, which doesn't exist in this tree
|
||||
* yet -- see epd13in3e.c's top comment and epd_init()'s stub. The panel
|
||||
* is also driven as two halves over a shared bus with two independent
|
||||
* chip-selects (see Kconfig's EPD_PIN_CS_MASTER/EPD_PIN_CS_SLAVE), unlike
|
||||
* epd7in3e's single-CS interface -- that's an implementation detail of
|
||||
* epd13in3e.c, not something callers of this header need to know about. */
|
||||
#define EPD_WIDTH 1600
|
||||
#define EPD_HEIGHT 1200
|
||||
#define EPD_BYTES_PER_ROW ((EPD_WIDTH + 1) / 2)
|
||||
#define EPD_FRAME_BYTES (EPD_BYTES_PER_ROW * EPD_HEIGHT)
|
||||
|
||||
/* Same 6-ink Spectra family as the 7.3" panel, so the same 6 named
|
||||
* colors -- but whether this panel's controller uses the SAME nibble
|
||||
* values as epd7in3e.h's epd_color_t is UNCONFIRMED (see this file's own
|
||||
* top comment). Left identical to epd7in3e.h's values as the working
|
||||
* assumption; correct these against the vendor demo code once it exists,
|
||||
* alongside server/app/image_pipeline.py's PANEL_CODES if they turn out
|
||||
* to differ (see that file's own comment on PANEL_CODES). */
|
||||
typedef enum {
|
||||
EPD_COLOR_BLACK = 0x0,
|
||||
EPD_COLOR_WHITE = 0x1,
|
||||
EPD_COLOR_YELLOW = 0x2,
|
||||
EPD_COLOR_RED = 0x3,
|
||||
EPD_COLOR_BLUE = 0x5,
|
||||
EPD_COLOR_GREEN = 0x6,
|
||||
} epd_color_t;
|
||||
|
||||
/** Configures SPI + GPIO and runs the panel's power-on register init sequence. */
|
||||
esp_err_t epd_init(void);
|
||||
|
||||
/** Fills the whole panel with a single color and refreshes. */
|
||||
esp_err_t epd_clear(epd_color_t color);
|
||||
|
||||
/**
|
||||
* Called repeatedly by epd_display_stream() to fill up to chunk_size bytes
|
||||
* into chunk. Must return the number of bytes written, or 0 once exhausted.
|
||||
*/
|
||||
typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx);
|
||||
|
||||
/**
|
||||
* Streams a full frame (EPD_FRAME_BYTES bytes, packed 2 pixels/byte) to the
|
||||
* panel via read_fn and refreshes. Pulling from a caller-supplied source
|
||||
* instead of a single buffer lets callers feed the panel directly from an
|
||||
* HTTP response without holding the whole ~960KB frame in RAM.
|
||||
*/
|
||||
esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx);
|
||||
|
||||
/**
|
||||
* Like epd_display_stream(), but writes the frame into the panel's
|
||||
* internal buffer over SPI WITHOUT triggering the physical refresh (the
|
||||
* visible flash/flicker) -- call epd_turn_on_display() separately to make
|
||||
* it visible. Returns ESP_ERR_INVALID_SIZE if read_fn didn't supply
|
||||
* exactly EPD_FRAME_BYTES, same as epd_display_stream(); either way
|
||||
* nothing is refreshed, so the visible screen is left untouched on
|
||||
* error.
|
||||
*
|
||||
* If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written --
|
||||
* lets a caller compare against the last-displayed frame's CRC and skip
|
||||
* the refresh entirely when nothing actually changed (e.g. redisplaying
|
||||
* the same photo after a reboot).
|
||||
*/
|
||||
esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32);
|
||||
|
||||
/**
|
||||
* Triggers the panel's physical refresh cycle (power on, refresh, power
|
||||
* off) -- the visible flash/flicker sequence. Call after epd_write_frame()
|
||||
* to make the written buffer visible.
|
||||
*/
|
||||
esp_err_t epd_turn_on_display(void);
|
||||
|
||||
/** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */
|
||||
esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
|
||||
|
||||
/** Puts the panel into deep sleep to minimize power draw between refreshes. */
|
||||
esp_err_t epd_sleep(void);
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,45 @@
|
||||
# Board-specific overrides for Seeed's EE02 (XIAO ESP32-S3 Plus + 13.3"
|
||||
# Spectra 6 panel), layered on top of sdkconfig.defaults via
|
||||
# SDKCONFIG_DEFAULTS -- see build_for_board.sh, which is the supported
|
||||
# way to build with this file. Don't set this via a plain `idf.py
|
||||
# menuconfig` on the default build; that writes straight into the shared
|
||||
# sdkconfig, not this file.
|
||||
#
|
||||
# Unlike xiao (a same-chip Kconfig-only variant of the ESP32-C6 dev
|
||||
# board), EE02 is a genuinely different chip target (ESP32-S3) --
|
||||
# build_for_board.sh runs `set-target esp32s3` for this board before
|
||||
# building, same as it runs `set-target esp32c6` for devkit/xiao.
|
||||
CONFIG_FRAME_BOARD_NAME="ee02"
|
||||
|
||||
# Selects the epd13in3e driver component (13.3", 1600x1200) instead of
|
||||
# epd7in3e -- see main/Kconfig.projbuild and main/CMakeLists.txt.
|
||||
CONFIG_FRAME_PANEL_EE02_13IN3=y
|
||||
|
||||
# XIAO ESP32-S3 Plus: 16MB flash, 8MB PSRAM (vs. the plain XIAO ESP32-S3's
|
||||
# 8MB/8MB) -- see partitions_ee02.csv, sized generously against this,
|
||||
# not yet trimmed/tuned against a real build's actual footprint.
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_ee02.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions_ee02.csv"
|
||||
|
||||
# EE02's e-paper interface pin defaults live in
|
||||
# firmware/components/epd13in3e/Kconfig instead of being overridden here
|
||||
# (mirrors how epd7in3e's Kconfig defaults are devkit-shaped and
|
||||
# sdkconfig.xiao only overrides the ones that actually differ) -- EE02's
|
||||
# pins are a different Kconfig menu entirely (EPD_PIN_CS_MASTER/CS_SLAVE/
|
||||
# POWER_EN don't exist on epd7in3e's board at all), not a same-menu
|
||||
# override, so there's nothing to set here beyond selecting the component
|
||||
# above.
|
||||
#
|
||||
# Deliberately NOT overriding FRAME_NEXT_BUTTON_GPIO/FRAME_BACK_BUTTON_
|
||||
# GPIO/FRAME_COMBO_BUTTON_GPIO/FRAME_BATTERY_ADC_GPIO here, even though
|
||||
# the same community source that gave the epd13in3e pinout also reports
|
||||
# EE02 has 3 user buttons at GPIO2/3/5: (1) which physical button maps to
|
||||
# which logical role (next/back/combo) isn't confirmed, and (2) more
|
||||
# importantly, those Kconfig options' `range -1 7`/`range -1 6`
|
||||
# constraints (main/Kconfig.projbuild) are hardcoded to the ESP32-C6's
|
||||
# deep-sleep-wakeup-capable GPIO set -- NOT verified against which pins
|
||||
# can actually wake the ESP32-S3 from deep sleep, which is a different
|
||||
# set on a different chip. Confirm both before wiring this up; widen/
|
||||
# adjust those Kconfig ranges if the real wakeup-capable pins for
|
||||
# whichever GPIOs EE02's buttons land on fall outside them.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,40 @@ 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 -- confirmed from
|
||||
# Waveshare's/Seeed's public product pages, not yet from real
|
||||
# hardware or vendor demo code (see firmware/components/epd13in3e's
|
||||
# own docstring once it exists -- the init/LUT/refresh sequence and
|
||||
# whether this panel's nibble color codes actually match PANEL_CODES
|
||||
# below are still unconfirmed pending that).
|
||||
"epd13in3e": (1600, 1200),
|
||||
}
|
||||
|
||||
# Human-readable label per PANEL_SPECS key, for the frame settings page's
|
||||
# read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how
|
||||
# a frame's panel_type actually gets set -- this is display-only).
|
||||
PANEL_LABELS: dict[str, str] = {
|
||||
"epd7in3e": '7.3" Spectra 6',
|
||||
"epd13in3e": '13.3" Spectra 6',
|
||||
}
|
||||
|
||||
|
||||
def panel_size(panel_type: str) -> tuple[int, int]:
|
||||
"""(width, height) native pixel size for a Frame.panel_type key.
|
||||
Unknown/blank panel_type (e.g. a frame created before this field
|
||||
existed) falls back to the original 7.3" panel this project shipped
|
||||
with, never raises."""
|
||||
return PANEL_SPECS.get(panel_type, PANEL_SPECS[DEFAULT_PANEL_TYPE])
|
||||
|
||||
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
||||
# pixels). Those survive straight into _quantize's Floyd-Steinberg
|
||||
# dithering, which -- confirmed visually -- turns them into scattered
|
||||
@@ -146,21 +180,24 @@ ORIENTATION_TRANSPOSE = {
|
||||
}
|
||||
|
||||
|
||||
def logical_render_size(orientation: str) -> tuple[int, int]:
|
||||
def logical_render_size(orientation: str, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
|
||||
"""(width, height) the photo is composed/cropped at for this
|
||||
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 +245,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 -- unverified whether the 13.3" panel's
|
||||
# driver (once it exists, see PANEL_SPECS["epd13in3e"]) uses the same
|
||||
# codes; if not, this needs to become a PANEL_CODE_TABLES dict keyed like
|
||||
# PANEL_SPECS, with _transpose_and_pack taking the right one as a param.
|
||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||
|
||||
# Per-widget optional border (models.Widget.border_style, see
|
||||
@@ -428,11 +469,13 @@ def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, t
|
||||
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
|
||||
|
||||
|
||||
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:
|
||||
@@ -464,17 +507,22 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float
|
||||
|
||||
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
||||
"""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."""
|
||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||
if transpose is not None:
|
||||
quantized = quantized.transpose(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 +549,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,8 +579,14 @@ 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)
|
||||
@@ -547,7 +601,8 @@ def _png_bytes(img: Image.Image) -> bytes:
|
||||
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
||||
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 +641,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
|
||||
from the same already-quantized canvas, so a device-facing render can
|
||||
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))
|
||||
@@ -607,13 +668,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 +685,8 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
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 +695,9 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
`manage`, same as render_frame's -- lets the manage button still work
|
||||
(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
|
||||
|
||||
|
||||
@@ -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,83 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user