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.
244 lines
10 KiB
Python
244 lines
10 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
|