Extends weather's experimental Chromium+Jinja2 render style to battery, text, tasks, static image, whiteboard, and calendar (all four view modes -- agenda/today_tomorrow/week/month), and gives the photos widget its own genuinely independent palette + dithering strength. Photos: Frame.photo_palette_rgb/photo_dither_strength (mirroring the existing palette_rgb/dither_strength), with a second "Photos configuration" card in Advanced Configuration. widgets/photos.py's render() quantizes itself against these before returning -- no render_panel changes needed, since photos is the only widget that genuinely needs a different reference palette and can carry that itself, the same way modern-style widgets already self-dither via ordered_dither. Battery/text/tasks/static image/whiteboard: same render_style pattern weather established (render_style column, html_render.py build function, Jinja2 template, dialog toggle). Static image/whiteboard get their first-ever visual chrome (a rounded-corner shadowed card, shared framed_image.html.jinja) since classic draws them with zero frame at all. Fixed the same "preview endpoint bypasses render_style" bug weather originally shipped with, for tasks/static/whiteboard/ calendar's preview endpoints. Calendar: own module (app/calendar_html_render.py, mirroring calendar_render.py's separation from the simpler widgets) covering all four view modes, not just agenda -- reuses calendar_render's own private helpers so event colors/times/weather/month-grid math match classic exactly. Found and fixed two real cross-day layout bugs along the way: a per-day header height that varied based on whether that specific day had a weather entry (misaligning where every other day's event rows started across the week/month grid), and regular-weight small text being fragile under Bayer ordered dithering (out-of-month day numbers degraded into unrecognizable speckle) -- fixed by using bold everywhere and de-emphasizing via size instead of weight/gray, since gray text has the same dithering fragility this project's PIL renderers already avoid for exactly this reason. Migrations 32-38 (Frame's two new columns, then one render_style column per widget config table). 452 tests passing, including new dispatch/ migration coverage per widget type and a dedicated photos test proving photo_palette_rgb produces genuinely independent quantization from the frame's main palette_rgb.
443 lines
21 KiB
Python
443 lines
21 KiB
Python
"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/...
|
|
Per-widget settings (album, calendar view/inclusion, whiteboard source,
|
|
etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/
|
|
{widget_id}/... -- split out once a frame could hold more than one
|
|
widget of the same type.
|
|
|
|
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: only provided fields 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 sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import gitea_releases, grid, quiet_hours
|
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
|
from ..db import frame_locked, get_db
|
|
from ..global_actions import GLOBAL_ACTIONS
|
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
|
from ..firmware import firmware_path, parse_app_version
|
|
from ..models import BatteryLog, Frame, Widget
|
|
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
|
from .device import render_frame_preview_png
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
MIN_REFRESH_INTERVAL_S = 60
|
|
MAX_REFRESH_INTERVAL_S = 86400
|
|
|
|
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
|
|
|
# See app/global_actions.py -- how long NEXT/BACK must be held before the
|
|
# device treats it as a hold instead of a short press.
|
|
MIN_HOLD_DURATION_MS = 3000
|
|
MAX_HOLD_DURATION_MS = 10000
|
|
|
|
|
|
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
|
|
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
|
|
cols x rows (see grid.grid_dims) -- landscape and portrait use a
|
|
transposed grid (8x5 vs 5x8), so an existing placement is often
|
|
literally out of bounds on the new grid, not just visually wrong.
|
|
There's no sensible coordinate remap between two differently-shaped
|
|
grids, so instead: keep whichever widget was first by placement
|
|
order, resized to fill the new full panel, and delete the rest --
|
|
cascading to their own config rows and any FrameButtonAction
|
|
bindings via ondelete="CASCADE" (see models.py). The frontend is
|
|
expected to confirm this with the user before submitting an
|
|
orientation change (see frame_config.js) -- this always executes
|
|
unconditionally once called, same posture as every other
|
|
confirm-on-the-client / act-unconditionally-on-the-server action in
|
|
this codebase."""
|
|
widgets = db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame_id).order_by(Widget.sort_order)
|
|
).all()
|
|
if not widgets:
|
|
return
|
|
keep, *rest = widgets
|
|
for widget in rest:
|
|
db.delete(widget)
|
|
keep.x, keep.y, keep.w, keep.h = grid.full_panel_rect(new_orientation)
|
|
|
|
|
|
@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),
|
|
refresh_interval_s: 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),
|
|
photo_palette: list[str] | None = Form(None),
|
|
photo_palette_reset: bool | None = Form(None),
|
|
photo_dither_strength: float | None = Form(None),
|
|
hold_duration_ms: int | None = Form(None),
|
|
next_hold_action: str | None = Form(None),
|
|
back_hold_action: str | None = Form(None),
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Partial update of frame-wide settings only -- per-widget settings
|
|
(album, calendar view/inclusion, whiteboard source, etc.) live on
|
|
routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
|
|
since a frame can hold more than one widget of the same type and
|
|
"the frame's calendar settings" stopped being unambiguous the moment
|
|
that became possible. `mode` and `calendar_photo_inlay` are no
|
|
longer accepted here either: mode no longer governs anything (a
|
|
frame's widgets do), and photo inlay has no widget-system equivalent
|
|
(place an independent photo widget alongside instead). All three are
|
|
harmless no-ops if an old cached page still POSTs them -- FastAPI
|
|
silently ignores form fields with no matching parameter.
|
|
|
|
An actual orientation *change* resets the frame's widget layout (see
|
|
_reset_widget_layout_for_new_orientation) -- widget placement is
|
|
grid-cell-relative to the panel's long/short axis, which swaps on a
|
|
landscape<->portrait change, so an old placement is usually not just
|
|
visually wrong but literally out of bounds on the new grid.
|
|
|
|
hold_duration_ms/next_hold_action/back_hold_action configure hold-
|
|
for-global-action (see app/global_actions.py) -- a frame-wide
|
|
setting, not per-widget, hence living here rather than on
|
|
api_widgets.py's per-widget button-actions endpoint. An unrecognized
|
|
action value clears the binding rather than erroring, same posture
|
|
as this endpoint's other enum-ish fields (orientation, timezone)."""
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if name is not None:
|
|
cfg.name = name.strip()[:64] or cfg.name
|
|
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 orientation is not None:
|
|
new_orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
|
if new_orientation != cfg.orientation:
|
|
_reset_widget_layout_for_new_orientation(db, cfg.id, new_orientation)
|
|
cfg.orientation = new_orientation
|
|
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 photo_palette_reset:
|
|
cfg.photo_palette_rgb = None
|
|
elif photo_palette is not None:
|
|
if len(photo_palette) != len(PALETTE_LABELS):
|
|
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(photo_palette)}")
|
|
parsed = [hex_to_rgb(h) for h in photo_palette]
|
|
if any(rgb is None for rgb in parsed):
|
|
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
|
cfg.photo_palette_rgb = [list(rgb) for rgb in parsed]
|
|
if photo_dither_strength is not None:
|
|
cfg.photo_dither_strength = max(0.0, min(1.0, photo_dither_strength))
|
|
if hold_duration_ms is not None:
|
|
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
|
|
if next_hold_action is not None:
|
|
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
|
|
if back_hold_action is not None:
|
|
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
|
|
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}/status")
|
|
def api_status(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Device liveness + control-lock info -- frame-level facts (battery,
|
|
last-seen, firmware, who has control), not tied to any particular
|
|
widget. Powers static/device_status_bar.js, shown on every per-frame
|
|
page regardless of which widgets that frame has. Used to piggyback on
|
|
the photo queue endpoint (back when a frame had at most one widget,
|
|
always photos-shaped); split out once that stopped being true, so the
|
|
status bar isn't blank on a frame with no photo widget."""
|
|
user = require_user_api(request, db)
|
|
now = time.time()
|
|
overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
|
|
return {
|
|
"control": {
|
|
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
|
"you": frame.controlled_by_user_id == user.id,
|
|
},
|
|
"device": {
|
|
"last_seen": frame.last_seen or None,
|
|
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
|
# When the device is next expected to check in, per the same
|
|
# sleep duration frame_config() actually hands it (see
|
|
# device.py's /frame/config) -- not the raw overdue_gap above,
|
|
# which is deliberately generous (OVERDUE_FACTOR) to avoid
|
|
# false alarms during quiet hours rather than a best guess.
|
|
"expected_next_checkin": (
|
|
frame.last_seen + quiet_hours.effective_refresh_interval_s(frame) if frame.last_seen else None
|
|
),
|
|
"firmware_version": frame.device_firmware_version or None,
|
|
"firmware_available": frame.firmware_available_version or None,
|
|
"battery": (
|
|
{"percent": frame.battery_percent, "as_of": frame.battery_as_of}
|
|
if frame.battery_percent >= 0 else None
|
|
),
|
|
"battery_estimate_s": battery_estimate_s(frame, db),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/preview")
|
|
def api_frame_preview(
|
|
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
):
|
|
"""A small PNG of exactly what the frame is currently displaying --
|
|
the same widget compositor /frame/image uses (see routers/device.py's
|
|
render_frame_preview_png), just handed back upright and unpacked for
|
|
the dashboard header's live thumbnail instead of the device's packed
|
|
native format. Not cached: cheap enough for an on-demand header image,
|
|
and each widget's own render is already idempotent between a device's
|
|
real wakes (see photo_queue.get_current, calendar widget's browse
|
|
reset), so an extra read here doesn't skip or duplicate anything."""
|
|
png = render_frame_preview_png(db, frame, request)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/now-displaying")
|
|
def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
|
|
"""Exactly what was last actually sent to this frame's device (see
|
|
routers/device.py's _record_last_displayed) -- the frozen "now
|
|
displaying" half of the header preview pair, as opposed to /preview's
|
|
always-live "up next" re-render. 404 (not a placeholder image) until
|
|
the device has fetched at least once, so the web UI can show its own
|
|
empty state instead of a broken image. X-Displayed-At carries the
|
|
capture time (unix seconds) for a "N ago" label -- a header, not the
|
|
body, since the body is the raw PNG bytes."""
|
|
if frame.last_displayed_image is None:
|
|
raise HTTPException(404, "This frame hasn't displayed anything yet")
|
|
return Response(
|
|
content=frame.last_displayed_image,
|
|
media_type="image/png",
|
|
headers={"X-Displayed-At": str(frame.last_displayed_at)},
|
|
)
|
|
|
|
|
|
@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]}
|
|
|
|
|
|
|
|
@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,
|
|
"running_version": frame.device_firmware_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} |