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