Files
espresso_frame/server/app/main.py
T
tfaour 5d4bb53b8a
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Widget system Phase 4a: widget CRUD + grid placement UI
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.
2026-07-24 12:48:56 -04:00

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