Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.
Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.
Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
54 lines
2.2 KiB
Python
54 lines
2.2 KiB
Python
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
|
|
Stats tabs, all inside the sidebar app shell. Data loading happens
|
|
client-side against /api/frames/{id}/... (routers/api_frames.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.orm import Session
|
|
|
|
from ..auth import can_view_frame, current_user
|
|
from ..db import get_db
|
|
from ..image_pipeline import DEFAULT_PALETTE_RGB, PALETTE_LABELS, palette_to_hex
|
|
from ..models import Frame
|
|
from ..quiet_hours import ALL_TIMEZONES
|
|
from .common import shell_context
|
|
|
|
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_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
|
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
|
|
|
|
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
|
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
|
return _frame_page(
|
|
request, db, frame_id, "frame_config.html", "config",
|
|
timezones=ALL_TIMEZONES,
|
|
palette_labels=PALETTE_LABELS,
|
|
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
|
palette_to_hex=palette_to_hex,
|
|
)
|
|
|
|
|
|
@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") |