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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
|
||||
· 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" }})
|
||||
· panel: {{ panel_labels.get(f.panel_type, f.panel_type) }}
|
||||
· 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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user