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.
428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
|
|
|
|
Auth: session-only (require_frame_view for reads, require_frame_control
|
|
for mutations -- the "take control" soft lock). The limited manage-QR
|
|
surface lives separately under /api/m/ (routers/manage.py), and device
|
|
traffic under /frame/* (routers/device.py).
|
|
|
|
Config saves are PARTIAL updates: each page's form posts only its own
|
|
fields (the old single Settings form split across the Photos and
|
|
Configuration tabs), so every field is optional and only provided ones
|
|
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
|
|
the page JS -- an absent field means "not this form's field", never
|
|
"unchecked".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import gitea_releases, photo_queue, quiet_hours
|
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
|
from ..db import frame_locked, get_db
|
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
|
from ..firmware import firmware_path, parse_app_version
|
|
from ..models import BatteryLog, Frame
|
|
from .common import (
|
|
OVERDUE_FACTOR,
|
|
battery_estimate_s,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
require_configured,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
MIN_REFRESH_INTERVAL_S = 60
|
|
MAX_REFRESH_INTERVAL_S = 86400
|
|
MIN_QUEUE_TARGET_LEN = 5
|
|
MAX_QUEUE_TARGET_LEN = 5000
|
|
|
|
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/albums")
|
|
def api_albums(frame: Frame = Depends(require_frame_view)):
|
|
url, key = immich_creds(frame)
|
|
if not url or not key:
|
|
raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
|
|
try:
|
|
albums = immich_client_for(frame).list_albums()
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
|
|
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/config")
|
|
def api_config_save(
|
|
name: str | None = Form(None),
|
|
album_id: str | None = Form(None),
|
|
order: str | None = Form(None),
|
|
refresh_interval_s: int | None = Form(None),
|
|
smart_crop_faces: bool | None = Form(None),
|
|
queue_target_len: int | None = Form(None),
|
|
orientation: str | None = Form(None),
|
|
quiet_hours_enabled: bool | None = Form(None),
|
|
quiet_hours_start: str | None = Form(None),
|
|
quiet_hours_end: str | None = Form(None),
|
|
timezone: str | None = Form(None),
|
|
firmware_update_repo_url: str | None = Form(None),
|
|
firmware_auto_update: bool | None = Form(None),
|
|
battery_alert_threshold_pct: int | None = Form(None),
|
|
palette: list[str] | None = Form(None),
|
|
palette_reset: bool | None = Form(None),
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if name is not None:
|
|
cfg.name = name.strip()[:64] or cfg.name
|
|
if album_id is not None and album_id != cfg.album_id:
|
|
# A newly selected album starts clean -- the old current photo
|
|
# and queue don't mean anything in the new album's context.
|
|
cfg.current_asset_id = ""
|
|
cfg.current_asset_set_at = 0.0
|
|
cfg.queue = []
|
|
cfg.queue_cursor = 0
|
|
cfg.history = []
|
|
cfg.excluded_asset_ids = []
|
|
cfg.album_id = album_id
|
|
if order is not None:
|
|
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
|
if refresh_interval_s is not None:
|
|
cfg.refresh_interval_s = max(
|
|
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
|
)
|
|
if smart_crop_faces is not None:
|
|
cfg.smart_crop_faces = smart_crop_faces
|
|
if queue_target_len is not None:
|
|
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
|
if orientation is not None:
|
|
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
|
if quiet_hours_enabled is not None:
|
|
cfg.quiet_hours_enabled = quiet_hours_enabled
|
|
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
|
|
cfg.quiet_hours_start = quiet_hours_start
|
|
if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
|
|
cfg.quiet_hours_end = quiet_hours_end
|
|
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
|
cfg.timezone = timezone
|
|
if firmware_update_repo_url is not None:
|
|
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
|
if firmware_auto_update is not None:
|
|
cfg.firmware_auto_update = firmware_auto_update
|
|
if battery_alert_threshold_pct is not None:
|
|
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
|
|
# A changed threshold should be able to fire again immediately,
|
|
# not stay suppressed by a flag set under the old value.
|
|
cfg.battery_alert_sent = False
|
|
if palette_reset:
|
|
cfg.palette_rgb = None
|
|
elif palette is not None:
|
|
if len(palette) != len(PALETTE_LABELS):
|
|
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
|
|
parsed = [hex_to_rgb(h) for h in palette]
|
|
if any(rgb is None for rgb in parsed):
|
|
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
|
cfg.palette_rgb = [list(rgb) for rgb in parsed]
|
|
cfg.stats_config_saves += 1
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/take-control")
|
|
def api_take_control(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Always succeeds for any linked user -- the lock is deliberately
|
|
soft. The previous holder just sees who has it now."""
|
|
user = require_user_api(request, db)
|
|
previous = frame.controlled_by
|
|
frame.controlled_by_user_id = user.id
|
|
db.commit()
|
|
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
|
|
previous.username if previous else "nobody")
|
|
return {"status": "saved", "controller": user.display_name or user.username}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/stats")
|
|
def api_stats(frame: Frame = Depends(require_frame_view)):
|
|
return {
|
|
"first_seen": frame.stats_first_seen,
|
|
"device_wakes": frame.stats_device_wakes,
|
|
"photos_displayed": frame.stats_photos_displayed,
|
|
"photos_removed": frame.stats_photos_removed,
|
|
"battery_reports": frame.stats_battery_reports,
|
|
"recharge_cycles": frame.stats_recharge_cycles,
|
|
"ota_updates_applied": frame.stats_ota_updates_applied,
|
|
"config_saves": frame.stats_config_saves,
|
|
}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/queue")
|
|
def api_queue(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
user = require_user_api(request, db)
|
|
require_configured(frame)
|
|
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as cfg:
|
|
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
|
photo_queue.sync_queue_length(cfg, assets)
|
|
snapshot = {
|
|
"current_asset_id": cfg.current_asset_id,
|
|
"queue": list(cfg.queue),
|
|
"last_seen": cfg.last_seen,
|
|
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
|
|
"firmware_version": cfg.device_firmware_version,
|
|
"firmware_available": cfg.firmware_available_version,
|
|
"battery_percent": cfg.battery_percent,
|
|
"battery_as_of": cfg.battery_as_of,
|
|
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
|
"battery_estimate_s": battery_estimate_s(cfg),
|
|
"controller_id": cfg.controlled_by_user_id,
|
|
"controller": (
|
|
(cfg.controlled_by.display_name or cfg.controlled_by.username)
|
|
if cfg.controlled_by
|
|
else None
|
|
),
|
|
}
|
|
|
|
def entry(asset_id: str) -> dict:
|
|
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
|
|
|
|
now = time.time()
|
|
return {
|
|
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
|
|
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
|
|
"control": {
|
|
"controller": snapshot["controller"],
|
|
"you": snapshot["controller_id"] == user.id,
|
|
},
|
|
"device": {
|
|
"last_seen": snapshot["last_seen"] or None,
|
|
"overdue": bool(
|
|
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
|
|
),
|
|
"firmware_version": snapshot["firmware_version"] or None,
|
|
"firmware_available": snapshot["firmware_available"] or None,
|
|
"battery": (
|
|
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
|
|
if snapshot["battery_percent"] >= 0
|
|
else None
|
|
),
|
|
"on_battery_since": snapshot["on_battery_since"],
|
|
"battery_estimate_s": snapshot["battery_estimate_s"],
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/battery-log")
|
|
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
rows = db.execute(
|
|
select(BatteryLog.ts, BatteryLog.percent)
|
|
.where(BatteryLog.frame_id == frame.id)
|
|
.order_by(BatteryLog.ts)
|
|
).all()
|
|
return {"log": [[ts, percent] for ts, percent in rows]}
|
|
|
|
|
|
class QueueReorderRequest(BaseModel):
|
|
queue: list[str]
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/queue/reorder")
|
|
def api_queue_reorder(
|
|
body: QueueReorderRequest,
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Applies the client's requested order, tolerating drift between the
|
|
browser's last-fetched snapshot and the server's current queue (e.g.
|
|
a top-up/trim landed in between) instead of hard-rejecting: any ID
|
|
the client sent that's no longer actually queued is dropped, and any
|
|
ID the server has that the client didn't know about is appended
|
|
rather than lost."""
|
|
with frame_locked(db, frame.id) as cfg:
|
|
current_set = set(cfg.queue)
|
|
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
|
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
|
|
cfg.queue = reordered
|
|
return {"status": "saved"}
|
|
|
|
|
|
class QueuePromoteRequest(BaseModel):
|
|
asset_id: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/queue/promote")
|
|
def api_queue_promote(
|
|
body: QueuePromoteRequest,
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Moves a single photo to the front of the queue -- "Show next".
|
|
Unlike reorder, doesn't depend on the client knowing the queue's
|
|
exact current order, so it can't fail from staleness."""
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if body.asset_id not in cfg.queue:
|
|
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
|
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
|
|
return {"status": "saved"}
|
|
|
|
|
|
class QueueRemoveRequest(BaseModel):
|
|
asset_id: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/queue/remove")
|
|
def api_queue_remove(
|
|
body: QueueRemoveRequest,
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Permanently removes a photo from this frame's rotation. Does NOT
|
|
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as cfg:
|
|
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
|
|
return {"status": "removed"}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
|
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
try:
|
|
content, content_type = client.download_asset_thumbnail(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
|
return Response(content=content, media_type=content_type)
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/firmware")
|
|
def api_firmware_upload(
|
|
file: UploadFile = File(...),
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Uploads a firmware image for OTA. The version is parsed out of the
|
|
image itself (esp_app_desc_t) rather than trusted from a filename or
|
|
form field, and the project name is checked so an unrelated .bin
|
|
can't be pushed to the frame by mistake."""
|
|
data = file.file.read()
|
|
version = parse_app_version(data)
|
|
path = firmware_path(frame.id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.firmware_available_version = version
|
|
return {"status": "saved", "version": version, "size": len(data)}
|
|
|
|
|
|
def _fetch_latest_release(frame: Frame) -> dict | None:
|
|
try:
|
|
return gitea_releases.fetch_latest_release(
|
|
frame.firmware_update_repo_url, frame.firmware_update_token
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e
|
|
|
|
|
|
def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
|
"""Downloads the configured Gitea repo's latest release asset for this
|
|
frame's board variant (learned from the device's X-Frame-Board
|
|
header, never picked by hand) and stages it exactly like a manual
|
|
upload. Network I/O happens before the lock is taken."""
|
|
if not frame.device_board_variant:
|
|
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
|
|
release = _fetch_latest_release(frame)
|
|
if not release:
|
|
raise HTTPException(404, "No releases found in the configured Gitea repo")
|
|
asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
|
|
asset_url = release["assets"].get(asset_name)
|
|
if not asset_url:
|
|
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
|
|
try:
|
|
data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
|
|
version = parse_app_version(data) # same validation the manual upload path applies
|
|
path = firmware_path(frame.id)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.firmware_available_version = version
|
|
cfg.firmware_gitea_latest_version = version
|
|
cfg.firmware_update_checked_at = time.time()
|
|
return version
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/firmware/check")
|
|
def api_firmware_check(
|
|
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Throttled check of the configured Gitea repo's latest release
|
|
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
|
on and a newer version is found, applies it immediately; otherwise
|
|
just reports it so the UI can offer the "Update frame" button.
|
|
force=true (the "Check now" button) bypasses the throttle."""
|
|
if not frame.firmware_update_repo_url:
|
|
return {"enabled": False}
|
|
|
|
now = time.time()
|
|
if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
|
|
# checked_at only advances on a successful reach, so a Gitea
|
|
# outage gets retried every poll instead of waiting out the full
|
|
# throttle interval.
|
|
release = _fetch_latest_release(frame)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.firmware_update_checked_at = now
|
|
if release:
|
|
cfg.firmware_gitea_latest_version = release["version"]
|
|
|
|
update_available = (
|
|
bool(frame.firmware_gitea_latest_version)
|
|
and frame.firmware_gitea_latest_version != frame.firmware_available_version
|
|
and bool(frame.device_board_variant)
|
|
)
|
|
if update_available and frame.firmware_auto_update:
|
|
_apply_gitea_update(db, frame)
|
|
update_available = False
|
|
|
|
return {
|
|
"enabled": True,
|
|
"board": frame.device_board_variant or None,
|
|
"latest_version": frame.firmware_gitea_latest_version or None,
|
|
"staged_version": frame.firmware_available_version or None,
|
|
"update_available": update_available,
|
|
}
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/firmware/apply-latest")
|
|
def api_firmware_apply_latest(
|
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
"""The "Update frame" button: applies the latest Gitea release right
|
|
now, bypassing the check throttle -- an explicit user action, not a
|
|
background poll."""
|
|
if not frame.firmware_update_repo_url:
|
|
raise HTTPException(400, "No Gitea firmware repo configured")
|
|
version = _apply_gitea_update(db, frame)
|
|
return {"status": "saved", "version": version} |