Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps.
This commit is contained in:
+1
Submodule .claude/worktrees/golden-imagining-lightning added at fdcb444d57
@@ -206,6 +206,13 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
to photos this frame is actually showing or has queued, not any
|
to photos this frame is actually showing or has queued, not any
|
||||||
Immich asset ID someone might guess -- a second layer a leaked device
|
Immich asset ID someone might guess -- a second layer a leaked device
|
||||||
token alone wouldn't bypass.
|
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`
|
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||||
(Waveshare doesn't publish exact color primaries for this panel).
|
(Waveshare doesn't publish exact color primaries for this panel).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
+19
-24
@@ -1,6 +1,6 @@
|
|||||||
"""Maps named faces (from Immich's own face recognition/People feature)
|
"""Maps named faces (from Immich's own face recognition/People feature)
|
||||||
onto their position in the final rendered 800x480 frame, for the
|
onto their position in the final rendered frame, for the manage-button
|
||||||
manage-button overlay's escalated "who's in this photo" menu level.
|
overlay's named-face labels (see manage_overlay.py, which draws them).
|
||||||
|
|
||||||
No face detection or recognition happens here or anywhere else in this
|
No face detection or recognition happens here or anywhere else in this
|
||||||
project -- Immich's GET /api/faces?id={assetId} already returns each
|
project -- Immich's GET /api/faces?id={assetId} already returns each
|
||||||
@@ -15,32 +15,32 @@ import io
|
|||||||
|
|
||||||
from PIL import Image, ImageOps
|
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
|
# Not a memory constraint anymore (the overlay renders server-side now,
|
||||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
# not malloc'd per-label on the device) -- purely a legibility cap. A
|
||||||
# four existing fixed corner regions already use a meaningful chunk of
|
# photo with a dozen named people would just be visual clutter regardless
|
||||||
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
|
# of what's rendering it.
|
||||||
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
|
MAX_LABELED_FACES = 6
|
||||||
MAX_LABELED_FACES = 4
|
|
||||||
NAME_MAX_LEN = 10
|
|
||||||
|
|
||||||
|
|
||||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
||||||
orientation: str = "landscape") -> list[dict]:
|
orientation: str = "landscape") -> list[dict]:
|
||||||
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
|
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
|
||||||
800x480 panel pixel space at each named face's bottom-center point.
|
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.
|
Faces without an Immich-identified person name are skipped entirely.
|
||||||
preview_bytes must be the same preview image render_frame() used for
|
preview_bytes must be the same preview image render_frame() used for
|
||||||
the currently-displayed frame, and display_mode/orientation must
|
the currently-displayed frame, and display_mode/orientation must
|
||||||
match the settings that were active then -- otherwise the placement
|
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
|
The placement math matches render_frame()'s own composition step
|
||||||
render_frame()'s composition step (see image_pipeline._placement_transform,
|
exactly (see image_pipeline._placement_transform, shared so the two
|
||||||
shared so the two can't drift apart); each anchor is then rotated
|
can't drift apart).
|
||||||
into native panel coordinates via logical_to_native(), since the
|
|
||||||
firmware draws labels in native space.
|
|
||||||
"""
|
"""
|
||||||
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
||||||
if not named:
|
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):
|
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
||||||
continue # this face got cropped out of the final frame entirely
|
continue # this face got cropped out of the final frame entirely
|
||||||
|
|
||||||
name = face["person"]["name"]
|
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)})
|
||||||
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})
|
|
||||||
|
|
||||||
return labels
|
return labels
|
||||||
|
|||||||
@@ -222,26 +222,37 @@ def _placement_transform(
|
|||||||
return scale_x, scale_y, -left * scale_x, -top * scale_y
|
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:
|
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
|
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
|
||||||
image at logical_render_size(orientation), before enhancement or
|
image at logical_render_size(orientation), before enhancement or
|
||||||
quantization. See render_frame for what each display_mode does."""
|
quantization. See render_frame for what each display_mode does."""
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
return compose_into(source, faces, *logical_render_size(orientation), display_mode)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
|
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)
|
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,
|
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
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
|
"""Fits `source` to the panel's resolution, applies color/contrast
|
||||||
enhancement, quantizes it to the 6-color palette, and packs 2
|
enhancement, quantizes it to the 6-color palette, and packs 2
|
||||||
pixels/byte the way epd7in3e.c expects. Always returns exactly
|
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,
|
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
|
||||||
see Frame.palette_rgb) -- None uses the default.
|
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 = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||||
|
fitted = _apply_manage_overlay(fitted, manage)
|
||||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||||
return _transpose_and_pack(quantized, orientation)
|
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,
|
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
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
|
"""Identical composition/enhancement/quantization pipeline as
|
||||||
render_frame, but returned as a normal browser-viewable PNG in
|
render_frame, but returned as a normal browser-viewable PNG in
|
||||||
logical (upright, as-the-frame-actually-hangs) orientation rather
|
logical (upright, as-the-frame-actually-hangs) orientation rather
|
||||||
than packed native-panel bytes and rotation -- what the web UI's
|
than packed native-panel bytes and rotation -- what the web UI's
|
||||||
"how it will look on the frame" preview shows."""
|
"how it will look on the frame" preview shows."""
|
||||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, 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)
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
quantized.convert("RGB").save(buf, format="PNG")
|
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,
|
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
|
"""A readable full-panel message (plus an optional QR code) in the
|
||||||
same packed format as render_frame -- what /frame/image serves for a
|
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
|
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
|
from PIL import ImageDraw, ImageFont
|
||||||
|
|
||||||
logical_w, logical_h = logical_render_size(orientation)
|
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:
|
if qr_img:
|
||||||
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
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)
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||||
return _transpose_and_pack(quantized, orientation)
|
return _transpose_and_pack(quantized, orientation)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,7 +5,7 @@ Frame ORM model satisfy it."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo, available_timezones
|
from zoneinfo import ZoneInfo, available_timezones
|
||||||
|
|
||||||
# Populated once from the OS's zoneinfo database (installed via the
|
# Populated once from the OS's zoneinfo database (installed via the
|
||||||
@@ -33,6 +33,14 @@ def _zoneinfo(name: str) -> ZoneInfo:
|
|||||||
return ZoneInfo("UTC")
|
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]:
|
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
|
"""Whether `now` falls inside the quiet-hours window, and the next
|
||||||
boundary: if inside, when it ends; if outside, when it next starts.
|
boundary: if inside, when it ends; if outside, when it next starts.
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
@@ -26,7 +25,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
@@ -37,15 +36,19 @@ from ..image_pipeline import (
|
|||||||
render_preview_png,
|
render_preview_png,
|
||||||
)
|
)
|
||||||
from ..firmware import firmware_path, parse_app_version
|
from ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import BatteryLog, Frame
|
from ..models import BatteryLog, Frame, UserFrame
|
||||||
from .common import (
|
from .common import (
|
||||||
|
FRAME_MODES,
|
||||||
OVERDUE_FACTOR,
|
OVERDUE_FACTOR,
|
||||||
battery_estimate_s,
|
battery_estimate_s,
|
||||||
|
calendar_sources_for_frame,
|
||||||
fetch_source_and_faces,
|
fetch_source_and_faces,
|
||||||
|
get_or_refresh_calendar_events,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
list_assets,
|
list_assets,
|
||||||
require_configured,
|
require_configured,
|
||||||
|
valid_http_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -60,17 +63,6 @@ MAX_QUEUE_TARGET_LEN = 5000
|
|||||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
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")
|
@router.get("/api/frames/{frame_id}/albums")
|
||||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||||
url, key = immich_creds(frame)
|
url, key = immich_creds(frame)
|
||||||
@@ -104,6 +96,9 @@ def api_config_save(
|
|||||||
color_boost: float | None = Form(None),
|
color_boost: float | None = Form(None),
|
||||||
contrast_boost: float | None = Form(None),
|
contrast_boost: float | None = Form(None),
|
||||||
dither_strength: 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),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -142,7 +137,7 @@ def api_config_save(
|
|||||||
cfg.timezone = timezone
|
cfg.timezone = timezone
|
||||||
if firmware_update_repo_url is not None:
|
if firmware_update_repo_url is not None:
|
||||||
stripped = firmware_update_repo_url.strip()
|
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")
|
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
||||||
cfg.firmware_update_repo_url = stripped
|
cfg.firmware_update_repo_url = stripped
|
||||||
if firmware_auto_update is not None:
|
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))
|
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||||
if dither_strength is not None:
|
if dither_strength is not None:
|
||||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
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
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
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")
|
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")
|
@router.post("/api/frames/{frame_id}/firmware")
|
||||||
def api_firmware_upload(
|
def api_firmware_upload(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -12,12 +15,16 @@ from PIL import Image
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import calendar_feed, quiet_hours
|
||||||
|
from ..db import frame_locked
|
||||||
from ..image_pipeline import render_frame
|
from ..image_pipeline import render_frame
|
||||||
from ..immich_client import ImmichClient
|
from ..immich_client import ImmichClient
|
||||||
from ..models import Frame
|
from ..models import Frame, User, UserFrame
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FRAME_MODES = ("photos", "calendar")
|
||||||
|
|
||||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
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
|
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
|
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)
|
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
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:
|
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_frame": active_frame,
|
||||||
"active_nav": active_nav,
|
"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
@@ -2,13 +2,18 @@
|
|||||||
into deployed firmware -- so multi-frame support changes only how the
|
into deployed firmware -- so multi-frame support changes only how the
|
||||||
calling frame is resolved (see auth.require_device), never the paths or
|
calling frame is resolved (see auth.require_device), never the paths or
|
||||||
response key names the deployed flat parser depends on
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -17,10 +22,9 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..face_labels import compute_face_labels
|
|
||||||
from ..firmware import firmware_path
|
from ..firmware import firmware_path
|
||||||
from ..image_pipeline import render_placeholder
|
from ..image_pipeline import render_placeholder
|
||||||
from ..models import BatteryLog, Frame
|
from ..models import BatteryLog, Frame
|
||||||
@@ -29,6 +33,8 @@ from .common import (
|
|||||||
BATTERY_LOG_MAX,
|
BATTERY_LOG_MAX,
|
||||||
RECHARGE_JUMP_PCT,
|
RECHARGE_JUMP_PCT,
|
||||||
RECHARGE_LOOKBACK,
|
RECHARGE_LOOKBACK,
|
||||||
|
build_manage_content,
|
||||||
|
get_or_refresh_calendar_events,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
list_assets,
|
list_assets,
|
||||||
@@ -41,7 +47,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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
|
"""What an unclaimed or not-yet-configured frame displays instead of a
|
||||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
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
|
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,
|
qr_url=claim_url,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
|
manage=manage,
|
||||||
)
|
)
|
||||||
if frame.owner_user_id is None:
|
if frame.owner_user_id is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", f"Open {base} to finish setting up this frame."],
|
["Almost there!", f"Open {base} to finish setting up this frame."],
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb,
|
palette_rgb=frame.palette_rgb,
|
||||||
|
manage=manage,
|
||||||
)
|
)
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", "Pick an album for this frame:", base],
|
["Almost there!", "Pick an album for this frame:", base],
|
||||||
qr_url=base,
|
qr_url=base,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
palette_rgb=frame.palette_rgb,
|
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)
|
return bool(url and key and frame.album_id)
|
||||||
|
|
||||||
|
|
||||||
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
|
# --- photos mode ---
|
||||||
# /frame/image looks up the frame's mode here. Only photos exists today.
|
|
||||||
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||||
|
is_normal_wake: bool) -> bytes:
|
||||||
if not _frame_configured(frame):
|
if not _frame_configured(frame):
|
||||||
return _setup_placeholder(frame, request)
|
return _setup_placeholder(frame, request, manage=manage)
|
||||||
client = immich_client_for(frame)
|
client = immich_client_for(frame)
|
||||||
assets = list_assets(client, 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))
|
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||||
asset_id = locked.current_asset_id
|
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 = {
|
RENDERERS = {
|
||||||
"photos": _render_photos_mode,
|
"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
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _manage_flag(request: Request) -> bool:
|
||||||
|
return request.query_params.get("manage") == "1"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frame/image")
|
@router.get("/frame/image")
|
||||||
def frame_image(
|
def frame_image(
|
||||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
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
|
safe to call as often as the device wants, including after an
|
||||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
||||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
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)
|
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")
|
@router.post("/frame/advance")
|
||||||
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||||
"""Forces an immediate advance to the next photo, ignoring
|
"""Forces an immediate move forward -- the next photo in photos mode,
|
||||||
refresh_interval_s, and resets the interval clock from now. Used by
|
or the next day/week/month in calendar mode -- ignoring
|
||||||
the device's next-photo button."""
|
refresh_interval_s. Used by the device's next-photo button."""
|
||||||
require_configured(frame)
|
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
|
||||||
client = immich_client_for(frame)
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
assets = list_assets(client, frame)
|
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/frame/back")
|
@router.post("/frame/back")
|
||||||
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||||
"""Returns to the previously-current photo (the mirror image of
|
"""The mirror of /frame/advance -- back a photo in photos mode, back
|
||||||
/frame/advance -- see photo_queue.back_forced()), and resets the
|
a period in calendar mode. A no-op (still 200, unchanged) if there's
|
||||||
interval clock from now. A no-op (still 200, current photo
|
nothing to go back to. Used by the device's back-photo button."""
|
||||||
unchanged) if there's no history to go back to -- same "always
|
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
|
||||||
returns something displayable" contract as /frame/advance, rather
|
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||||
than erroring. Used by the device's back-photo button."""
|
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
class BatteryReport(BaseModel):
|
class BatteryReport(BaseModel):
|
||||||
@@ -272,115 +375,6 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
|||||||
return FileResponse(path, media_type="application/octet-stream")
|
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}")
|
@router.get("/frame/share/{asset_id}")
|
||||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||||
"""Creates a 30-minute public Immich share link for asset_id and
|
"""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
|
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||||
|
|
||||||
return RedirectResponse(share_url)
|
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
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..auth import can_view_frame, current_user
|
from ..auth import can_view_frame, current_user
|
||||||
|
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
DEFAULT_PALETTE_RGB,
|
DEFAULT_PALETTE_RGB,
|
||||||
@@ -18,7 +20,7 @@ from ..image_pipeline import (
|
|||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
palette_to_hex,
|
palette_to_hex,
|
||||||
)
|
)
|
||||||
from ..models import Frame
|
from ..models import Frame, User, UserFrame
|
||||||
from ..quiet_hours import ALL_TIMEZONES
|
from ..quiet_hours import ALL_TIMEZONES
|
||||||
from .common import shell_context
|
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")
|
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)
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
return _frame_page(
|
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,
|
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||||
palette_to_hex=palette_to_hex,
|
palette_to_hex=palette_to_hex,
|
||||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
display_mode_labels=DISPLAY_MODE_LABELS,
|
||||||
|
calendar_views=CALENDAR_VIEW_LABELS,
|
||||||
|
calendar_users=_calendar_users_for_frame(db, frame_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""HTML page routes: first-run setup, login/logout, user settings, and
|
"""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
|
the admin panel. The per-frame pages (Photos/Configuration/Stats) live in
|
||||||
single-frame index) until the Phase D restructure.
|
routers/frame_pages.py.
|
||||||
|
|
||||||
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
|
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
|
(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 ..db import get_db
|
||||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||||
|
from .common import valid_http_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -422,6 +423,7 @@ def settings_submit(
|
|||||||
email: str = Form(""),
|
email: str = Form(""),
|
||||||
immich_url: str = Form(""),
|
immich_url: str = Form(""),
|
||||||
immich_api_key: str = Form(""),
|
immich_api_key: str = Form(""),
|
||||||
|
calendar_ics_url: str = Form(""),
|
||||||
current_password: str = Form(""),
|
current_password: str = Form(""),
|
||||||
new_password: str = Form(""),
|
new_password: str = Form(""),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -441,6 +443,15 @@ def settings_submit(
|
|||||||
if immich_api_key.strip():
|
if immich_api_key.strip():
|
||||||
user.immich_api_key = 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 new_password:
|
||||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||||
error = "Current password is wrong -- password not changed."
|
error = "Current password is wrong -- password not changed."
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
|
mode: document.getElementById('frame_mode').value,
|
||||||
name: document.getElementById('frame_name').value || '',
|
name: document.getElementById('frame_name').value || '',
|
||||||
order: document.getElementById('order').value,
|
order: document.getElementById('order').value,
|
||||||
orientation: document.getElementById('orientation').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() {
|
async function takeControl() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card-title">Display settings</h2>
|
<h2 class="card-title">Display settings</h2>
|
||||||
<form id="config-form">
|
<form id="config-form">
|
||||||
|
<label>Frame mode
|
||||||
|
<select id="frame_mode">
|
||||||
|
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
|
||||||
|
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>Frame name
|
<label>Frame name
|
||||||
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
||||||
</label>
|
</label>
|
||||||
@@ -77,6 +83,59 @@
|
|||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="calendar-card" style="{% if frame.mode != 'calendar' %}display: none;{% endif %}">
|
||||||
|
<h2 class="card-title">Calendar</h2>
|
||||||
|
<form id="calendar-config-form">
|
||||||
|
<label>View
|
||||||
|
<select id="calendar_view">
|
||||||
|
{% for value, label in calendar_views.items() %}
|
||||||
|
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="checkbox-row" id="calendar-inlay-row" style="{% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
|
||||||
|
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
|
||||||
|
<label for="calendar_photo_inlay">Show a photo alongside today's agenda</label>
|
||||||
|
</div>
|
||||||
|
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px; {% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
|
||||||
|
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
|
||||||
|
<p class="sub">Each linked person decides whether their own calendar
|
||||||
|
contributes to this frame -- being linked here doesn't include it
|
||||||
|
automatically.</p>
|
||||||
|
<ul class="calendar-user-list">
|
||||||
|
{% for u in calendar_users %}
|
||||||
|
<li>
|
||||||
|
{% if u.user_id == user.id %}
|
||||||
|
{% if u.has_url %}
|
||||||
|
<label class="checkbox-row" style="margin-top: 6px;">
|
||||||
|
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
|
||||||
|
{{ u.display_name }} (you)
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
|
||||||
|
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if frame.calendar_fetch_summary %}
|
||||||
|
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2 class="card-title" style="margin-top: 20px;">Preview</h2>
|
||||||
|
<p class="sub">How this frame's calendar currently renders.</p>
|
||||||
|
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
|
||||||
|
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="side-col">
|
<div class="side-col">
|
||||||
|
|||||||
@@ -32,6 +32,19 @@
|
|||||||
this Immich library. The key needs read access to albums/assets/faces
|
this Immich library. The key needs read access to albums/assets/faces
|
||||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||||
|
|
||||||
|
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
|
||||||
|
<label>Calendar URL (iCal/CalDAV .ics feed)
|
||||||
|
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
|
||||||
|
value="{{ user.calendar_ics_url }}">
|
||||||
|
</label>
|
||||||
|
<p class="sub" style="margin-top: 8px;">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.</p>
|
||||||
|
|
||||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||||
<label>Current password
|
<label>Current password
|
||||||
<input type="password" name="current_password" autocomplete="current-password">
|
<input type="password" name="current_password" autocomplete="current-password">
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ python-multipart==0.0.20
|
|||||||
jinja2==3.1.5
|
jinja2==3.1.5
|
||||||
sqlalchemy==2.0.51
|
sqlalchemy==2.0.51
|
||||||
qrcode==8.2
|
qrcode==8.2
|
||||||
|
icalendar==7.2.2
|
||||||
|
recurring-ical-events==3.8.2
|
||||||
|
|||||||
Reference in New Issue
Block a user