Build and push server image / build-and-push (push) Successful in 53s
calendar_week_start's fixed-weekday anchor ("start on the most recent
Monday") stops making sense once the view isn't a literal calendar
week, so a non-7-day week view now starts calendar_week_start_offset
days from today instead (0 = starts today, negative/positive = past/
future) -- calendar_week_start still governs at the default 7 days,
unchanged.
Also hides the Calendar tab's week-only fields (days to show, layout,
start offset) unless View is actually set to Week, and further hides
the new start-offset field specifically when Days to show is 7 (where
it has no effect). "Week starts on" stays visible for Month too, since
it actually still applies there.
781 lines
34 KiB
Python
781 lines
34 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, weather
|
|
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, FrameCalendar
|
|
from .common import (
|
|
FRAME_MODES,
|
|
OVERDUE_FACTOR,
|
|
battery_estimate_s,
|
|
calendar_sources_for_frame,
|
|
fetch_source_and_faces,
|
|
get_or_refresh_calendar_events,
|
|
get_or_refresh_tasks,
|
|
get_or_refresh_weather,
|
|
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),
|
|
calendar_week_start: int | None = Form(None),
|
|
calendar_week_days: int | None = Form(None),
|
|
calendar_week_layout: str | None = Form(None),
|
|
calendar_week_start_offset: int | None = Form(None),
|
|
calendar_weather_enabled: bool | None = Form(None),
|
|
calendar_weather_units: str | None = Form(None),
|
|
calendar_tasks_enabled: 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
|
|
if calendar_week_start is not None:
|
|
cfg.calendar_week_start = max(0, min(6, calendar_week_start))
|
|
if calendar_week_days is not None:
|
|
new_days = max(2, min(10, calendar_week_days))
|
|
if new_days != cfg.calendar_week_days:
|
|
# A stale offset counts a different-sized page under the
|
|
# old day count -- same reasoning as calendar_view's own
|
|
# reset below.
|
|
cfg.calendar_browse_offset = 0
|
|
cfg.calendar_week_days = new_days
|
|
if calendar_week_layout is not None:
|
|
cfg.calendar_week_layout = calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
|
|
if calendar_week_start_offset is not None:
|
|
new_offset = max(-30, min(30, calendar_week_start_offset))
|
|
if new_offset != cfg.calendar_week_start_offset:
|
|
cfg.calendar_browse_offset = 0
|
|
cfg.calendar_week_start_offset = new_offset
|
|
if calendar_weather_enabled is not None:
|
|
cfg.calendar_weather_enabled = calendar_weather_enabled
|
|
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
|
|
if calendar_weather_units != cfg.calendar_weather_units:
|
|
# Cached forecasts are in the old unit -- force a refetch
|
|
# rather than showing stale numbers under a new unit label.
|
|
cfg.calendar_weather_checked_at = 0.0
|
|
cfg.calendar_weather_units = calendar_weather_units
|
|
if calendar_tasks_enabled is not None:
|
|
cfg.calendar_tasks_enabled = calendar_tasks_enabled
|
|
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, db),
|
|
"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 CalendarSelectRequest(BaseModel):
|
|
user_id: int
|
|
calendar_key: str
|
|
calendar_label: str = ""
|
|
included: bool
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/calendar-select")
|
|
def api_calendar_select(
|
|
body: CalendarSelectRequest,
|
|
request: Request,
|
|
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Include/exclude one calendar (calendar_key "ics" or
|
|
"caldav:<href>", see FrameCalendar) on this frame. Deliberately not
|
|
require_frame_control: adding your own calendar, or muting anyone's
|
|
(including your own), is each viewer's own call, not something a
|
|
frame's controller manages on someone else's behalf. The one-sided
|
|
permission split lives here: turning a calendar ON requires being its
|
|
owner (nobody can add someone else's calendar to a shared frame for
|
|
them); turning one OFF only requires being linked to the frame at
|
|
all, so anyone sharing the display can mute a calendar they'd rather
|
|
not see there even if they don't own it."""
|
|
user = require_user_api(request, db)
|
|
if body.included and body.user_id != user.id:
|
|
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
|
|
row = db.execute(
|
|
select(FrameCalendar).where(
|
|
FrameCalendar.frame_id == frame.id,
|
|
FrameCalendar.user_id == body.user_id,
|
|
FrameCalendar.calendar_key == body.calendar_key,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
if not body.included:
|
|
raise HTTPException(404, "Not currently included on this frame")
|
|
row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
|
|
db.add(row)
|
|
row.included = body.included
|
|
if body.calendar_label:
|
|
row.calendar_label = body.calendar_label
|
|
# 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.included}
|
|
|
|
|
|
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
|
|
|
|
|
|
class CalendarColorRequest(BaseModel):
|
|
calendar_key: str
|
|
color_index: int | None # None clears the pin, reverting to auto-cycle
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/calendar-color")
|
|
def api_calendar_color(
|
|
body: CalendarColorRequest,
|
|
request: Request,
|
|
frame: Frame = Depends(require_frame_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Pins a specific panel color to one of your own included calendars
|
|
(models.FrameCalendar.color_index) -- always owner-only, unlike
|
|
calendar-select's included=False, since recoloring someone else's
|
|
calendar isn't the same kind of "I'd rather not see this" veto as
|
|
muting it. None clears the pin, reverting calendar_render.py to its
|
|
old auto-cycle-by-owner-name behavior for this calendar."""
|
|
user = require_user_api(request, db)
|
|
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
|
|
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
|
|
row = db.execute(
|
|
select(FrameCalendar).where(
|
|
FrameCalendar.frame_id == frame.id,
|
|
FrameCalendar.user_id == user.id,
|
|
FrameCalendar.calendar_key == body.calendar_key,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise HTTPException(404, "Not included on this frame")
|
|
row.color_index = body.color_index
|
|
frame.calendar_checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "color_index": row.color_index}
|
|
|
|
|
|
def _calendar_photo_inlay(frame: Frame, db: Session):
|
|
"""The photo-inlay's source image (any view now, not just agenda), or
|
|
None if inlay is off 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_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"
|
|
weather_cities = get_or_refresh_weather(db, frame)
|
|
tasks = get_or_refresh_tasks(db, frame) if (view == "week" and frame.calendar_tasks_enabled) else None
|
|
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,
|
|
week_start=frame.calendar_week_start,
|
|
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
|
|
week_days=frame.calendar_week_days, week_layout=frame.calendar_week_layout, tasks=tasks,
|
|
week_start_offset=frame.calendar_week_start_offset,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
class TasksSourceRequest(BaseModel):
|
|
calendar_key: str | None # None clears the source
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/tasks-source")
|
|
def api_tasks_source(
|
|
body: TasksSourceRequest,
|
|
request: Request,
|
|
frame: Frame = Depends(require_frame_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Points this frame's week-view task list at one of the calling
|
|
user's own CalDAV calendars -- same owner-controls-their-own-data
|
|
permission split as calendar-select's included=True, since this is
|
|
volunteering personal calendar data, not a frame-wide display
|
|
setting a controller should get to pick on someone else's behalf.
|
|
None clears the source; clearing (unlike setting) isn't
|
|
ownership-gated -- like muting a shared calendar, anyone linked to
|
|
the frame can turn off a task list they'd rather not see, but only
|
|
its owner can point the frame at one of their calendars to begin
|
|
with."""
|
|
user = require_user_api(request, db)
|
|
with frame_locked(db, frame.id) as cfg:
|
|
if body.calendar_key is None:
|
|
cfg.calendar_tasks_user_id = None
|
|
cfg.calendar_tasks_calendar_key = None
|
|
cfg.calendar_tasks_cached = None
|
|
else:
|
|
cfg.calendar_tasks_user_id = user.id
|
|
cfg.calendar_tasks_calendar_key = body.calendar_key
|
|
cfg.calendar_tasks_checked_at = 0.0 # pick up the change promptly
|
|
return {"status": "saved", "calendar_key": body.calendar_key}
|
|
|
|
|
|
class WeatherCityAddRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/weather-cities/add")
|
|
def api_weather_city_add(
|
|
body: WeatherCityAddRequest,
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Geocodes a free-text city name (e.g. "Portland, OR") and adds it
|
|
to this frame's weather strip -- a frame-wide display setting (like
|
|
calendar_view), not personal data, so this is gated the same way as
|
|
api_config_save rather than the calendar-select owner/mute split."""
|
|
try:
|
|
city = weather.geocode_city(body.name)
|
|
except weather.WeatherFetchError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cities = list(cfg.calendar_weather_cities or [])
|
|
if any(c["label"] == city["label"] for c in cities):
|
|
raise HTTPException(400, f"{city['label']} is already on this frame's list")
|
|
cities.append(city)
|
|
cfg.calendar_weather_cities = cities
|
|
cfg.calendar_weather_checked_at = 0.0 # pick up the new city promptly
|
|
return {"status": "saved", "city": city}
|
|
|
|
|
|
class WeatherCityRemoveRequest(BaseModel):
|
|
label: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/weather-cities/remove")
|
|
def api_weather_city_remove(
|
|
body: WeatherCityRemoveRequest,
|
|
frame: Frame = Depends(require_frame_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label]
|
|
cfg.calendar_weather_cities = cities
|
|
cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label]
|
|
cfg.calendar_weather_cached = cached
|
|
return {"status": "saved"}
|
|
|
|
|
|
@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} |