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.
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
|
|
canvas), Configuration, and Stats, all inside the sidebar app shell.
|
|
Each widget's own settings (album, calendar view/inclusion, whiteboard
|
|
source, etc.) no longer have their own tab/page -- they're a dialog
|
|
opened from a gear icon on the widget's box in the Layout canvas (see
|
|
static/frame_layout.js), whose content this module also serves (the
|
|
/widgets/{widget_id}/dialog route) as a small HTML fragment, not a full
|
|
page. Data loading otherwise happens client-side against
|
|
/api/frames/{id}/... (routers/api_frames.py, routers/api_widgets.py);
|
|
these routes just authorize and render the scaffold."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import panel_style, theme_tokens, weather
|
|
from ..auth import can_view_frame, current_user
|
|
from ..calendar_render import CALENDAR_VIEW_LABELS
|
|
from ..db import get_db
|
|
from ..global_actions import GLOBAL_ACTION_LABELS
|
|
from ..image_pipeline import (
|
|
BORDER_STYLES,
|
|
BORDER_STYLE_LABELS,
|
|
CALIBRATED_SPECTRA6_RGB,
|
|
DEFAULT_PALETTE_RGB,
|
|
DISPLAY_MODE_LABELS,
|
|
MAX_BORDER_THICKNESS,
|
|
MIN_BORDER_THICKNESS,
|
|
PALETTE_LABELS,
|
|
PANEL_LABELS,
|
|
STATIC_DISPLAY_MODES,
|
|
palette_to_hex,
|
|
)
|
|
from ..models import (
|
|
BatteryWidgetConfig,
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
StaticWidgetConfig,
|
|
TaskWidgetConfig,
|
|
TextWidgetConfig,
|
|
User,
|
|
UserFrame,
|
|
WeatherWidgetConfig,
|
|
WhiteboardWidgetConfig,
|
|
Widget,
|
|
)
|
|
from ..quiet_hours import ALL_TIMEZONES
|
|
from ..widgets import WIDGET_TYPES
|
|
from ..widgets import text as text_widget
|
|
from .common import shell_context, widget_of_type
|
|
|
|
router = APIRouter()
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
|
|
def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra):
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303)
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None or not can_view_frame(db, user, frame):
|
|
raise HTTPException(404, "No such frame")
|
|
ctx = shell_context(request, db, user, active_frame=frame)
|
|
ctx.update({"frame": frame, "active_tab": tab, **extra})
|
|
return templates.TemplateResponse(template, ctx)
|
|
|
|
|
|
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
|
def frame_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
|
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
|
|
|
|
|
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
|
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
|
frame = db.get(Frame, frame_id)
|
|
photo_widget_id = None
|
|
if frame is not None:
|
|
photo_widget = widget_of_type(db, frame, "photos")
|
|
if photo_widget is not None:
|
|
photo_widget_id = photo_widget.id
|
|
return _frame_page(
|
|
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,
|
|
photo_widget_id=photo_widget_id,
|
|
global_action_labels=GLOBAL_ACTION_LABELS,
|
|
themes=theme_tokens.THEMES,
|
|
)
|
|
|
|
|
|
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
|
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
|
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
|
|
|
|
|
|
# --- Per-widget config dialog content -------------------------------------
|
|
|
|
def _user_available_calendars(user: User) -> list[dict]:
|
|
"""This user's full set of calendars available to add to any widget:
|
|
the single ICS subscription (if set) plus every CalDAV calendar last
|
|
discovered from Settings' "Discover calendars" button. Doesn't hit
|
|
the network -- reads the cached list a user refreshes themselves."""
|
|
calendars = []
|
|
if user.calendar_ics_url:
|
|
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
|
|
for c in (user.calendar_caldav_calendars or []):
|
|
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
|
|
return calendars
|
|
|
|
|
|
def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
|
|
"""Per-linked-user calendar list for the calendar dialog's "Included
|
|
calendars" section. The viewer's own row lists EVERY calendar they
|
|
have available, each with a full add/remove toggle; every other
|
|
linked user's row lists ONLY the calendars they've already included
|
|
(mute-only for the viewer -- see api_widgets.py's
|
|
api_widget_calendar_select: only a calendar's owner may turn it on,
|
|
but anyone linked to the frame may turn one off)."""
|
|
users = db.execute(
|
|
select(User).join(UserFrame, UserFrame.user_id == User.id)
|
|
.where(UserFrame.frame_id == frame_id).order_by(User.username)
|
|
).scalars().all()
|
|
included_by_user: dict[int, list[FrameCalendar]] = {}
|
|
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.widget_id == widget_id)).scalars().all():
|
|
included_by_user.setdefault(fc.user_id, []).append(fc)
|
|
|
|
result = []
|
|
for u in users:
|
|
is_self = u.id == viewer_id
|
|
if is_self:
|
|
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
|
|
calendars = [
|
|
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
|
|
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
|
|
for c in _user_available_calendars(u)
|
|
]
|
|
else:
|
|
calendars = [
|
|
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
|
|
for fc in included_by_user.get(u.id, []) if fc.included
|
|
]
|
|
result.append({
|
|
"user_id": u.id, "display_name": u.display_name or u.username,
|
|
"is_self": is_self, "calendars": calendars,
|
|
})
|
|
return result
|
|
|
|
|
|
def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
|
|
"""Per-linked-user task-list list for the tasks dialog's "Included
|
|
task lists" section -- same shape as _calendar_users_for_widget,
|
|
restricted to CalDAV calendars only (no "ics" option: a plain ICS
|
|
subscription has no VTODO collection to speak of, see
|
|
caldav_client.fetch_tasks)."""
|
|
users = db.execute(
|
|
select(User).join(UserFrame, UserFrame.user_id == User.id)
|
|
.where(UserFrame.frame_id == frame_id).order_by(User.username)
|
|
).scalars().all()
|
|
included_by_user: dict[int, list[FrameTaskList]] = {}
|
|
for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all():
|
|
included_by_user.setdefault(ftl.user_id, []).append(ftl)
|
|
|
|
result = []
|
|
for u in users:
|
|
is_self = u.id == viewer_id
|
|
available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")]
|
|
if is_self:
|
|
own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])}
|
|
task_lists = [
|
|
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
|
|
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
|
|
for c in available
|
|
]
|
|
else:
|
|
task_lists = [
|
|
{"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True}
|
|
for ftl in included_by_user.get(u.id, []) if ftl.included
|
|
]
|
|
result.append({
|
|
"user_id": u.id, "display_name": u.display_name or u.username,
|
|
"is_self": is_self, "task_lists": task_lists,
|
|
})
|
|
return result
|
|
|
|
|
|
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
|
|
"""Whose account this widget currently fetches with, for showing
|
|
"using <name>'s account" to everyone linked, not just whoever set
|
|
it. None if no source is configured."""
|
|
if not whiteboard_cfg.user_id or not whiteboard_cfg.url:
|
|
return None
|
|
user = db.get(User, whiteboard_cfg.user_id)
|
|
if user is None:
|
|
return None
|
|
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
|
|
|
|
|
|
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
|
|
4: "Friday", 5: "Saturday", 6: "Sunday"}
|
|
|
|
|
|
@router.get("/frames/{frame_id}/widgets/{widget_id}/dialog", response_class=HTMLResponse)
|
|
def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = Depends(get_db)):
|
|
"""The gear-icon dialog's content, dispatched by widget_type -- a
|
|
small HTML fragment (no app_base shell/tabs), fetched and injected
|
|
into a <dialog> by static/frame_layout.js. Not itself a page a user
|
|
would navigate to directly."""
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
raise HTTPException(401, "Not logged in")
|
|
frame = db.get(Frame, frame_id)
|
|
if frame is None or not can_view_frame(db, user, frame):
|
|
raise HTTPException(404, "No such frame")
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
|
|
# Every dialog includes the shared "Border" card (_widget_border_fields.html,
|
|
# models.Widget.border_style/border_thickness/border_color_index) --
|
|
# a Widget-level property, not a per-type config field, so this
|
|
# context is the same regardless of widget_type.
|
|
border_ctx = {
|
|
"border_styles": BORDER_STYLES,
|
|
"border_style_labels": BORDER_STYLE_LABELS,
|
|
"border_color_labels": PALETTE_LABELS,
|
|
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
|
"palette_to_hex": palette_to_hex,
|
|
"min_border_thickness": MIN_BORDER_THICKNESS,
|
|
"max_border_thickness": MAX_BORDER_THICKNESS,
|
|
}
|
|
|
|
# Every dialog also includes the shared "Button actions" card
|
|
# (_widget_button_fields.html) if this widget type supports any --
|
|
# empty for tasks/static/text/battery, so the card renders nothing
|
|
# for those. Bindings, not the type's own config, so this lives in
|
|
# FrameButtonAction (see models.py), same reasoning as border_ctx
|
|
# above for why it's a separate card/endpoint from the type-specific
|
|
# form.
|
|
bindings = {
|
|
row.button: row.action
|
|
for row in db.scalars(
|
|
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
|
|
)
|
|
}
|
|
button_ctx = {
|
|
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
|
|
"next_button_action": bindings.get("next", ""),
|
|
"back_button_action": bindings.get("back", ""),
|
|
}
|
|
|
|
# The shared "Text size" card (_widget_font_scale_fields.html,
|
|
# models.Widget.font_scale) -- only included on the dialogs whose
|
|
# on-panel content is mostly body text (calendar/tasks; the other
|
|
# types are either image-only or, for text, already have their own
|
|
# richer per-widget font_size control -- see TextWidgetConfig).
|
|
font_scale_labels = {1.0: "Normal", 1.25: "Large", 1.5: "X-Large"}
|
|
font_scale_ctx = {
|
|
"font_scale_choices": [(v, font_scale_labels[v]) for v in panel_style.FONT_SCALE_CHOICES],
|
|
}
|
|
|
|
if widget.widget_type == "photos":
|
|
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_photos.html", {
|
|
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
|
|
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "calendar":
|
|
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_calendar.html", {
|
|
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
|
|
"calendar_views": CALENDAR_VIEW_LABELS,
|
|
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
|
|
"week_start_labels": WEEK_START_LABELS,
|
|
"calendar_color_labels": PALETTE_LABELS,
|
|
**border_ctx, **button_ctx, **font_scale_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "tasks":
|
|
task_cfg = db.get(TaskWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_tasks.html", {
|
|
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
|
|
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
|
|
"task_color_labels": PALETTE_LABELS,
|
|
**border_ctx, **button_ctx, **font_scale_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "static":
|
|
static_cfg = db.get(StaticWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_static.html", {
|
|
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
|
|
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
|
|
**border_ctx, **button_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "text":
|
|
text_cfg = db.get(TextWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_text.html", {
|
|
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
|
|
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "whiteboard":
|
|
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
|
|
viewer_has_webdav_creds = bool(
|
|
user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username)
|
|
)
|
|
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
|
|
"request": request, "frame": frame, "widget": widget, "user": user,
|
|
"whiteboard_cfg": whiteboard_cfg,
|
|
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
|
|
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "weather":
|
|
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
|
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
|
|
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
|
|
})
|
|
|
|
if widget.widget_type == "battery":
|
|
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
|
|
return templates.TemplateResponse("_widget_dialog_battery.html", {
|
|
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
|
|
})
|
|
|
|
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|