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
+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),