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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user