Files
espresso_frame/server/app/calendar_render.py
T
tfaour c007acde75
Build and push server image / build-and-push (push) Successful in 42s
Add calendar frame mode + server-side manage overlay (server)
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds,
render agenda/week/month views. manage_overlay.py: composites the
manage-button overlay server-side (QR, battery, location/date,
share-QR, face labels), reused by every render mode. device.py/common.py
wire both together: mode dispatch for /frame/image+advance+back, and
the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings
calendar URL field) and the icalendar/recurring-ical-events deps.
2026-07-22 19:06:49 -04:00

293 lines
12 KiB
Python

"""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()