From c007acde75cf8f43e44ece19933a73499e2e3388 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Wed, 22 Jul 2026 19:06:49 -0400 Subject: [PATCH] Add calendar frame mode + server-side manage overlay (server) 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. --- .claude/worktrees/golden-imagining-lightning | 1 + server/README.md | 7 + server/app/calendar_feed.py | 110 ++++++ server/app/calendar_render.py | 292 ++++++++++++++++ server/app/face_labels.py | 43 +-- server/app/image_pipeline.py | 82 +++-- server/app/manage_overlay.py | 212 ++++++++++++ server/app/quiet_hours.py | 10 +- server/app/routers/api_frames.py | 112 +++++- server/app/routers/common.py | 192 ++++++++++- server/app/routers/device.py | 343 ++++++++----------- server/app/routers/frame_pages.py | 25 +- server/app/routers/pages.py | 15 +- server/app/static/frame_config.js | 64 ++++ server/app/templates/frame_config.html | 59 ++++ server/app/templates/settings.html | 13 + server/requirements.txt | 2 + 17 files changed, 1317 insertions(+), 265 deletions(-) create mode 160000 .claude/worktrees/golden-imagining-lightning create mode 100644 server/app/calendar_feed.py create mode 100644 server/app/calendar_render.py create mode 100644 server/app/manage_overlay.py diff --git a/.claude/worktrees/golden-imagining-lightning b/.claude/worktrees/golden-imagining-lightning new file mode 160000 index 0000000..fdcb444 --- /dev/null +++ b/.claude/worktrees/golden-imagining-lightning @@ -0,0 +1 @@ +Subproject commit fdcb444d57dac546cd2f527f725001c56e0b82b6 diff --git a/server/README.md b/server/README.md index 3f3a633..c72c515 100644 --- a/server/README.md +++ b/server/README.md @@ -206,6 +206,13 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`, to photos this frame is actually showing or has queued, not any Immich asset ID someone might guess -- a second layer a leaked device token alone wouldn't bypass. +- Calendar frame mode (`app/calendar_feed.py`) expands recurring events + (RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/), + which is LGPL-3.0-or-later -- the only non-permissively-licensed + dependency here. It's used as an ordinary `pip install` runtime import, + never vendored or modified, so this project's own code stays under its + own license; LGPL's copyleft terms apply to that library itself, not + to code that merely links against it dynamically. - The 6-color palette RGB values in `app/image_pipeline.py` (`DEFAULT_PALETTE_RGB`) are approximations, not measured values (Waveshare doesn't publish exact color primaries for this panel). diff --git a/server/app/calendar_feed.py b/server/app/calendar_feed.py new file mode 100644 index 0000000..656a917 --- /dev/null +++ b/server/app/calendar_feed.py @@ -0,0 +1,110 @@ +"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame +mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py). + +Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's +get_or_refresh_calendar_events) supply plain (owner_display_name, url) +pairs, not ORM objects, so this module stays testable against fixture .ics +text with no database or app involved. + +Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via +recurring-ical-events rather than hand-rolled -- that's genuinely fiddly +to get right (see its own docs), not worth reinventing. It's LGPL-3.0 (an +ordinary runtime pip dependency, never vendored/modified -- see the +server README's Notes section for why that doesn't put this project's own +code under LGPL terms). +""" + +from __future__ import annotations + +from datetime import date, datetime + +import httpx +import icalendar +import recurring_ical_events + +HTTP_TIMEOUT_S = 15.0 +FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB + +CHECK_INTERVAL_S = 20 * 60 # don't refetch/reparse any feed more often than this + +# How far back/forward each merge-fetch expands recurring events. Households +# look back far less than they plan ahead, hence the asymmetry. Browsing +# outside this window (calendar_browse_offset) just yields an empty view, +# not an error -- self-heals on the next normal wake regardless. +EXPAND_WINDOW_PAST_DAYS = 30 +EXPAND_WINDOW_FUTURE_DAYS = 200 + + +class CalendarFetchError(Exception): + """One feed was unreachable, not valid ICS, or too large. Raised by + fetch_source_events(); merge_events() is what catches this per-source + so one broken feed can't blank out another's events.""" + + +def fetch_source_events(url: str, window_start: date, window_end: date) -> list[dict]: + """One feed: download, parse, expand recurrences within + [window_start, window_end]. Raises CalendarFetchError on any problem + -- network, malformed ICS, or an oversized response.""" + try: + with httpx.stream("GET", url, timeout=HTTP_TIMEOUT_S, follow_redirects=True) as resp: + resp.raise_for_status() + chunks = [] + total = 0 + for chunk in resp.iter_bytes(): + total += len(chunk) + if total > FETCH_MAX_BYTES: + raise CalendarFetchError(f"Feed exceeds {FETCH_MAX_BYTES} bytes") + chunks.append(chunk) + body = b"".join(chunks) + except httpx.HTTPError as e: + raise CalendarFetchError(str(e)) from e + + try: + cal = icalendar.Calendar.from_ical(body) + occurrences = recurring_ical_events.of(cal).between(window_start, window_end) + except Exception as e: # icalendar/recurring_ical_events raise a mix of ValueError-family exceptions + raise CalendarFetchError(f"Could not parse ICS feed: {e}") from e + + events = [] + for occ in occurrences: + dtstart = occ.get("DTSTART") + dtend = occ.get("DTEND") + if dtstart is None: + continue + start_dt = dtstart.dt + end_dt = dtend.dt if dtend is not None else start_dt + all_day = not isinstance(start_dt, datetime) # date, not datetime -- VALUE=DATE + events.append({ + "summary": str(occ.get("SUMMARY") or "(untitled)"), + "start": start_dt.isoformat(), + "end": end_dt.isoformat(), + "all_day": all_day, + }) + return events + + +def merge_events( + sources: list[tuple[str, str]], window_start: date, window_end: date +) -> tuple[list[dict], str]: + """sources: [(owner_display_name, ics_url), ...]. Fetches each + independently -- one broken feed never blanks another's events. + Returns (merged_time_sorted_events, fetch_summary); fetch_summary is + "" when every source succeeded, else "N of M calendars unavailable" + (never *which* source -- naming whose feed is down to everyone who + looks at a shared household display is a bigger overshare than the + outage itself).""" + merged: list[dict] = [] + failures = 0 + for owner_display_name, url in sources: + try: + events = fetch_source_events(url, window_start, window_end) + except CalendarFetchError: + failures += 1 + continue + for event in events: + event["owner_display_name"] = owner_display_name + merged.append(event) + + merged.sort(key=lambda e: e["start"]) + summary = f"{failures} of {len(sources)} calendars unavailable" if failures else "" + return merged, summary diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py new file mode 100644 index 0000000..bf70a0c --- /dev/null +++ b/server/app/calendar_render.py @@ -0,0 +1,292 @@ +"""Renders calendar frame mode's three views (agenda/week/month) into the +panel's packed format, following image_pipeline.render_placeholder's own +precedent: build an RGB canvas with ImageDraw/ImageFont, then the same +_quantize/_transpose_and_pack every other renderer ends on. + +Event dicts here are calendar_feed.py's shape: {"summary", "start", "end" +(ISO 8601 strings), "all_day", "owner_display_name"}. +""" + +from __future__ import annotations + +import calendar as calendar_module +import io +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +from PIL import Image, ImageDraw, ImageFont + +from .image_pipeline import ( + DEFAULT_PALETTE_RGB, + _apply_manage_overlay, + _quantize, + _transpose_and_pack, + compose_into, + logical_render_size, +) + +CALENDAR_VIEWS = ["agenda", "week", "month"] +CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "week": "Week", "month": "Month"} + +MARGIN = 20 +BG = (255, 255, 255) +FG = (0, 0, 0) +MUTED = (110, 110, 110) +RULE = (200, 200, 200) + +# Cycled per distinct owner_display_name so a merged multi-person calendar +# can visually tell whose event is whose -- the panel's own non-black/ +# white ink colors, skipping black/white (index 0/1 in DEFAULT_PALETTE_RGB) +# since those are already the page's text/background. +OWNER_COLORS = DEFAULT_PALETTE_RGB[2:] + + +def _owner_color(owner_display_name: str, owners_seen: list[str]) -> tuple[int, int, int]: + if owner_display_name not in owners_seen: + owners_seen.append(owner_display_name) + return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)] + + +def _event_start(event: dict, tz: ZoneInfo) -> datetime | date: + """Parses event["start"] and, for timed events, converts to `tz` -- + calendar_feed.py stores whatever timezone each source event carried + (often UTC), but display/bucketing needs to happen in the frame's own + timezone.""" + dt = datetime.fromisoformat(event["start"]) + if event["all_day"]: + return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date() + return dt.astimezone(tz) + + +def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]: + on_day = [e for e in events if _local_date(e, tz) == day] + on_day.sort(key=lambda e: (not e["all_day"], e["start"])) + return on_day + + +def _local_date(event: dict, tz: ZoneInfo) -> date: + start = _event_start(event, tz) + return start if isinstance(start, date) and not isinstance(start, datetime) else start.date() + + +def _add_months(d: date, months: int) -> date: + total = d.month - 1 + months + year = d.year + total // 12 + month = total % 12 + 1 + day = min(d.day, calendar_module.monthrange(year, month)[1]) + return date(year, month, day) + + +def _fmt_time(dt: datetime) -> str: + text = dt.strftime("%I:%M %p").lstrip("0") + return text if text else "12:00 AM" + + +def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str: + """Pixel-width-aware truncation (unlike device.py's char-count + _truncate, tuned for a fixed firmware font at a fixed size) -- this + module draws at several different sizes, so truncation has to + measure the actual font/size in play.""" + if draw.textlength(text, font=font) <= max_width: + return text + ellipsis = "..." + lo, hi = 0, len(text) + while lo < hi: + mid = (lo + hi + 1) // 2 + if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width: + lo = mid + else: + hi = mid - 1 + return text[:lo] + ellipsis if lo else ellipsis + + +def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, + photo_inlay: Image.Image | None) -> Image.Image: + logical_w, logical_h = logical_render_size(orientation) + img = Image.new("RGB", (logical_w, logical_h), BG) + + text_x0 = MARGIN + text_w = logical_w - MARGIN * 2 + if photo_inlay is not None: + # Long axis split: landscape splits left/right, portrait top/bottom. + if logical_w >= logical_h: + photo_w = logical_w // 2 + photo = compose_into(photo_inlay, None, photo_w, logical_h, "crop_fill") + img.paste(photo, (0, 0)) + text_x0 = photo_w + MARGIN + text_w = logical_w - photo_w - MARGIN * 2 + else: + photo_h = logical_h // 2 + photo = compose_into(photo_inlay, None, logical_w, photo_h, "crop_fill") + img.paste(photo, (0, 0)) + + draw = ImageDraw.Draw(img) + # Smaller title when the inlay halves the available width -- "Wednesday, + # July 22" at full size doesn't fit ~360px, and a *narrower* column is + # exactly when a smaller font (rather than truncating to "Wednesday...") + # keeps it actually informative. + title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24) + body_font = ImageFont.load_default(size=22) + + text_y0 = MARGIN if photo_inlay is None or logical_w >= logical_h else logical_h // 2 + MARGIN + day = datetime.now(tz).date() + timedelta(days=browse_offset) + header = day.strftime("%A, %B ") + str(day.day) + draw.text((text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), fill=FG, font=title_font) + y = text_y0 + title_font.size + 12 + draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE) + y += 12 + + day_events = _events_on_day(events, day, tz) + owners_seen: list[str] = [] + row_h = body_font.size + 14 + max_rows = max(0, (logical_h - MARGIN - y) // row_h) + + if not day_events: + draw.text((text_x0, y), "Nothing scheduled", fill=MUTED, font=body_font) + for i, event in enumerate(day_events): + if i >= max_rows: + draw.text((text_x0, y), f"+{len(day_events) - max_rows} more", fill=MUTED, font=body_font) + break + color = _owner_color(event["owner_display_name"], owners_seen) + draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color) + time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz)) + line = f"{time_str} {event['summary']}" + draw.text((text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), fill=FG, font=body_font) + y += row_h + + return img + + +def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image: + logical_w, logical_h = logical_render_size(orientation) + img = Image.new("RGB", (logical_w, logical_h), BG) + draw = ImageDraw.Draw(img) + + header_font = ImageFont.load_default(size=18) + chip_font = ImageFont.load_default(size=14) + + today = datetime.now(tz).date() + week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=browse_offset) + col_w = (logical_w - MARGIN * 2) // 7 + header_h = 44 + owners_seen: list[str] = [] + + for col in range(7): + day = week_start + timedelta(days=col) + x0 = MARGIN + col * col_w + if col > 0: + draw.line([(x0, MARGIN), (x0, logical_h - MARGIN)], fill=RULE) + label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}" + draw.text((x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), fill=FG, font=header_font) + + y = MARGIN + header_h + row_h = chip_font.size + 10 + max_rows = max(0, (logical_h - MARGIN - y) // row_h) + day_events = _events_on_day(events, day, tz) + for i, event in enumerate(day_events): + if i >= max_rows: + draw.text((x0 + 6, y), f"+{len(day_events) - max_rows}", fill=MUTED, font=chip_font) + break + color = _owner_color(event["owner_display_name"], owners_seen) + draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color) + text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}" + draw.text((x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), fill=FG, font=chip_font) + y += row_h + + return img + + +def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image: + """Density dots per day, not literal event text -- real text at + typical month-cell size (~100x70px) is close to unreadable on a + 6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond.""" + logical_w, logical_h = logical_render_size(orientation) + img = Image.new("RGB", (logical_w, logical_h), BG) + draw = ImageDraw.Draw(img) + + header_font = ImageFont.load_default(size=16) + day_font = ImageFont.load_default(size=18) + + today = datetime.now(tz).date() + target_month = _add_months(date(today.year, today.month, 1), browse_offset) + weeks = list(calendar_module.Calendar(firstweekday=0).monthdatescalendar(target_month.year, target_month.month)) + + col_w = (logical_w - MARGIN * 2) // 7 + header_h = 28 + grid_top = MARGIN + header_h + row_h = (logical_h - MARGIN - grid_top) // len(weeks) + + for col, name in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]): + draw.text((MARGIN + col * col_w + 6, MARGIN), name, fill=MUTED, font=header_font) + + owners_seen: list[str] = [] + dot_r = 4 + for row, week in enumerate(weeks): + for col, day in enumerate(week): + x0 = MARGIN + col * col_w + y0 = grid_top + row * row_h + draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE) + in_month = day.month == target_month.month + color = FG if in_month else MUTED + if day == today: + draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG) + draw.text((x0 + 6, y0 + 4), str(day.day), fill=color, font=day_font) + + day_events = _events_on_day(events, day, tz) + dot_x = x0 + 8 + dot_y = y0 + row_h - 14 + for i, event in enumerate(day_events[:4]): + color = _owner_color(event["owner_display_name"], owners_seen) + draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color) + dot_x += dot_r * 2 + 4 + if len(day_events) > 4: + draw.text((dot_x, dot_y - 4), f"+{len(day_events) - 4}", fill=MUTED, font=header_font) + + return img + + +_BUILDERS = {"agenda": _build_agenda, "week": _build_week, "month": _build_month} + + +def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str, + photo_inlay: Image.Image | None, fetch_summary: str) -> Image.Image: + tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC") + builder = _BUILDERS.get(view, _build_agenda) + if builder is _build_agenda: + img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay) + else: + img = builder(events, browse_offset, orientation, tz) + + if fetch_summary: + draw = ImageDraw.Draw(img) + font = ImageFont.load_default(size=14) + logical_w, logical_h = img.size + draw.text((MARGIN, logical_h - MARGIN - font.size), fetch_summary, fill=MUTED, font=font) + + return img + + +def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str, + palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None, + fetch_summary: str = "", manage: dict | None = None) -> bytes: + """Renders one of CALENDAR_VIEWS to the panel's packed format. Always + returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every + other renderer honors.""" + img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary) + img = _apply_manage_overlay(img, manage) + quantized = _quantize(img, palette_rgb, dither_strength=1.0) + return _transpose_and_pack(quantized, orientation) + + +def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str, + palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None, + fetch_summary: str = "", manage: dict | None = None) -> bytes: + """Same pipeline as render_calendar, but a normal browser-viewable + PNG in logical (upright) orientation -- mirrors + image_pipeline.render_preview_png's relationship to render_frame.""" + img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary) + img = _apply_manage_overlay(img, manage) + quantized = _quantize(img, palette_rgb, dither_strength=1.0) + buf = io.BytesIO() + quantized.convert("RGB").save(buf, format="PNG") + return buf.getvalue() diff --git a/server/app/face_labels.py b/server/app/face_labels.py index f5e2a44..6e87241 100644 --- a/server/app/face_labels.py +++ b/server/app/face_labels.py @@ -1,6 +1,6 @@ """Maps named faces (from Immich's own face recognition/People feature) -onto their position in the final rendered 800x480 frame, for the -manage-button overlay's escalated "who's in this photo" menu level. +onto their position in the final rendered frame, for the manage-button +overlay's named-face labels (see manage_overlay.py, which draws them). No face detection or recognition happens here or anywhere else in this project -- Immich's GET /api/faces?id={assetId} already returns each @@ -15,32 +15,32 @@ import io from PIL import Image, ImageOps -from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size, logical_to_native +from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size -# Small caps, not arbitrary: each label is its own malloc'd overlay -# buffer on the device (see firmware/main/manage_qr_overlay.c), and the -# four existing fixed corner regions already use a meaningful chunk of -# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total -# overlay memory budget well clear of the WiFi/HTTP stack's own needs. -MAX_LABELED_FACES = 4 -NAME_MAX_LEN = 10 +# Not a memory constraint anymore (the overlay renders server-side now, +# not malloc'd per-label on the device) -- purely a legibility cap. A +# photo with a dozen named people would just be visual clutter regardless +# of what's rendering it. +MAX_LABELED_FACES = 6 def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str, orientation: str = "landscape") -> list[dict]: - """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native - 800x480 panel pixel space at each named face's bottom-center point. + """Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in + logical (pre-rotation) frame space at each named face's bottom-center + point -- manage_overlay.compose() draws these directly onto the + logical-space image before it's rotated into native panel space, so + no rotation happens here (contrast with the old firmware-side + version, which drew post-rotation and needed logical_to_native). Faces without an Immich-identified person name are skipped entirely. preview_bytes must be the same preview image render_frame() used for the currently-displayed frame, and display_mode/orientation must match the settings that were active then -- otherwise the placement - and rotation computed here won't match what's actually on screen. + computed here won't match what's actually on screen. - The placement math runs in logical (pre-rotation) space, matching - render_frame()'s composition step (see image_pipeline._placement_transform, - shared so the two can't drift apart); each anchor is then rotated - into native panel coordinates via logical_to_native(), since the - firmware draws labels in native space. + The placement math matches render_frame()'s own composition step + exactly (see image_pipeline._placement_transform, shared so the two + can't drift apart). """ named = [face for face in faces if (face.get("person") or {}).get("name")] if not named: @@ -71,11 +71,6 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h): continue # this face got cropped out of the final frame entirely - name = face["person"]["name"] - if len(name) > NAME_MAX_LEN: - name = name[: NAME_MAX_LEN - 3] + "..." - - native_x, native_y = logical_to_native(frame_x, frame_y, orientation) - labels.append({"name": name, "x": native_x, "y": native_y}) + labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)}) return labels diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index ae7a1f6..2e93dcc 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -222,26 +222,37 @@ def _placement_transform( return scale_x, scale_y, -left * scale_x, -top * scale_y +def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, target_h: int, + display_mode: str) -> Image.Image: + """Crop/resize/letterbox `source` per display_mode into an arbitrary + target_w x target_h box -- returns an RGB image, before enhancement or + quantization. See render_frame for what each display_mode does. + _compose() is the common case of this (target = the full panel, at + logical_render_size(orientation)); this more general form also backs + calendar_render.py's agenda photo-inlay, which composes into just a + sub-region of the panel instead of the whole thing.""" + fitted = ImageOps.exif_transpose(source.convert("RGB")) + + if display_mode == "stretch_fill": + return fitted.resize((target_w, target_h), Image.LANCZOS) + if display_mode == "letterbox": + scale = min(target_w / fitted.width, target_h / fitted.height) + new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale)) + resized = fitted.resize((new_w, new_h), Image.LANCZOS) + canvas = Image.new("RGB", (target_w, target_h), LETTERBOX_BG) + canvas.paste(resized, ((target_w - new_w) // 2, (target_h - new_h) // 2)) + return canvas + if display_mode == "crop_faces" and faces: + box = _face_aware_crop_box(fitted.width, fitted.height, target_w, target_h, faces) + return fitted.crop(box).resize((target_w, target_h), Image.LANCZOS) + return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces + + def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image: """Crop/resize/letterbox `source` per display_mode -- returns an RGB image at logical_render_size(orientation), before enhancement or quantization. See render_frame for what each display_mode does.""" - logical_w, logical_h = logical_render_size(orientation) - fitted = ImageOps.exif_transpose(source.convert("RGB")) - - if display_mode == "stretch_fill": - return fitted.resize((logical_w, logical_h), Image.LANCZOS) - if display_mode == "letterbox": - scale = min(logical_w / fitted.width, logical_h / fitted.height) - new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale)) - resized = fitted.resize((new_w, new_h), Image.LANCZOS) - canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG) - canvas.paste(resized, ((logical_w - new_w) // 2, (logical_h - new_h) // 2)) - return canvas - if display_mode == "crop_faces" and faces: - box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces) - return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS) - return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces + return compose_into(source, faces, *logical_render_size(orientation), display_mode) def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image: @@ -292,10 +303,25 @@ def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes: return bytes(out) +def _apply_manage_overlay(img: Image.Image, manage: dict | None) -> Image.Image: + """Composites the manage-button overlay (scan-to-manage QR, battery, + location/date/share-QR, named face labels) onto an already-composed, + already-enhanced image, if requested -- see manage_overlay.compose(). + Local import: manage_overlay is an optional, occasionally-used + concern (only /frame/*?manage=1 requests need it), same reasoning + render_placeholder already applies to its own `import qrcode`.""" + if manage is None: + return img + from . import manage_overlay + + return manage_overlay.compose(img, **manage) + + def render_frame(source: Image.Image, faces: list[dict] | None = None, orientation: str = "landscape", palette_rgb: list | None = None, display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0, - contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes: + contrast_boost: float = 1.0, dither_strength: float = 1.0, + manage: dict | None = None) -> bytes: """Fits `source` to the panel's resolution, applies color/contrast enhancement, quantizes it to the 6-color palette, and packs 2 pixels/byte the way epd7in3e.c expects. Always returns exactly @@ -318,8 +344,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None, `palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors, see Frame.palette_rgb) -- None uses the default. + + `manage` is a dict of manage_overlay.compose()'s kwargs (management_url, + battery_percent, location_lines, taken_at, share_url, face_labels), or + None to skip it -- see routers/device.py's build_manage_content(), + which callers pass this straight through from. Applied after + enhancement, before quantization, so the overlay's pure black/white + graphics aren't affected by color/contrast boost. """ fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) + fitted = _apply_manage_overlay(fitted, manage) quantized = _quantize(fitted, palette_rgb, dither_strength) return _transpose_and_pack(quantized, orientation) @@ -327,13 +361,15 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None, def render_preview_png(source: Image.Image, faces: list[dict] | None = None, orientation: str = "landscape", palette_rgb: list | None = None, display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0, - contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes: + contrast_boost: float = 1.0, dither_strength: float = 1.0, + manage: dict | None = None) -> bytes: """Identical composition/enhancement/quantization pipeline as render_frame, but returned as a normal browser-viewable PNG in logical (upright, as-the-frame-actually-hangs) orientation rather than packed native-panel bytes and rotation -- what the web UI's "how it will look on the frame" preview shows.""" fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost) + fitted = _apply_manage_overlay(fitted, manage) quantized = _quantize(fitted, palette_rgb, dither_strength) buf = io.BytesIO() quantized.convert("RGB").save(buf, format="PNG") @@ -341,11 +377,16 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None, def render_placeholder(lines: list[str], qr_url: str | None = None, - orientation: str = "landscape", palette_rgb: list | None = None) -> bytes: + orientation: str = "landscape", palette_rgb: list | None = None, + manage: dict | None = None) -> bytes: """A readable full-panel message (plus an optional QR code) in the same packed format as render_frame -- what /frame/image serves for a frame that isn't claimed or configured yet, so a fresh device shows - instructions instead of an error screen and never error-loops.""" + instructions instead of an error screen and never error-loops. + + `manage`, same as render_frame's -- lets the manage button still work + (at minimum, the scan-to-manage QR) on a frame that isn't configured + yet.""" from PIL import ImageDraw, ImageFont logical_w, logical_h = logical_render_size(orientation) @@ -386,5 +427,6 @@ def render_placeholder(lines: list[str], qr_url: str | None = None, if qr_img: img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14)) + img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) return _transpose_and_pack(quantized, orientation) diff --git a/server/app/manage_overlay.py b/server/app/manage_overlay.py new file mode 100644 index 0000000..00b573c --- /dev/null +++ b/server/app/manage_overlay.py @@ -0,0 +1,212 @@ +"""Composites the manage-button overlay -- "scan to manage" QR, battery, +location/date-taken, share-QR, named face labels -- server-side, onto an +already-composed image (any mode: a photo, or a calendar view), before +quantization. Replaces what used to be firmware/main/manage_qr_overlay.c +generating and positioning all of this on-device. + +Corner/spacing constants below are plain Python now, not a protocol +contract with firmware -- adjustable here without touching anything else. +Uses the same toolkit image_pipeline.render_placeholder already does +(PIL ImageDraw/ImageFont, the qrcode library), just doing more with it. +""" + +from __future__ import annotations + +from PIL import Image, ImageDraw, ImageFont + +PADDING = 16 +QR_TEXT_GAP = 8 +LINE_GAP = 4 +PANEL_MARGIN = 20 +QR_TARGET_PX = 180 + +TITLE_FONT_SIZE = 22 +BODY_FONT_SIZE = 20 + +BATTERY_ICON_W = 40 +BATTERY_ICON_H = 22 +BATTERY_ICON_STROKE = 2 +BATTERY_NUB_W = 5 +BATTERY_NUB_H = 10 +BATTERY_ICON_TEXT_GAP = 8 +BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box + +FACE_LABEL_PADDING = 8 +FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box + + +def _font(size: int) -> ImageFont.ImageFont: + return ImageFont.load_default(size=size) + + +def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image: + import qrcode + + qr = qrcode.QRCode(border=1, box_size=1) + qr.add_data(url) + qr.make(fit=True) + raw = qr.make_image().get_image().convert("RGB") + scale = max(1, target_px // raw.width) + return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST) + + +def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]: + """(width, height) of `lines` stacked with LINE_GAP between them, at + `font` -- the box _draw_text_box below will need.""" + w = 0 + h = 0 + for i, line in enumerate(lines): + bbox = draw.textbbox((0, 0), line, font=font) + w = max(w, bbox[2] - bbox[0]) + h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0) + return w, h + + +def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont, + center_x: int, top: int) -> None: + y = top + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + w = bbox[2] - bbox[0] + draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font) + y += (bbox[3] - bbox[1]) + LINE_GAP + + +def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str], + corner: str) -> tuple[int, int, int, int]: + """White-padded box with a QR code and centered caption lines below + it, placed in one of the panel's four corners. Returns (x0, y0, w, h) + -- callers that need to anchor something else relative to this box + (the battery, below the manage QR) use it instead of recomputing the + same geometry a second time.""" + qr_img = _qr_image(url) + text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0) + content_w = max(qr_img.width, text_w) + content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0) + + w = content_w + PADDING * 2 + h = content_h + PADDING * 2 + x0, y0 = _corner_origin(img.size, (w, h), corner) + + draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + center_x = x0 + w // 2 + img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING)) + if caption: + _draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP) + return x0, y0, w, h + + +def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None: + """White-padded box with centered text lines, placed in one of the + panel's four corners.""" + font = _font(BODY_FONT_SIZE) + text_w, text_h = _text_box(draw, lines, font) + w = text_w + PADDING * 2 + h = text_h + PADDING * 2 + x0, y0 = _corner_origin(img.size, (w, h), corner) + + draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + _draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING) + + +def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]: + img_w, img_h = img_size + box_w, box_h = box_size + if corner == "top-left": + return PANEL_MARGIN, PANEL_MARGIN + if corner == "top-right": + return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN + if corner == "bottom-left": + return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h + return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right + + +def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int, + anchor_w: int, anchor_h: int) -> None: + """Battery glyph + "NN%" text, right-aligned under the given anchor + box (the manage QR box) -- a sensible default position, not a + constraint anything else has to route around; move this call site's + arguments to place it anywhere else instead.""" + font = _font(BODY_FONT_SIZE) + text = f"{percent}%" + icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W + text_w = draw.textlength(text, font=font) + content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w + content_h = max(font.size, BATTERY_ICON_H) + + w = int(content_w + PADDING * 2) + h = int(content_h + PADDING * 2) + x0 = anchor_x0 + anchor_w - w + y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP + + draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + + icon_x = x0 + PADDING + icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2 + draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0), + width=BATTERY_ICON_STROKE) + nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2 + draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H], + fill=(0, 0, 0)) + draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2), + text, fill=(0, 0, 0), font=font) + + +def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None: + """White-padded name label centered under an arbitrary (anchor_x, + anchor_y) point, flipped above if there's no room below, clamped to + stay fully on-panel -- unlike the four corner boxes (always in-bounds + by construction), a face can be anywhere, including near an edge.""" + font = _font(BODY_FONT_SIZE) + text_w = draw.textlength(name, font=font) + bbox = draw.textbbox((0, 0), name, font=font) + text_h = bbox[3] - bbox[1] + + w = int(text_w + FACE_LABEL_PADDING * 2) + h = int(text_h + FACE_LABEL_PADDING * 2) + img_w, img_h = img.size + + x0 = anchor_x - w // 2 + y0 = anchor_y + FACE_LABEL_GAP + if y0 + h > img_h: + y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead + x0 = max(0, min(x0, img_w - w)) + y0 = max(0, min(y0, img_h - h)) + + draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0)) + draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font) + + +def compose(image: Image.Image, management_url: str, battery_percent: int | None = None, + location_lines: tuple[str, str] | None = None, taken_at: str | None = None, + share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image: + """Draws the manage overlay onto a copy of `image` (RGB, any mode's + already-composed/enhanced logical-space canvas) and returns it. + management_url's "scan to manage" box always shows; everything else + is optional and simply omitted when not given -- battery_percent + None or out of 0-100 skips the battery box, location_lines/taken_at/ + share_url empty/None skip their own box, face_labels empty skips + those.""" + img = image.copy() + draw = ImageDraw.Draw(img) + + qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right") + + if battery_percent is not None and 0 <= battery_percent <= 100: + _draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h) + + if location_lines and location_lines[0]: + lines = [line for line in location_lines if line] + _draw_text_box(img, draw, lines, "top-left") + + if taken_at: + _draw_text_box(img, draw, [taken_at], "bottom-right") + + if share_url: + _draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left") + + for label in face_labels or []: + if label.get("name"): + _draw_face_label(img, draw, label["name"], label["x"], label["y"]) + + return img diff --git a/server/app/quiet_hours.py b/server/app/quiet_hours.py index d76a65e..d09005f 100644 --- a/server/app/quiet_hours.py +++ b/server/app/quiet_hours.py @@ -5,7 +5,7 @@ Frame ORM model satisfy it.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo, available_timezones # Populated once from the OS's zoneinfo database (installed via the @@ -33,6 +33,14 @@ def _zoneinfo(name: str) -> ZoneInfo: return ZoneInfo("UTC") +def local_date(cfg) -> date: + """`date.today()` in cfg.timezone (falls back to UTC for an + unrecognized zone, same as _zoneinfo) -- what calendar mode's "today" + anchor and browse-offset both key off of, so every part of that + feature agrees on what day it is for a given frame.""" + return datetime.now(_zoneinfo(cfg.timezone)).date() + + def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]: """Whether `now` falls inside the quiet-hours window, and the next boundary: if inside, when it ends; if outside, when it next starts. diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 4697ec1..5277890 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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(...), diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 8672dc1..dd32d75 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -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 diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 7a53b82..3f83183 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -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 diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index b64d2e4..662b681 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -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), ) diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py index cd20186..a850d88 100644 --- a/server/app/routers/pages.py +++ b/server/app/routers/pages.py @@ -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." diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 6b5d97c..ccefdb2 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -6,6 +6,7 @@ async function saveConfig() { const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60; const body = new URLSearchParams({ + mode: document.getElementById('frame_mode').value, name: document.getElementById('frame_name').value || '', order: document.getElementById('order').value, orientation: document.getElementById('orientation').value, @@ -36,6 +37,69 @@ document.getElementById('config-form').addEventListener('submit', async (e) => { } }); +// ---- Calendar card: mode/view toggling, its own save, self opt-in, preview ---- + +const calendarCard = document.getElementById('calendar-card'); +if (calendarCard) { + document.getElementById('frame_mode').addEventListener('change', () => { + calendarCard.style.display = document.getElementById('frame_mode').value === 'calendar' ? 'block' : 'none'; + }); + + const inlayRow = document.getElementById('calendar-inlay-row'); + const inlayHint = document.getElementById('calendar-inlay-hint'); + document.getElementById('calendar_view').addEventListener('change', () => { + const isAgenda = document.getElementById('calendar_view').value === 'agenda'; + inlayRow.style.display = isAgenda ? 'flex' : 'none'; + inlayHint.style.display = isAgenda ? 'block' : 'none'; + }); + + document.getElementById('calendar-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + calendar_view: document.getElementById('calendar_view').value, + calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked), + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadCalendarPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + // Each person's own opt-in -- auto-saves on toggle, not batched into + // the form above, since it's the toggling user's own preference (see + // api_frames.py's /calendar-included), not a frame-wide setting. + document.querySelectorAll('.calendar-self-toggle').forEach((el) => { + el.addEventListener('change', async () => { + try { + const resp = await fetch(`${window.FRAME_API}/calendar-included`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ included: el.checked }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.'); + } catch (e) { + el.checked = !el.checked; + showStatus(false, e.message); + } + }); + }); + + function loadCalendarPreview() { + document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; + } + document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); + loadCalendarPreview(); +} + async function takeControl() { try { const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' }); diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 1be3465..9e309ec 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -17,6 +17,12 @@

Display settings

+ @@ -77,6 +83,59 @@
+ +
+

Calendar

+
+ +
+ + +
+

+ Uses the same album configured on the Photos tab -- nothing extra to set up.

+ +
+ +

Included calendars

+

Each linked person decides whether their own calendar + contributes to this frame -- being linked here doesn't include it + automatically.

+ + + {% if frame.calendar_fetch_summary %} +

Last fetch: {{ frame.calendar_fetch_summary }}

+ {% endif %} + +

Preview

+

How this frame's calendar currently renders.

+ Calendar preview + +
diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html index 7278d75..9f8d4a9 100644 --- a/server/app/templates/settings.html +++ b/server/app/templates/settings.html @@ -32,6 +32,19 @@ this Immich library. The key needs read access to albums/assets/faces plus sharedLink.create for the on-frame share QR.

+

Calendar

+ +

Your personal calendar + subscription link (no login needed -- e.g. Google Calendar's + Settings → "Secret address in iCal format", or Apple/Outlook/ + Nextcloud's equivalent). Setting it here doesn't show it anywhere + by itself -- include it on any frame you're linked to from that + frame's Configuration → Calendar card, so a frame only shows + calendars people have actually chosen to share with it.

+

Change password