Build and push server image / build-and-push (push) Successful in 48s
Weather: multiple cities per frame, geocoded via Open-Meteo (no API key), shown above the event list on agenda/today & tomorrow/week views -- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/ thunderstorm icons (no new font/icon asset, same primitives-only approach the rest of calendar_render.py already uses). City geocoding handles "City, State" qualifiers Open-Meteo's own search doesn't (disambiguates same-named cities, e.g. the three "Portland"s). CalDAV fix: events were never showing despite calendars discovering fine, because fetch_calendar_events relied on the calendar-query REPORT's server-side time-range filter, which real servers implement inconsistently (confirmed against a real server, not just guessed -- reproduced locally with Radicale). Switched to fetching every event unfiltered and doing all date-window filtering/expansion client-side, same approach already used for plain ICS feeds.
404 lines
17 KiB
Python
404 lines
17 KiB
Python
"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
|
|
into deployed firmware -- so multi-frame support changes only how the
|
|
calling frame is resolved (see auth.require_device), never the paths or
|
|
response key names the deployed flat parser depends on
|
|
("refresh_interval_s", "firmware_version").
|
|
|
|
manage=1 is the one addition: appended by firmware's manage button to
|
|
whichever of these three GET/POST requests it was already about to make
|
|
(see firmware/main/frame_client.c's fetch_and_display -- it no longer
|
|
does its own overlay fetching/compositing, that's all server-side now,
|
|
see manage_overlay.py and common.build_manage_content)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import FileResponse, RedirectResponse, Response
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import calendar_render, mail, photo_queue, quiet_hours
|
|
from ..auth import get_server_settings, require_device
|
|
from ..db import frame_locked, get_db
|
|
from ..firmware import firmware_path
|
|
from ..image_pipeline import render_placeholder
|
|
from ..models import BatteryLog, Frame
|
|
from .common import (
|
|
BATTERY_HISTORY_MAX,
|
|
BATTERY_LOG_MAX,
|
|
RECHARGE_JUMP_PCT,
|
|
RECHARGE_LOOKBACK,
|
|
build_manage_content,
|
|
get_or_refresh_calendar_events,
|
|
get_or_refresh_weather,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
render_asset,
|
|
require_configured,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
|
|
"""What an unclaimed or not-yet-configured frame displays instead of a
|
|
photo -- instructions with a QR, rendered at 200 so the device treats
|
|
it as a perfectly normal image and never error-loops. The URLs are
|
|
built from the request's own base URL: whatever address the device
|
|
reached us at is by definition an address that works on this
|
|
network."""
|
|
base = str(request.base_url).rstrip("/")
|
|
if frame.owner_user_id is None and frame.device_id:
|
|
claim_url = f"{base}/claim?device_id={frame.device_id}"
|
|
return render_placeholder(
|
|
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
|
qr_url=claim_url,
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
manage=manage,
|
|
)
|
|
if frame.owner_user_id is None:
|
|
return render_placeholder(
|
|
["Almost there!", f"Open {base} to finish setting up this frame."],
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
manage=manage,
|
|
)
|
|
return render_placeholder(
|
|
["Almost there!", "Pick an album for this frame:", base],
|
|
qr_url=base,
|
|
orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb,
|
|
manage=manage,
|
|
)
|
|
|
|
|
|
def _frame_configured(frame: Frame) -> bool:
|
|
url, key = immich_creds(frame)
|
|
return bool(url and key and frame.album_id)
|
|
|
|
|
|
# --- photos mode ---
|
|
|
|
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
|
is_normal_wake: bool) -> bytes:
|
|
if not _frame_configured(frame):
|
|
return _setup_placeholder(frame, request, manage=manage)
|
|
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
|
|
|
|
return render_asset(client, frame, asset_id, manage=manage)
|
|
|
|
|
|
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.advance_forced(locked, assets)
|
|
asset_id = locked.current_asset_id
|
|
|
|
return render_asset(client, frame, asset_id, manage=manage)
|
|
|
|
|
|
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
|
require_configured(frame)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, frame)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
photo_queue.back_forced(locked, assets)
|
|
asset_id = locked.current_asset_id
|
|
|
|
return render_asset(client, frame, asset_id, manage=manage)
|
|
|
|
|
|
# --- calendar mode ---
|
|
|
|
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
|
is_normal_wake: bool) -> bytes:
|
|
from .common import calendar_sources_for_frame
|
|
|
|
if not calendar_sources_for_frame(db, frame):
|
|
return render_placeholder(
|
|
["This frame's calendar isn't set up yet",
|
|
"Add a calendar in Settings, then include it on",
|
|
"this frame's Configuration -> Calendar card."],
|
|
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
|
)
|
|
|
|
with frame_locked(db, frame.id) as locked:
|
|
if is_normal_wake and locked.calendar_browse_offset != 0:
|
|
locked.calendar_browse_offset = 0
|
|
browse_offset = locked.calendar_browse_offset
|
|
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
|
week_start = locked.calendar_week_start
|
|
inlay_wanted = locked.calendar_photo_inlay
|
|
|
|
events, summary = get_or_refresh_calendar_events(db, frame)
|
|
weather_cities = get_or_refresh_weather(db, frame)
|
|
|
|
photo_inlay = None
|
|
if inlay_wanted and _frame_configured(frame):
|
|
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 asset_id:
|
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
|
|
except HTTPException:
|
|
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
|
|
|
|
return calendar_render.render_calendar(
|
|
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
|
|
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
|
|
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
|
|
)
|
|
|
|
|
|
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
|
"""NEXT in calendar mode: moves the displayed period forward one step
|
|
(day for agenda, week for week view, month for month view) from
|
|
wherever it currently is -- not from "today" -- so repeated presses
|
|
walk further forward. See Frame.calendar_browse_offset."""
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.calendar_browse_offset += 1
|
|
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
|
|
|
|
|
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.calendar_browse_offset -= 1
|
|
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
|
|
|
|
|
RENDERERS = {
|
|
"photos": _render_photos_mode,
|
|
"calendar": _render_calendar_mode,
|
|
}
|
|
ADVANCE_RENDERERS = {
|
|
"photos": _advance_photos_mode,
|
|
"calendar": _advance_calendar_mode,
|
|
}
|
|
BACK_RENDERERS = {
|
|
"photos": _back_photos_mode,
|
|
"calendar": _back_calendar_mode,
|
|
}
|
|
|
|
|
|
@router.get("/frame/config")
|
|
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Device-facing settings, polled by the frame alongside its
|
|
reachability check. Always returns 200 with current settings -- no
|
|
Immich-configured gate, since this doubles as the "is the server up"
|
|
signal. Also captures the device's running firmware version and board
|
|
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
|
|
available OTA image's version, so the device's update check costs
|
|
zero extra round trips."""
|
|
reported_version = request.headers.get("X-Frame-Version", "")
|
|
reported_board = request.headers.get("X-Frame-Board", "")
|
|
with frame_locked(db, frame.id) as locked:
|
|
if locked.stats_first_seen == 0:
|
|
locked.stats_first_seen = time.time()
|
|
locked.stats_device_wakes += 1
|
|
if reported_version:
|
|
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
|
|
locked.stats_ota_updates_applied += 1
|
|
locked.device_firmware_version = reported_version
|
|
if reported_board:
|
|
locked.device_board_variant = reported_board
|
|
|
|
response = {
|
|
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
|
|
"firmware_version": locked.firmware_available_version or None,
|
|
}
|
|
# Per-frame token push: only once the device has introduced itself
|
|
# by id (so the response to pure-legacy firmware stays byte-
|
|
# compatible with its 256-byte parse buffer), and only until the
|
|
# device has authenticated with the token once (device_token_ack).
|
|
if locked.device_id is not None and not locked.device_token_ack:
|
|
response["device_token"] = locked.device_token
|
|
return response
|
|
|
|
|
|
def _manage_flag(request: Request) -> bool:
|
|
return request.query_params.get("manage") == "1"
|
|
|
|
|
|
@router.get("/frame/image")
|
|
def frame_image(
|
|
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
|
):
|
|
"""Returns the frame's current image. For photos mode: idempotent --
|
|
only actually advances to the next photo once refresh_interval_s has
|
|
elapsed since the current one was set (see app/photo_queue.py) --
|
|
safe to call as often as the device wants, including after an
|
|
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
|
unconfigured frame gets a rendered instruction placeholder (200, not
|
|
an error) so a fresh device never error-loops.
|
|
|
|
?manage=1 (the manage button) composites the manage overlay onto
|
|
whatever this would have returned anyway -- see build_manage_content.
|
|
For calendar mode, this is also the "normal wake" that resets
|
|
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
|
|
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
content = renderer(db, frame, request, manage, True)
|
|
return Response(content=content, media_type="application/octet-stream")
|
|
|
|
|
|
@router.post("/frame/advance")
|
|
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""Forces an immediate move forward -- the next photo in photos mode,
|
|
or the next day/week/month in calendar mode -- ignoring
|
|
refresh_interval_s. Used by the device's next-photo button."""
|
|
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
|
|
|
|
|
@router.post("/frame/back")
|
|
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
|
"""The mirror of /frame/advance -- back a photo in photos mode, back
|
|
a period in calendar mode. A no-op (still 200, unchanged) if there's
|
|
nothing to go back to. Used by the device's back-photo button."""
|
|
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
|
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
|
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
|
|
|
|
|
class BatteryReport(BaseModel):
|
|
percent: int
|
|
|
|
|
|
@router.post("/frame/battery")
|
|
def frame_battery(
|
|
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
|
):
|
|
"""Battery level reported by the device (only when running on battery
|
|
-- it stays silent on mains, where the charging voltage would read
|
|
misleadingly full). Stored with a timestamp plus a per-discharge-
|
|
cycle history that feeds the Device panel's "on battery for" and
|
|
"estimated remaining" numbers; every report also lands in the
|
|
permanent battery_log table behind the history chart."""
|
|
if not 0 <= body.percent <= 100:
|
|
raise HTTPException(400, "percent must be 0-100")
|
|
now = time.time()
|
|
should_alert = False
|
|
alert_email = ""
|
|
alert_frame_name = ""
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.stats_battery_reports += 1
|
|
# See RECHARGE_LOOKBACK: compared against the max of the last few
|
|
# reports, not just the single previous one, so a lone noisy dip
|
|
# can't make the next normal reading look like a recharge.
|
|
recent = locked.battery_history[-RECHARGE_LOOKBACK:]
|
|
recent_max = max((pct for _, pct in recent), default=None)
|
|
if recent_max is not None and body.percent >= recent_max + RECHARGE_JUMP_PCT:
|
|
# Percent jumped up meaningfully -- the battery was recharged
|
|
# (or swapped). Start a fresh discharge cycle so runtime and
|
|
# discharge-rate estimates never span a charge -- and let a
|
|
# low-battery alert fire again next time it actually gets low.
|
|
locked.battery_history = []
|
|
locked.stats_recharge_cycles += 1
|
|
locked.battery_alert_sent = False
|
|
locked.battery_history.append([now, body.percent])
|
|
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
|
locked.battery_percent = body.percent
|
|
locked.battery_as_of = now
|
|
|
|
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
|
|
# Safety bound, not a real limit at realistic report rates --
|
|
# mirrors the old JSON list's cap.
|
|
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
|
|
if count is not None and count >= BATTERY_LOG_MAX:
|
|
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
|
|
BatteryLog.ts
|
|
).limit(count + 1 - BATTERY_LOG_MAX)
|
|
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
|
|
|
# Once per discharge cycle (see the recharge reset above), not
|
|
# once per report -- a frame idling at 4% would otherwise get an
|
|
# email every wake.
|
|
if (
|
|
locked.battery_alert_threshold_pct >= 0
|
|
and body.percent <= locked.battery_alert_threshold_pct
|
|
and not locked.battery_alert_sent
|
|
and locked.owner is not None
|
|
and locked.owner.email
|
|
):
|
|
should_alert = True
|
|
alert_email = locked.owner.email
|
|
alert_frame_name = locked.name or f"Frame {locked.id}"
|
|
|
|
if should_alert:
|
|
# Network I/O outside the lock, same convention as everywhere
|
|
# else in this file -- then a short re-lock to record that it
|
|
# went out, only on actual success (an SMTP hiccup should let
|
|
# the next report's still-below-threshold reading try again
|
|
# rather than silently giving up for the rest of the cycle).
|
|
settings = get_server_settings(db)
|
|
sent = mail.send_email(
|
|
settings, alert_email, f"{alert_frame_name}: battery low",
|
|
f"{alert_frame_name}'s battery is at {body.percent}%.",
|
|
)
|
|
if sent:
|
|
with frame_locked(db, frame.id) as locked:
|
|
locked.battery_alert_sent = True
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/frame/firmware")
|
|
def frame_firmware(frame: Frame = Depends(require_device)):
|
|
"""The frame's staged OTA image, streamed to the device
|
|
(esp_https_ota). 404 until something has been uploaded/fetched."""
|
|
path = firmware_path(frame.id)
|
|
if not path.exists():
|
|
raise HTTPException(404, "No firmware uploaded")
|
|
return FileResponse(path, media_type="application/octet-stream")
|
|
|
|
|
|
@router.get("/frame/share/{asset_id}")
|
|
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
|
"""Creates a 30-minute public Immich share link for asset_id and
|
|
redirects to it -- what the manage overlay's bottom-left QR code
|
|
points to. The link is created lazily, when this actually gets hit
|
|
(i.e. when someone scans it), not when the manage button was
|
|
pressed, so the 30-minute window starts when it's actually used.
|
|
Also scoped to the photo currently showing or queued on THIS frame --
|
|
not any arbitrary Immich asset id -- as a second layer even a leaked
|
|
token wouldn't bypass."""
|
|
require_configured(frame)
|
|
|
|
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
|
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
|
|
|
client = immich_client_for(frame)
|
|
try:
|
|
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not create share link: {e}") from e
|
|
|
|
return RedirectResponse(share_url)
|