Add server-side support for a second panel (13.3in Spectra 6 / EE02) and scaffold its firmware target
Build and push server image / test (push) Successful in 45s
Firmware build check / build-check (push) Successful in 2m50s
Build and push server image / build-and-push (push) Successful in 4m36s
Build and push server image / deploy (push) Failing after 1m34s

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:
2026-08-04 20:08:22 +00:00
parent 1d39e439ff
commit 474b92a282
44 changed files with 1287 additions and 172 deletions
+20 -15
View File
@@ -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)
+4 -3
View File
@@ -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
+4 -3
View File
@@ -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)
+99 -35
View File
@@ -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
+13
View File
@@ -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),
]
+7
View File
@@ -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
+19 -8
View File
@@ -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")
+2 -2
View File
@@ -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)
+38 -4
View File
@@ -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),
+2
View File
@@ -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,
+3
View File
@@ -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)
+1
View File
@@ -108,6 +108,7 @@
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
&middot; 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" }})
&middot; panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
&middot; 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">
+4
View File
@@ -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 %}
+4 -3
View File
@@ -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)
+2 -2
View File
@@ -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()
+4 -3
View File
@@ -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()