Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps.
600 lines
26 KiB
Python
600 lines
26 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 calendar_render, 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 (
|
|
DEFAULT_DISPLAY_MODE,
|
|
DISPLAY_MODES,
|
|
PALETTE_LABELS,
|
|
hex_to_rgb,
|
|
render_preview_png,
|
|
)
|
|
from ..firmware import firmware_path, parse_app_version
|
|
from ..models import BatteryLog, Frame, UserFrame
|
|
from .common import (
|
|
FRAME_MODES,
|
|
OVERDUE_FACTOR,
|
|
battery_estimate_s,
|
|
calendar_sources_for_frame,
|
|
fetch_source_and_faces,
|
|
get_or_refresh_calendar_events,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
require_configured,
|
|
valid_http_url,
|
|
)
|
|
|
|
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),
|
|
display_mode: str | 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),
|
|
color_boost: float | None = Form(None),
|
|
contrast_boost: float | None = Form(None),
|
|
dither_strength: float | None = Form(None),
|
|
mode: str | None = Form(None),
|
|
calendar_view: str | None = Form(None),
|
|
calendar_photo_inlay: 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 display_mode is not None:
|
|
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
|
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:
|
|
stripped = firmware_update_repo_url.strip()
|
|
if stripped and not valid_http_url(stripped):
|
|
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
|
cfg.firmware_update_repo_url = stripped
|
|
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]
|
|
if color_boost is not None:
|
|
cfg.color_boost = max(0.0, min(2.0, color_boost))
|
|
if contrast_boost is not None:
|
|
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
|
if dither_strength is not None:
|
|
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
|
if mode is not None:
|
|
cfg.mode = mode if mode in FRAME_MODES else "photos"
|
|
if calendar_view is not None:
|
|
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
|
if new_view != cfg.calendar_view:
|
|
# A stale offset means something different in a different
|
|
# view's units (days vs. weeks vs. months) -- same
|
|
# reasoning as album_id's reset above.
|
|
cfg.calendar_browse_offset = 0
|
|
cfg.calendar_view = new_view
|
|
if calendar_photo_inlay is not None:
|
|
cfg.calendar_photo_inlay = calendar_photo_inlay
|
|
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,
|
|
"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
|
|
),
|
|
"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)):
|
|
"""Scoped to what this frame is actually showing/queuing -- a user
|
|
merely linked to view this frame shouldn't be able to pull thumbnails
|
|
for arbitrary asset ids in the owner's Immich library, only the
|
|
frame's own curated album. Same rule device.frame_share and
|
|
manage.manage_thumbnail already enforce."""
|
|
require_configured(frame)
|
|
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
|
raise HTTPException(404, "Not on this 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)
|
|
|
|
|
|
def _current_asset_id(frame: Frame, db: Session) -> str:
|
|
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
|
|
picks a current photo if none is set yet, otherwise just reads it,
|
|
never advances early."""
|
|
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))
|
|
asset_id = cfg.current_asset_id
|
|
if not asset_id:
|
|
raise HTTPException(404, "No current photo")
|
|
return asset_id
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/preview/original")
|
|
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
"""The Immich preview image behind the currently-displayed photo,
|
|
unprocessed -- the "now displaying" side of the Configuration tab's
|
|
before/after comparison."""
|
|
asset_id = _current_asset_id(frame, db)
|
|
client = immich_client_for(frame)
|
|
try:
|
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
|
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/preview/rendered")
|
|
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
"""The same photo run through this frame's actual saved rendering
|
|
pipeline (display mode, palette, color/contrast/dithering) and
|
|
exported as a PNG -- the "how it will look on the frame" side of the
|
|
comparison. Not a live preview of unsaved slider values; reflects
|
|
whatever's currently saved."""
|
|
asset_id = _current_asset_id(frame, db)
|
|
client = immich_client_for(frame)
|
|
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
|
png = render_preview_png(
|
|
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
display_mode=frame.display_mode, color_boost=frame.color_boost,
|
|
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
class CalendarIncludedRequest(BaseModel):
|
|
included: bool
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/calendar-included")
|
|
def api_calendar_included(
|
|
body: CalendarIncludedRequest,
|
|
request: Request,
|
|
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""A user's own opt-in into this frame's merged calendar (see
|
|
UserFrame.calendar_included). Deliberately not require_frame_control:
|
|
this is the toggling user's own data-sharing preference about their
|
|
own calendar, not a frame setting its controller manages on someone
|
|
else's behalf -- there's no target user_id in the request body by
|
|
design, it always toggles the calling session's own row."""
|
|
user = require_user_api(request, db)
|
|
row = db.get(UserFrame, (user.id, frame.id))
|
|
if row is None:
|
|
raise HTTPException(404, "Not linked to this frame")
|
|
row.calendar_included = body.included
|
|
# Force this frame's merged cache to pick up the change promptly
|
|
# rather than waiting out the throttle.
|
|
frame.calendar_checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "included": row.calendar_included}
|
|
|
|
|
|
def _calendar_photo_inlay(frame: Frame, db: Session):
|
|
"""The agenda view's optional photo-inlay source image, or None if
|
|
inlay is off, not agenda view, or the frame's photos-mode album isn't
|
|
configured. Shared shape between the live render (routers/device.py's
|
|
_render_calendar_mode) and this preview endpoint; small enough that
|
|
duplicating rather than factoring out is fine, since the two call
|
|
sites differ slightly in error handling."""
|
|
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
|
|
return None
|
|
url, key = immich_creds(frame)
|
|
if not (url and key and frame.album_id):
|
|
return None
|
|
try:
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
|
asset_id = locked.current_asset_id
|
|
if not asset_id:
|
|
return None
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
|
|
except HTTPException:
|
|
return None
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/preview/calendar")
|
|
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
"""The same merged, cached event set a live device render would use
|
|
-- not a live preview of an unsaved calendar_view choice, same
|
|
"reflects what's currently saved" convention as preview/rendered."""
|
|
if not calendar_sources_for_frame(db, frame):
|
|
raise HTTPException(400, "No calendars included on this frame yet")
|
|
events, summary = get_or_refresh_calendar_events(db, frame)
|
|
photo_inlay = _calendar_photo_inlay(frame, db)
|
|
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
|
png = calendar_render.render_calendar_preview_png(
|
|
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
@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.post("/api/frames/{frame_id}/firmware/check")
|
|
def api_firmware_check(
|
|
force: bool = False, frame: Frame = Depends(require_frame_control), 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.
|
|
|
|
require_frame_control (not view), and POST (not GET): this can
|
|
silently stage new firmware as a side effect (the auto-apply path
|
|
below) exactly like /firmware/apply-latest, so it needs the same
|
|
guard that route has -- a linked viewer without control shouldn't be
|
|
able to trigger that, and as a GET it would've been exempt from the
|
|
CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
|
|
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} |