Adds the actual "Android home screen" placement experience: a new Layout tab with a pointer-driven canvas for dragging/resizing widgets and adding new ones from a type picker. Backed by a new routers/api_widgets.py (create/move/delete), which re-validates bounds, minimum footprint, and no-overlap server-side regardless of what the client already checked. A widget added without an explicit position lands in the first open space that fits it (grid.find_open_rect), so users don't have to hunt for empty space themselves. Also fixes a real latent bug this surfaced: changing a frame's orientation swaps the widget grid's long/short axis, which left existing widget placements out of bounds on the new grid with no render-time safeguard. Orientation changes now reset the layout to a single full-panel widget (keeping the first widget's type, dropping the rest), with a client-side confirm before it happens.
110 lines
4.4 KiB
Python
110 lines
4.4 KiB
Python
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
|
|
for the panel, and serves ESP32 frames ready-to-display images.
|
|
|
|
This module is assembly only -- routes live in app/routers/:
|
|
device.py the firmware-facing /frame/* protocol (paths frozen)
|
|
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
|
api_widgets.py widget CRUD + grid placement, /api/frames/{id}/widgets
|
|
frame_pages.py the per-frame Photos/Configuration/Layout/Stats pages
|
|
pages.py setup/login/claim/settings/admin
|
|
manage.py the limited manage-QR surface (/m/, /api/m/)
|
|
Storage is SQLite via models.py/db.py; migration.py imports a
|
|
pre-database config.json deployment on first boot."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import select
|
|
|
|
from . import migration
|
|
from .auth import (
|
|
browser_token_valid,
|
|
current_user,
|
|
management_token,
|
|
user_frames,
|
|
users_exist,
|
|
)
|
|
from .db import SessionLocal
|
|
from .models import Frame
|
|
from .routers import api_frames, api_widgets, device, frame_pages, manage, pages
|
|
from .routers.common import shell_context
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Schema + legacy-config import, before the first request is served.
|
|
migration.run_migrations()
|
|
|
|
app = FastAPI(title="ESPresso Frame Server")
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
|
|
app.include_router(device.router)
|
|
app.include_router(api_frames.router)
|
|
app.include_router(api_widgets.router)
|
|
app.include_router(frame_pages.router)
|
|
app.include_router(pages.router)
|
|
app.include_router(manage.router)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
|
"""The on-frame manage QR points at the server root with the device's
|
|
own credentials (new firmware: ?id=&token=; deployed firmware:
|
|
?token=<legacy shared token>). Those scans get the frame's limited
|
|
manage page -- never the full UI, which requires a login.
|
|
allow_legacy is False before /setup has run: at that point a bare
|
|
?token= hit is the admin coming through the token prompt to do
|
|
first-run setup, not a QR scan."""
|
|
device_id = request.query_params.get("id", "").strip().lower()
|
|
token = request.query_params.get("token", "")
|
|
if device_id and token:
|
|
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
|
if frame is not None and token == frame.device_token:
|
|
return f"/m/{frame.manage_token}"
|
|
if allow_legacy and token and management_token() and token == management_token():
|
|
frame = db.scalars(
|
|
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
|
).first()
|
|
if frame is not None:
|
|
return f"/m/{frame.manage_token}"
|
|
return None
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request):
|
|
"""Routing hub: manage-QR scans go to the limited manage page, users
|
|
land on their first frame (or an empty-state page), and everyone
|
|
else is walked through setup/login."""
|
|
with SessionLocal() as db:
|
|
have_users = users_exist(db)
|
|
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
|
|
if manage_redirect is not None:
|
|
return RedirectResponse(manage_redirect, status_code=303)
|
|
|
|
user = current_user(request, db)
|
|
if user is None:
|
|
if not have_users:
|
|
if management_token() and not browser_token_valid(request):
|
|
supplied = request.query_params.get("token")
|
|
return templates.TemplateResponse(
|
|
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
|
)
|
|
# Pre-setup: reachable (optionally token-gated), nudge setup.
|
|
return RedirectResponse("/setup", status_code=303)
|
|
return RedirectResponse("/login", status_code=303)
|
|
|
|
frames = user_frames(db, user)
|
|
if frames:
|
|
return RedirectResponse(f"/frames/{frames[0].id}", status_code=303)
|
|
return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))
|