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.
298 lines
13 KiB
Python
298 lines
13 KiB
Python
"""Every renderer that produces a device-facing frame must return
|
|
exactly EPD_WIDTH*EPD_HEIGHT/2 bytes (the panel's packed 2px/byte
|
|
format) -- firmware writes this straight to the display with no length
|
|
checking of its own, so a renderer that's off by even one byte is a
|
|
silent on-device corruption bug, not a clean error. This is a cheap,
|
|
high-value regression guard: pure PIL rendering, no DB/HTTP/Node."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from app.calendar_render import CALENDAR_VIEWS, render_calendar, render_tasks
|
|
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
|
|
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
|
|
|
|
EXPECTED_BYTES = EPD_WIDTH * EPD_HEIGHT // 2
|
|
ORIENTATIONS = ["landscape", "landscape_flipped", "portrait", "portrait_flipped"]
|
|
|
|
_SAMPLE_EVENTS = [
|
|
{
|
|
"summary": "Dentist", "start": "2026-08-01T14:00:00+00:00", "end": "2026-08-01T15:00:00+00:00",
|
|
"all_day": False, "sources": [{"owner_display_name": "Alice", "color_index": None}],
|
|
},
|
|
{
|
|
"summary": "Team Offsite", "start": "2026-08-03T00:00:00", "end": "2026-08-04T00:00:00",
|
|
"all_day": True, "sources": [{"owner_display_name": "Bob", "color_index": 2}],
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_placeholder_render_size(orientation):
|
|
data = render_placeholder(["Not configured yet"], orientation=orientation)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
@pytest.mark.parametrize("view", CALENDAR_VIEWS)
|
|
def test_calendar_render_size_across_views(view):
|
|
data = render_calendar(_SAMPLE_EVENTS, view, browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_calendar_render_size_across_orientations(orientation):
|
|
data = render_calendar(_SAMPLE_EVENTS, "agenda", browse_offset=0, orientation=orientation,
|
|
palette_rgb=None, timezone="UTC")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_calendar_render_size_empty_events():
|
|
data = render_calendar([], "week", browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_calendar_render_size_with_fetch_summary():
|
|
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable",
|
|
week_days=5, week_layout="vertical")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_calendar_render_size_with_week_start_offset():
|
|
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC", week_days=3, week_start_offset=2)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
# --- tasks widget (split out of the calendar widget's old week-view-only task list) ---
|
|
|
|
_SAMPLE_TASKS = [
|
|
{"summary": "Buy milk", "due": "2026-08-02"},
|
|
{"summary": "Walk the dog", "due": None},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_tasks_render_size_across_orientations(orientation):
|
|
data = render_tasks(_SAMPLE_TASKS, orientation=orientation, palette_rgb=None)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_tasks_render_size_empty():
|
|
data = render_tasks([], orientation="landscape", palette_rgb=None)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
# --- render_panel (the widget-system compositor) ---
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_render_panel_size_single_full_panel_region(orientation):
|
|
from app.image_pipeline import logical_render_size
|
|
|
|
w, h = logical_render_size(orientation)
|
|
region = Image.new("RGB", (w, h), (200, 0, 0))
|
|
data = render_panel([((0, 0, w, h), region)], orientation=orientation)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_render_panel_size_multiple_non_overlapping_regions():
|
|
cols, rows = grid_dims("landscape")
|
|
left_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, (0, 0, cols // 2, rows))
|
|
right_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, (cols // 2, 0, cols - cols // 2, rows))
|
|
regions = [
|
|
(left_px, Image.new("RGB", left_px[2:], (200, 0, 0))),
|
|
(right_px, Image.new("RGB", right_px[2:], (0, 0, 200))),
|
|
]
|
|
data = render_panel(regions, orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_render_panel_empty_region_list_is_blank_but_correctly_sized():
|
|
"""No widgets on a frame yet (or all somehow filtered out) shouldn't
|
|
crash the compositor -- just a blank panel, same size invariant."""
|
|
data = render_panel([], orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_render_panel_pastes_regions_at_the_right_place():
|
|
"""Not just a size check -- confirms two regions actually land where
|
|
their rects say, not just that *something* the right size comes out."""
|
|
cols, rows = grid_dims("landscape")
|
|
left_rect = (0, 0, cols // 2, rows)
|
|
right_rect = (cols // 2, 0, cols - cols // 2, rows)
|
|
left_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, left_rect)
|
|
right_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, right_rect)
|
|
# Pure red vs pure blue, both already-palette colors, dither_strength=0
|
|
# so quantization can't introduce any blending/dithering noise --
|
|
# every pixel on each side should land on exactly the color it started as.
|
|
regions = [
|
|
(left_px, Image.new("RGB", left_px[2:], (255, 0, 0))),
|
|
(right_px, Image.new("RGB", right_px[2:], (0, 0, 255))),
|
|
]
|
|
data = render_panel(regions, orientation="landscape", dither_strength=0.0)
|
|
|
|
from app.image_pipeline import PANEL_CODES
|
|
|
|
def code_at(x, y):
|
|
i = (y * EPD_WIDTH + x) // 2
|
|
byte = data[i]
|
|
return (byte >> 4) if x % 2 == 0 else (byte & 0x0F)
|
|
|
|
red_code = PANEL_CODES[3] # DEFAULT_PALETTE_RGB index 3 = RED
|
|
blue_code = PANEL_CODES[4] # index 4 = BLUE
|
|
# Sample well inside each half, away from the boundary, at a y
|
|
# comfortably inside the panel.
|
|
assert code_at(50, 240) == red_code
|
|
assert code_at(750, 240) == blue_code
|
|
|
|
|
|
def test_render_panel_backfilled_full_panel_widget_matches_grid_full_panel_rect():
|
|
"""Sanity-links app.grid's full_panel_rect (what the migration backfill
|
|
uses for the single auto-migrated widget) to render_panel's own size
|
|
invariant, so a mismatch between the two would fail loudly here."""
|
|
rect = full_panel_rect("landscape")
|
|
px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, rect)
|
|
assert px == (0, 0, EPD_WIDTH, EPD_HEIGHT)
|
|
region = Image.new("RGB", px[2:], (10, 20, 30))
|
|
data = render_panel([(px, region)], orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
# --- a second, synthetic panel size (proves the packing path is genuinely
|
|
# resolution-agnostic, ahead of the real 13.3" panel's numbers existing --
|
|
# see image_pipeline._transpose_and_pack, which derives its output size
|
|
# from the quantized image itself rather than a hardcoded EPD_WIDTH/
|
|
# EPD_HEIGHT global) ---
|
|
|
|
|
|
@pytest.fixture
|
|
def synthetic_panel(monkeypatch):
|
|
"""Registers a second PANEL_SPECS entry, a different size than the
|
|
real 7.3" panel, without needing the real 13.3" panel's confirmed
|
|
resolution to exist yet."""
|
|
from app import image_pipeline
|
|
|
|
monkeypatch.setitem(image_pipeline.PANEL_SPECS, "test_panel", (600, 400))
|
|
return "test_panel", 600, 400
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_render_panel_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation):
|
|
from app.image_pipeline import logical_render_size
|
|
|
|
panel_type, panel_w, panel_h = synthetic_panel
|
|
w, h = logical_render_size(orientation, panel_w, panel_h)
|
|
region = Image.new("RGB", (w, h), (200, 0, 0))
|
|
data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type=panel_type)
|
|
assert len(data) == panel_w * panel_h // 2
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_render_placeholder_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation):
|
|
panel_type, panel_w, panel_h = synthetic_panel
|
|
data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type=panel_type)
|
|
assert len(data) == panel_w * panel_h // 2
|
|
|
|
|
|
def test_render_panel_default_panel_type_is_unaffected_by_a_new_registry_entry(synthetic_panel):
|
|
"""A second PANEL_SPECS entry existing must never change what an
|
|
ordinary (no panel_type passed) render produces -- every existing
|
|
7.3" frame's output stays byte-identical regardless of what other
|
|
panels get registered."""
|
|
data = render_placeholder(["Not configured yet"], orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_panel_size_falls_back_to_the_original_panel_for_unknown_types():
|
|
from app.image_pipeline import panel_size
|
|
|
|
assert panel_size("nonexistent") == (EPD_WIDTH, EPD_HEIGHT)
|
|
assert panel_size("") == (EPD_WIDTH, EPD_HEIGHT)
|
|
|
|
|
|
# --- the real (not synthetic) 13.3" Spectra 6 / EE02 panel geometry,
|
|
# confirmed from Waveshare's/Seeed's public product pages -- the vendor
|
|
# init/LUT/refresh sequence firmware-side is still unconfirmed (see
|
|
# image_pipeline.PANEL_SPECS's own comment), but the geometry itself is
|
|
# real, not a placeholder, so it gets the same coverage as the 7.3" panel
|
|
# rather than just the synthetic-panel tests above. ---
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_render_panel_size_for_the_real_13in3_panel(orientation):
|
|
from app.image_pipeline import PANEL_SPECS, logical_render_size
|
|
|
|
panel_w, panel_h = PANEL_SPECS["epd13in3e"]
|
|
w, h = logical_render_size(orientation, panel_w, panel_h)
|
|
region = Image.new("RGB", (w, h), (200, 0, 0))
|
|
data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type="epd13in3e")
|
|
assert len(data) == panel_w * panel_h // 2
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_render_placeholder_size_for_the_real_13in3_panel(orientation):
|
|
from app.image_pipeline import PANEL_SPECS
|
|
|
|
panel_w, panel_h = PANEL_SPECS["epd13in3e"]
|
|
data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type="epd13in3e")
|
|
assert len(data) == panel_w * panel_h // 2
|
|
|
|
|
|
def test_transpose_and_pack_epd13in3e_uses_true_wire_raster_stride():
|
|
"""Regression guard for a corruption bug, not just a rotation bug: the
|
|
13.3" panel's SPI controller addresses a native 1200x1600 raster (600
|
|
bytes/row x 1600 rows), rotated 90 degrees from PANEL_SPECS's
|
|
1600x1200 mount/marketing size (800 bytes/row x 1200 rows) -- see
|
|
PANEL_WIRE_TRANSPOSE's own comment. Both shapes pack to the identical
|
|
960000-byte total, so a regression here wouldn't fail a plain length
|
|
assertion -- it would ship a driver that slices real image rows at the
|
|
wrong byte offsets and shreds the picture on a real panel.
|
|
|
|
This probes stride, not rotation direction: a vertical stripe (values
|
|
constant along the *mount* image's y-axis) stays constant along
|
|
whichever axis absorbs that constancy under ANY 90-degree-multiple
|
|
rotation, so this holds regardless of which direction
|
|
PANEL_WIRE_TRANSPOSE ends up using -- only the true 600-byte wire row
|
|
stride makes each decoded row uniform; decoding at the wrong (800-byte
|
|
mount) stride would slice across real row boundaries and mix both
|
|
colors into every "row"."""
|
|
from PIL import ImageDraw
|
|
|
|
from app.image_pipeline import (
|
|
DEFAULT_PALETTE_RGB,
|
|
PANEL_SPECS,
|
|
_build_palette_image,
|
|
_transpose_and_pack,
|
|
)
|
|
|
|
mount_w, mount_h = PANEL_SPECS["epd13in3e"] # (1600, 1200)
|
|
wire_w, wire_h = mount_h, mount_w # (1200, 1600) -- the true SPI wire raster
|
|
|
|
img = Image.new("RGB", (mount_w, mount_h), (255, 255, 255))
|
|
ImageDraw.Draw(img).rectangle([0, 0, mount_w // 2 - 1, mount_h - 1], fill=(0, 0, 0))
|
|
quantized = img.quantize(palette=_build_palette_image(DEFAULT_PALETTE_RGB))
|
|
|
|
packed = _transpose_and_pack(quantized, "landscape", panel_type="epd13in3e")
|
|
assert len(packed) == wire_w * wire_h // 2
|
|
|
|
row_bytes = wire_w // 2 # 600 -- the true wire row stride
|
|
first_row = packed[0:row_bytes]
|
|
last_row = packed[(wire_h - 1) * row_bytes: wire_h * row_bytes]
|
|
|
|
def nibbles(row_bytes_slice):
|
|
vals = set()
|
|
for b in row_bytes_slice:
|
|
vals.add(b >> 4)
|
|
vals.add(b & 0x0F)
|
|
return vals
|
|
|
|
first_nibbles, last_nibbles = nibbles(first_row), nibbles(last_row)
|
|
assert len(first_nibbles) == 1, "first wire row should be a single color at the true 600-byte stride"
|
|
assert len(last_nibbles) == 1, "last wire row should be a single color at the true 600-byte stride"
|
|
assert first_nibbles != last_nibbles, "the black/white split should still show up across wire rows"
|