Add calendar frame mode + server-side manage overlay (server)
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:
2026-07-22 19:06:49 -04:00
parent 15e37c77cd
commit c007acde75
17 changed files with 1317 additions and 265 deletions
+97 -15
View File
@@ -17,7 +17,6 @@ from __future__ import annotations
import logging
import time
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
@@ -26,7 +25,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..image_pipeline import (
@@ -37,15 +36,19 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from ..models import BatteryLog, Frame, UserFrame
from .common import (
FRAME_MODES,
OVERDUE_FACTOR,
battery_estimate_s,
calendar_sources_for_frame,
fetch_source_and_faces,
get_or_refresh_calendar_events,
immich_client_for,
immich_creds,
list_assets,
require_configured,
valid_http_url,
)
logger = logging.getLogger(__name__)
@@ -60,17 +63,6 @@ MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
def _valid_repo_url(url: str) -> bool:
"""The frame will periodically fetch from this URL on its own (see
gitea_releases.py) and, with auto-update on, install whatever it
finds -- unlike a one-off manual firmware upload, that's a standing
trust relationship, so it's worth rejecting obviously-wrong input at
save time rather than only failing later at fetch time. http(s) only
-- no file://, no other schemes."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
@@ -104,6 +96,9 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
mode: str | None = Form(None),
calendar_view: str | None = Form(None),
calendar_photo_inlay: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
@@ -142,7 +137,7 @@ def api_config_save(
cfg.timezone = timezone
if firmware_update_repo_url is not None:
stripped = firmware_update_repo_url.strip()
if stripped and not _valid_repo_url(stripped):
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:
@@ -167,6 +162,18 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
if mode is not None:
cfg.mode = mode if mode in FRAME_MODES else "photos"
if calendar_view is not None:
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
if new_view != cfg.calendar_view:
# A stale offset means something different in a different
# view's units (days vs. weeks vs. months) -- same
# reasoning as album_id's reset above.
cfg.calendar_browse_offset = 0
cfg.calendar_view = new_view
if calendar_photo_inlay is not None:
cfg.calendar_photo_inlay = calendar_photo_inlay
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -399,6 +406,81 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
return Response(content=png, media_type="image/png")
class CalendarIncludedRequest(BaseModel):
included: bool
@router.post("/api/frames/{frame_id}/calendar-included")
def api_calendar_included(
body: CalendarIncludedRequest,
request: Request,
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
db: Session = Depends(get_db),
):
"""A user's own opt-in into this frame's merged calendar (see
UserFrame.calendar_included). Deliberately not require_frame_control:
this is the toggling user's own data-sharing preference about their
own calendar, not a frame setting its controller manages on someone
else's behalf -- there's no target user_id in the request body by
design, it always toggles the calling session's own row."""
user = require_user_api(request, db)
row = db.get(UserFrame, (user.id, frame.id))
if row is None:
raise HTTPException(404, "Not linked to this frame")
row.calendar_included = body.included
# Force this frame's merged cache to pick up the change promptly
# rather than waiting out the throttle.
frame.calendar_checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.calendar_included}
def _calendar_photo_inlay(frame: Frame, db: Session):
"""The agenda view's optional photo-inlay source image, or None if
inlay is off, not agenda view, or the frame's photos-mode album isn't
configured. Shared shape between the live render (routers/device.py's
_render_calendar_mode) and this preview endpoint; small enough that
duplicating rather than factoring out is fine, since the two call
sites differ slightly in error handling."""
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
return None
url, key = immich_creds(frame)
if not (url and key and frame.album_id):
return None
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
return None
import io
from PIL import Image
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
except HTTPException:
return None
@router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same merged, cached event set a live device render would use
-- not a live preview of an unsaved calendar_view choice, same
"reflects what's currently saved" convention as preview/rendered."""
if not calendar_sources_for_frame(db, frame):
raise HTTPException(400, "No calendars included on this frame yet")
events, summary = get_or_refresh_calendar_events(db, frame)
photo_inlay = _calendar_photo_inlay(frame, db)
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
png = calendar_render.render_calendar_preview_png(
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
)
return Response(content=png, media_type="image/png")
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
file: UploadFile = File(...),
+189 -3
View File
@@ -5,6 +5,9 @@ from __future__ import annotations
import io
import logging
import os
import time
from datetime import datetime, timedelta
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
@@ -12,12 +15,16 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_feed, quiet_hours
from ..db import frame_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import Frame
from ..models import Frame, User, UserFrame
logger = logging.getLogger(__name__)
FRAME_MODES = ("photos", "calendar")
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
@@ -100,12 +107,12 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
return Image.open(io.BytesIO(jpeg_bytes)), faces
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
source, faces = fetch_source_and_faces(client, frame, asset_id)
return render_frame(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)
dither_strength=frame.dither_strength, manage=manage)
def battery_estimate_s(frame: Frame) -> int | None:
@@ -152,3 +159,182 @@ def shell_context(request, db: Session, user, active_frame: Frame | None = None,
"active_frame": active_frame,
"active_nav": active_nav,
}
def valid_http_url(url: str) -> bool:
"""http(s)-only URL check -- generalized from what was api_frames.py's
frame-specific _valid_repo_url, now shared by two call sites (the
Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
# --- Location/date-taken text for the manage overlay (see build_manage_content) ---
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
def _manage_content_asset_id(frame: Frame) -> str | None:
"""Whether frame.current_asset_id refers to a photo actually visible
right now, for whichever mode is active -- always true in photos
mode; only true in calendar mode when the agenda view's photo inlay
is on (otherwise current_asset_id could be stale, left over from
whenever photos mode last ran, and showing its location/date/share
info on a manage overlay over a view with no visible photo at all
would be actively misleading, not just unhelpful)."""
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
return frame.current_asset_id if relevant and frame.current_asset_id else None
def build_manage_content(db: Session, frame: Frame, request) -> dict:
"""Gathers everything manage_overlay.compose() needs -- what used to
be two separate device-facing endpoints (/frame/photo-info,
/frame/face-labels, both removed -- see the module docstring in
manage_overlay.py) are now just internal calls made here, once,
server-side, since compositing itself also moved server-side.
management_url and battery_percent always apply; location/date/
share-URL/face-labels only when there's a real current photo (see
_manage_content_asset_id) -- absent otherwise, which
manage_overlay.compose() already treats as "skip that region",
exactly the graceful-degradation behavior the old firmware-fetched
version had."""
base = str(request.base_url).rstrip("/")
content: dict = {
"management_url": f"{base}/m/{frame.manage_token}",
"battery_percent": frame.battery_percent,
}
asset_id = _manage_content_asset_id(frame)
if not asset_id:
return content
client = immich_client_for(frame)
try:
asset = client.get_asset(asset_id)
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
return content
exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif)
content["share_url"] = f"{base}/frame/share/{asset_id}"
if any((face.get("person") or {}).get("name") for face in faces):
try:
preview_bytes = client.download_asset_preview(asset_id)
from ..face_labels import compute_face_labels
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
"""Every user linked to this frame with BOTH a calendar URL set AND
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
set calendar_feed.merge_events needs. [(display_name-or-username,
ics_url), ...]."""
rows = db.execute(
select(User)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
User.calendar_ics_url != "")
).scalars().all()
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
-- same shape as the Gitea release-check throttle in api_frames.py's
api_firmware_check. One shared cache for the whole merged result
(every included user's events together), not per-user -- ICS feeds
are small and this refetches at most every ~20 minutes regardless of
how many are included, so per-user cache columns would add
bookkeeping for a marginal benefit."""
now = time.time()
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
return frame.calendar_cached_events, frame.calendar_fetch_summary
sources = calendar_sources_for_frame(db, frame)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
)
with frame_locked(db, frame.id) as locked:
locked.calendar_cached_events = events
locked.calendar_fetch_summary = summary
locked.calendar_checked_at = now
return events, summary
+144 -199
View File
@@ -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
+24 -1
View File
@@ -8,9 +8,11 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..image_pipeline import (
DEFAULT_PALETTE_RGB,
@@ -18,7 +20,7 @@ from ..image_pipeline import (
PALETTE_LABELS,
palette_to_hex,
)
from ..models import Frame
from ..models import Frame, User, UserFrame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
@@ -43,6 +45,25 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
"""Every user linked to this frame, their calendar opt-in state, and
whether they even have a calendar URL set -- what the Configuration
tab's "Included calendars" list needs. Whether a given row is *this*
viewer's own (and therefore editable) is decided in the template,
using the `user` shell_context already provides."""
rows = db.execute(
select(User, UserFrame.calendar_included)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id)
.order_by(User.username)
).all()
return [
{"user_id": u.id, "display_name": u.display_name or u.username,
"has_url": bool(u.calendar_ics_url), "included": included}
for u, included in rows
]
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(
@@ -52,6 +73,8 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
display_mode_labels=DISPLAY_MODE_LABELS,
calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id),
)
+13 -2
View File
@@ -1,6 +1,6 @@
"""HTML page routes: first-run setup, login/logout, user settings, and
the admin panel. The frame pages themselves stay in main.py (Phase A's
single-frame index) until the Phase D restructure.
the admin panel. The per-frame pages (Photos/Configuration/Stats) live in
routers/frame_pages.py.
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
(checked explicitly) rather than the X-CSRF-Token header the JSON API
@@ -35,6 +35,7 @@ from ..auth import (
)
from ..db import get_db
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
from .common import valid_http_url
logger = logging.getLogger(__name__)
@@ -422,6 +423,7 @@ def settings_submit(
email: str = Form(""),
immich_url: str = Form(""),
immich_api_key: str = Form(""),
calendar_ics_url: str = Form(""),
current_password: str = Form(""),
new_password: str = Form(""),
db: Session = Depends(get_db),
@@ -441,6 +443,15 @@ def settings_submit(
if immich_api_key.strip():
user.immich_api_key = immich_api_key.strip()
# Unlike the API key, this isn't a secret -- it round-trips visibly in
# the form, so blank means an explicit clear (there needs to be some
# way to actually remove a linked calendar), not "keep existing".
stripped_ics = calendar_ics_url.strip()
if stripped_ics and not valid_http_url(stripped_ics):
error = "Calendar URL must be a plain http:// or https:// URL."
else:
user.calendar_ics_url = stripped_ics
if new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed."