Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
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.
This commit is contained in:
+144
-199
@@ -2,13 +2,18 @@
|
||||
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")."""
|
||||
("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
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -17,10 +22,9 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail, photo_queue, quiet_hours
|
||||
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 ..face_labels import compute_face_labels
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
@@ -29,6 +33,8 @@ from .common import (
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
RECHARGE_LOOKBACK,
|
||||
build_manage_content,
|
||||
get_or_refresh_calendar_events,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -41,7 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
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
|
||||
@@ -56,18 +62,21 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,11 +85,12 @@ def _frame_configured(frame: Frame) -> bool:
|
||||
return bool(url and key and frame.album_id)
|
||||
|
||||
|
||||
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
|
||||
# /frame/image looks up the frame's mode here. Only photos exists today.
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
# --- 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)
|
||||
return _setup_placeholder(frame, request, manage=manage)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
@@ -88,11 +98,108 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
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)
|
||||
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"
|
||||
inlay_wanted = locked.calendar_photo_inlay and view == "agenda"
|
||||
|
||||
events, summary = get_or_refresh_calendar_events(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,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +238,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
|
||||
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)
|
||||
@@ -141,44 +252,36 @@ def frame_image(
|
||||
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."""
|
||||
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)
|
||||
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
|
||||
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(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Forces an immediate advance to the next photo, ignoring
|
||||
refresh_interval_s, and resets the interval clock from now. Used by
|
||||
the device's next-photo button."""
|
||||
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 Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
||||
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(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Returns to the previously-current photo (the mirror image of
|
||||
/frame/advance -- see photo_queue.back_forced()), and resets the
|
||||
interval clock from now. A no-op (still 200, current photo
|
||||
unchanged) if there's no history to go back to -- same "always
|
||||
returns something displayable" contract as /frame/advance, rather
|
||||
than erroring. Used by the device's back-photo button."""
|
||||
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 Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
||||
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):
|
||||
@@ -272,115 +375,6 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
||||
return FileResponse(path, media_type="application/octet-stream")
|
||||
|
||||
|
||||
LOCATION_LINE_MAX_LEN = 14
|
||||
|
||||
US_STATE_ABBR = {
|
||||
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
||||
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
||||
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
||||
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
||||
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
|
||||
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
|
||||
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
|
||||
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
|
||||
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
|
||||
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
||||
"district of columbia": "DC",
|
||||
}
|
||||
|
||||
CA_PROVINCE_ABBR = {
|
||||
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
|
||||
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
|
||||
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
|
||||
"saskatchewan": "SK", "yukon": "YT",
|
||||
}
|
||||
|
||||
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
|
||||
CA_COUNTRY_NAMES = {"canada"}
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _format_location(exif: dict) -> tuple[str, str] | None:
|
||||
"""Returns (city_line, region_line), each independently truncated to
|
||||
fit its own corner-overlay line, or None if Immich hasn't geocoded
|
||||
this photo. region_line is the abbreviated state/province for US/CAN
|
||||
locations (e.g. "CA", "ON"), else the full country name."""
|
||||
city = exif.get("city")
|
||||
if not city:
|
||||
return None
|
||||
|
||||
state = exif.get("state")
|
||||
country = exif.get("country")
|
||||
country_key = (country or "").strip().lower()
|
||||
|
||||
if state and country_key in US_COUNTRY_NAMES:
|
||||
region = US_STATE_ABBR.get(state.strip().lower(), state)
|
||||
elif state and country_key in CA_COUNTRY_NAMES:
|
||||
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
|
||||
elif country:
|
||||
region = country
|
||||
elif state:
|
||||
region = state
|
||||
else:
|
||||
region = ""
|
||||
|
||||
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
|
||||
|
||||
|
||||
def _format_taken_at(exif: dict) -> str | None:
|
||||
raw = exif.get("dateTimeOriginal")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/frame/photo-info")
|
||||
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Location/date-taken text for the manage-button overlay, plus the
|
||||
asset id used to build the share-QR's target URL. Read-only, same
|
||||
idempotent current-photo semantics as /frame/image -- doesn't advance
|
||||
anything."""
|
||||
require_configured(frame)
|
||||
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:
|
||||
raise HTTPException(404, "No current photo")
|
||||
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
location = _format_location(exif)
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"location_line1": location[0] if location else None,
|
||||
"location_line2": location[1] if location and location[1] else None,
|
||||
"taken_at": _format_taken_at(exif),
|
||||
# Last value this frame itself reported (see /frame/battery) --
|
||||
# not a fresh reading. Good enough for a glance on the manage
|
||||
# overlay, and lets the device skip a synchronous ADC read (which
|
||||
# would otherwise need to happen before the overlay is composited,
|
||||
# i.e. before the photo it's part of is even pushed to the panel)
|
||||
# just to render this.
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
@@ -403,52 +397,3 @@ def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||
|
||||
return RedirectResponse(share_url)
|
||||
|
||||
|
||||
@router.get("/frame/face-labels")
|
||||
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Named-face positions for the manage button's escalated "level 2"
|
||||
menu -- who's in the current photo, per Immich's own face
|
||||
recognition (no detection/recognition happens here, see
|
||||
app/face_labels.py). Response is a flattened, fixed-slot shape
|
||||
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
|
||||
hand-rolled parser can read it with the same flat-scalar helpers it
|
||||
already has. Empty (count: 0) if no faces are named, or if anything
|
||||
about fetching them fails -- this is a "nice to have" addition to
|
||||
the overlay, not worth failing the whole menu over."""
|
||||
require_configured(frame)
|
||||
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
|
||||
display_mode = locked.display_mode
|
||||
orientation = locked.orientation
|
||||
|
||||
if not asset_id:
|
||||
return {"count": 0}
|
||||
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
return {"count": 0} # skip the extra preview download in the common no-named-faces case
|
||||
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
|
||||
|
||||
result: dict[str, object] = {"count": len(labels)}
|
||||
for i, label in enumerate(labels):
|
||||
result[f"name_{i}"] = label["name"]
|
||||
result[f"x_{i}"] = label["x"]
|
||||
result[f"y_{i}"] = label["y"]
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user