Port real epd13in3e driver from vendor code; fix wire-raster stride bug
Build and push server image / test (push) Successful in 43s
Firmware build check / build-check (push) Successful in 2m47s
Build and push server image / build-and-push (push) Successful in 4m34s
Build and push server image / deploy (push) Failing after 1m27s

Vendored the panel's init/LUT/refresh register sequence from three
independent Waveshare reference drivers for this exact panel+controller
(RaspberryPi/c, ESP32, and the ESP32-S3-ePaper-13.3E6 ESP-IDF example),
which all agree byte-for-byte. The epd13in3e.c #error is gone; it
compiles clean and links (verified via /build-firmware ee02).

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

A full ee02 build still fails, but no longer because of this driver --
main/{back,next,combo}_button.c call an ESP32-C6-only deep-sleep
GPIO-wakeup API with no ESP32-S3 fallback, a separate pre-existing gap
that was simply hidden behind the panel driver's old #error. See
docs/hardware.md for details; CI's continue-on-error on this board
stays in place until that's fixed too.
This commit is contained in:
2026-08-04 22:00:18 +00:00
parent fe5a2df074
commit 454c03586e
6 changed files with 464 additions and 160 deletions
+57 -15
View File
@@ -19,15 +19,47 @@ 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).
# 1600x1200px, 4:3) driven by Seeed's EE02 board. This is the panel's
# MOUNT/marketing size, not its SPI wire raster -- the controller
# itself addresses a native 1200x1600 (portrait) raster, rotated 90
# degrees from how the panel physically hangs. Both facts are now
# vendor-confirmed (see firmware/components/epd13in3e's own docstring)
# -- PANEL_SPECS stays in mount/logical terms like the 7.3" panel's
# entry (everything upstream of packing -- composition, the widget
# grid, face-label placement -- reasons in this space); the wire-raster
# rotation is applied only at pack time, see PANEL_WIRE_TRANSPOSE.
"epd13in3e": (1600, 1200),
}
# Panels whose SPI wire raster is rotated 90 degrees from PANEL_SPECS's
# mount/logical size (see that dict's own comment on epd13in3e). None =
# wire raster already matches the logical size, no extra rotation (true
# for the 7.3" panel). Applied in _transpose_and_pack AFTER the
# user-selected ORIENTATION_TRANSPOSE -- these are two independent
# rotations for two independent reasons (how the frame is hung vs. a fixed
# fact about this panel's controller wiring) and must not be conflated.
#
# Getting this wrong doesn't just rotate the output image: 1600x1200 and
# 1200x1600 don't share a row stride (800 bytes/row x 1200 rows vs 600
# bytes/row x 1600 rows), so packing at the wrong one slices real image
# rows at the wrong byte offsets and shreds the picture into a repeating
# diagonal garble on the real panel, not a clean rotation -- see
# test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride in
# tests/test_render_size_invariants.py, which catches exactly that
# regression without needing real hardware.
#
# Direction (ROTATE_90 vs ROTATE_270) is a physical-assembly fact this
# code can't derive from vendor driver bytes -- it depends on which edge
# of the panel ends up "up" in this project's frame housing. Picked
# ROTATE_90 as a documented placeholder; confirm/flip against real
# hardware once the EE02 firmware target is actually flashed and
# displaying (a wrong direction shows a rotated/mirrored image, not
# corruption, so it's safe to ship pending that check).
PANEL_WIRE_TRANSPOSE: dict[str, "Image.Transpose | None"] = {
"epd7in3e": None,
"epd13in3e": Image.Transpose.ROTATE_90,
}
# Human-readable label per PANEL_SPECS key, for the frame settings page's
# read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how
# a frame's panel_type actually gets set -- this is display-only).
@@ -249,10 +281,10 @@ CALIBRATED_SPECTRA6_RGB = [
# 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.
# for every panel_type today -- confirmed (not just assumed) that the
# 13.3" panel's controller uses the identical codes, from the same vendor
# driver sources as PANEL_SPECS["epd13in3e"]'s own comment, so no
# panel-specific table is needed here.
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
# Per-widget optional border (models.Widget.border_style, see
@@ -505,17 +537,27 @@ def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
def _transpose_and_pack(quantized: Image.Image, orientation: str,
panel_type: str = DEFAULT_PANEL_TYPE) -> bytes:
"""Rotates a logical-space quantized image into native panel space
and packs it 2 pixels/byte the way 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."""
rather than a fixed global.
Two independent rotations happen here, in order: ORIENTATION_TRANSPOSE
(how the frame is physically hung -- a per-frame user choice), then
PANEL_WIRE_TRANSPOSE (a fixed fact about this panel_type's SPI wire
raster vs. its mount size -- see that dict's own comment). Most panels
need only the first; epd13in3e needs both."""
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
wire_transpose = PANEL_WIRE_TRANSPOSE.get(panel_type)
if wire_transpose is not None:
quantized = quantized.transpose(wire_transpose)
pixels = quantized.load()
w, h = quantized.size
@@ -589,7 +631,7 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
return _transpose_and_pack(quantized, orientation, panel_type)
def _png_bytes(img: Image.Image) -> bytes:
@@ -658,7 +700,7 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
quantized = _quantize(fitted, palette_rgb, dither_strength)
if as_png:
return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation)
packed = _transpose_and_pack(quantized, orientation, panel_type)
if capture_snapshot:
return packed, _png_bytes(quantized)
return packed
@@ -760,7 +802,7 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
if as_png:
return _png_bytes(quantized)
packed = _transpose_and_pack(quantized, orientation)
packed = _transpose_and_pack(quantized, orientation, panel_type)
if capture_snapshot:
return packed, _png_bytes(quantized)
return packed