Build and push server image / build-and-push (push) Successful in 49s
Each linked person can pin one of the panel's four non-black/white colors (Yellow/Red/Blue/Green) to their own calendar instead of relying on calendar_render.py's old auto-cycle-by-owner-name order -- owner-only, like adding a calendar in the first place. Colors resolve against whichever palette a frame actually renders with (including a custom Advanced configuration override), so a pinned "Blue" stays this frame's actual blue. Event color bars/dots are also bigger and rounded now across agenda/week/month views, easier to tell apart at a glance.
560 lines
27 KiB
Python
560 lines
27 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,
|
|
draw_text,
|
|
logical_render_size,
|
|
)
|
|
from .weather import weather_category
|
|
|
|
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
|
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
|
"week": "Week", "month": "Month"}
|
|
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
|
|
MARGIN = 20
|
|
BG = (255, 255, 255)
|
|
FG = (0, 0, 0)
|
|
MUTED = (110, 110, 110)
|
|
# Was a light gray, but that dithers away to near-invisible once quantized
|
|
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
|
|
RULE = (0, 0, 0)
|
|
|
|
# Fallback for any event whose calendar has no manually pinned color
|
|
# (event["color_index"] is None): cycled per distinct owner_display_name
|
|
# so a merged multi-person calendar can still 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 _event_color(event: dict, owners_seen: list[str], palette_rgb: list | None) -> tuple[int, int, int]:
|
|
"""A specific calendar's manually pinned color (event["color_index"],
|
|
set from models.FrameCalendar.color_index -- see
|
|
routers/api_frames.py's api_calendar_color) resolved against
|
|
whichever palette this frame actually renders with, so a pinned
|
|
"Blue" still looks like this frame's blue even if its Advanced
|
|
configuration has retuned the panel's RGB values. Falls back to the
|
|
old auto-cycle-by-owner-name when a calendar has no color pinned."""
|
|
color_index = event.get("color_index")
|
|
if color_index is not None:
|
|
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
|
return tuple(palette[color_index])
|
|
owner_display_name = event["owner_display_name"]
|
|
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. Still uses `draw.textlength`
|
|
for measurement (identical metrics to draw_text's own bbox), just
|
|
doesn't paint anything."""
|
|
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
|
|
|
|
|
|
# --- Photo inlay region, shared by every view --------------------------
|
|
|
|
def inlay_region(orientation: str) -> tuple[int, int, int, int]:
|
|
"""The photo-inlay's (x0, y0, w, h) within the full logical canvas --
|
|
the long-axis half (left in landscape, top in portrait), same
|
|
proportion for every view so switching views doesn't reflow the
|
|
photo. Also used by routers/common.py's build_manage_content to
|
|
correctly reposition manage-overlay face labels when a photo inlay
|
|
is active (they'd otherwise be computed as if the photo filled the
|
|
whole panel)."""
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
if logical_w >= logical_h:
|
|
return 0, 0, logical_w // 2, logical_h
|
|
return 0, 0, logical_w, logical_h // 2
|
|
|
|
|
|
def _content_region(orientation: str, has_inlay: bool) -> tuple[int, int, int, int]:
|
|
"""The remaining (x0, y0, w, h) a view's own content (list/grid)
|
|
draws into -- the whole canvas normally, or whatever inlay_region()
|
|
didn't claim."""
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
if not has_inlay:
|
|
return 0, 0, logical_w, logical_h
|
|
ix0, iy0, iw, ih = inlay_region(orientation)
|
|
if logical_w >= logical_h:
|
|
return iw, 0, logical_w - iw, logical_h
|
|
return 0, ih, logical_w, logical_h - ih
|
|
|
|
|
|
def _paste_inlay(img: Image.Image, photo_inlay: Image.Image, orientation: str) -> None:
|
|
x0, y0, w, h = inlay_region(orientation)
|
|
photo = compose_into(photo_inlay, None, w, h, "crop_fill")
|
|
img.paste(photo, (x0, y0))
|
|
|
|
|
|
# --- Weather strip, agenda/today & tomorrow/week views only (never
|
|
# month -- see _BUILDERS/_build) --------------------------------------
|
|
|
|
def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]:
|
|
"""[{"label", "code", "high", "low", "category"}, ...] for every
|
|
configured city that has a cached forecast for this specific date --
|
|
weather_cities is routers/common.py's get_or_refresh_weather() cache
|
|
shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}]."""
|
|
if not weather_cities:
|
|
return []
|
|
key = day.isoformat()
|
|
entries = []
|
|
for city in weather_cities:
|
|
d = (city.get("days") or {}).get(key)
|
|
if d is None:
|
|
continue
|
|
# Just the city name on-panel ("Portland", not the full
|
|
# disambiguated "Portland, Oregon, United States") -- that fuller
|
|
# form matters for telling apart geocoder candidates when adding
|
|
# a city (see weather.geocode_city), not for a compact display row.
|
|
entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"],
|
|
"category": weather_category(d["code"])})
|
|
return entries
|
|
|
|
|
|
def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
|
|
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
|
with a clean outline -- drawn as one black pass slightly larger than
|
|
the shapes, then the same shapes again in white on top. Overlapping
|
|
ellipses each drawn with their own `outline=` would leave visible
|
|
seams where they cross; this double-draw trick sidesteps that
|
|
entirely regardless of how the lobes overlap."""
|
|
stroke = 2
|
|
lobes = [
|
|
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
|
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
|
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
|
]
|
|
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
|
for x0, y0, x1, y1 in lobes:
|
|
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=FG)
|
|
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
|
|
for x0, y0, x1, y1 in lobes:
|
|
draw.ellipse([x0, y0, x1, y1], fill=BG)
|
|
draw.rectangle(base, fill=BG)
|
|
|
|
|
|
def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
|
|
"""A small hand-drawn glyph for one weather category -- no custom
|
|
font/icon asset, same hand-primitives-only approach the rest of this
|
|
module uses (colored rectangles for owner indicators, density dots
|
|
for month view)."""
|
|
if category == "clear":
|
|
# Kept within a ~1.1r visual radius overall (rays included) to
|
|
# match _draw_cloud's own footprint -- _draw_weather_row lays
|
|
# icons out assuming each one stays roughly within icon_r of its
|
|
# center, and the first entry in a row sits flush against the
|
|
# region's own left margin, so any icon that draws wider than
|
|
# that pokes out past it with nothing to visually connect to.
|
|
draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
|
|
for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
|
|
draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
|
|
fill=FG, width=3)
|
|
return
|
|
|
|
cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
|
|
if category == "partly_cloudy":
|
|
draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
|
|
_draw_cloud(draw, cx, cloud_cy, r)
|
|
|
|
if category == "fog":
|
|
for i in range(3):
|
|
y = cy + r * 0.5 + i * (r * 0.45)
|
|
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
|
elif category == "rain":
|
|
for dx in (-0.6, 0, 0.6):
|
|
x = cx + dx * r
|
|
draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
|
|
elif category == "snow":
|
|
for dx in (-0.6, 0, 0.6):
|
|
x, y = cx + dx * r, cloud_cy + r * 0.9
|
|
draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
|
|
elif category == "thunderstorm":
|
|
x, y = cx, cloud_cy + r * 0.5
|
|
draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
|
|
fill=FG, width=2)
|
|
|
|
|
|
def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
|
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
|
show_labels: bool = True) -> int:
|
|
"""Draws one or more cities' weather side by side starting at
|
|
(x0, y0), stopping once another entry wouldn't fit within max_w
|
|
(narrow views like week columns just end up showing fewer cities --
|
|
same graceful-degradation approach month view takes with density
|
|
dots). Returns the row height consumed (0 if there was nothing to
|
|
draw, so callers can skip reserving space entirely)."""
|
|
if not entries:
|
|
return 0
|
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
|
row_h = icon_r * 2 + 8
|
|
x = x0
|
|
drew_any = False
|
|
for entry in entries:
|
|
temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
|
|
label = f"{entry['label']} {temps}" if show_labels else temps
|
|
entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
|
|
if drew_any and x + entry_w > x0 + max_w:
|
|
break
|
|
cx, cy = x + icon_r, y0 + icon_r
|
|
_draw_weather_icon(draw, cx, cy, icon_r, entry["category"])
|
|
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
|
x += entry_w
|
|
drew_any = True
|
|
return row_h + 6
|
|
|
|
|
|
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
|
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
|
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
|
|
weather_units: str = "fahrenheit") -> None:
|
|
"""Draws one day's header + weather strip (if any) + event rows
|
|
within `region` (x0, y0, w, h) -- factored out of _build_agenda so
|
|
the today-and-tomorrow view (_build_today_tomorrow) can stack two of
|
|
these vertically without duplicating the row-layout/truncation
|
|
logic. Weather is drawn above the event list -- eating into the same
|
|
row budget the event count is truncated against, exactly like the
|
|
header/rule above it already does."""
|
|
x0, y0, w, h = region
|
|
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
|
|
text_w = w - MARGIN * 2
|
|
header = day.strftime("%A, %B ") + str(day.day)
|
|
draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
|
|
y = text_y0 + title_font.size + 12
|
|
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
|
y += 12
|
|
|
|
weather_entries = _weather_for_day(weather_cities, day)
|
|
if weather_entries:
|
|
y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
|
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
|
|
|
|
day_events = _events_on_day(events, day, tz)
|
|
row_h = body_font.size + 14
|
|
max_rows = max(0, (y0 + h - MARGIN - y) // row_h)
|
|
|
|
if not day_events:
|
|
draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
|
|
for i, event in enumerate(day_events):
|
|
if i >= max_rows:
|
|
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
|
|
break
|
|
color = _event_color(event, owners_seen, palette_rgb)
|
|
draw.rounded_rectangle([text_x0, y + 2, text_x0 + 10, y + row_h - 7], radius=3, 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(img, (text_x0 + 18, y), _truncate_to_width(draw, line, body_font, text_w - 18), body_font)
|
|
y += row_h
|
|
|
|
|
|
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
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)
|
|
weather_font = ImageFont.load_default(size=20 if photo_inlay is None else 16)
|
|
|
|
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
|
owners_seen: list[str] = []
|
|
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen,
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
return img
|
|
|
|
|
|
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
"""Two _draw_agenda_day sections stacked vertically within the content
|
|
region (below each other rather than side-by-side -- narrower than
|
|
tall doesn't leave enough width per day for the event-row text once
|
|
an inlay's already claimed half the canvas). browse_offset shifts
|
|
the whole two-day window together, same "days" unit _build_agenda
|
|
already uses, so NEXT/BACK behaves identically across both views."""
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
title_font = ImageFont.load_default(size=26 if photo_inlay is None else 20)
|
|
body_font = ImageFont.load_default(size=18 if photo_inlay is None else 15)
|
|
weather_font = ImageFont.load_default(size=16 if photo_inlay is None else 13)
|
|
|
|
start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
|
section_h = ch // 2
|
|
owners_seen: list[str] = []
|
|
for i in range(2):
|
|
section_y0 = cy0 + i * section_h
|
|
if i > 0:
|
|
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
|
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
|
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
|
palette_rgb, weather_cities, weather_font, weather_units)
|
|
|
|
return img
|
|
|
|
|
|
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None,
|
|
weather_units: str = "fahrenheit") -> Image.Image:
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), BG)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
header_font = ImageFont.load_default(size=18 if photo_inlay is None else 14)
|
|
chip_font = ImageFont.load_default(size=14 if photo_inlay is None else 12)
|
|
weather_font = ImageFont.load_default(size=12 if photo_inlay is None else 10)
|
|
|
|
today = datetime.now(tz).date()
|
|
days_since_start = (today.weekday() - week_start) % 7
|
|
week_first_day = today - timedelta(days=days_since_start) + timedelta(weeks=browse_offset)
|
|
col_w = (cw - MARGIN * 2) // 7
|
|
header_h = 44
|
|
owners_seen: list[str] = []
|
|
|
|
for col in range(7):
|
|
day = week_first_day + timedelta(days=col)
|
|
x0 = cx0 + MARGIN + col * col_w
|
|
if col > 0:
|
|
draw.line([(x0, cy0 + MARGIN), (x0, cy0 + ch - MARGIN)], fill=RULE)
|
|
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
|
|
draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
|
|
|
|
y = cy0 + MARGIN + header_h
|
|
# Columns are narrow, so only what actually fits gets drawn (see
|
|
# _draw_weather_row) -- typically one city, no label (the column
|
|
# itself makes which day it's for obvious; a city name wouldn't fit
|
|
# anyway). Never more than that -- this is already the tight view.
|
|
weather_entries = _weather_for_day(weather_cities, day)
|
|
if weather_entries:
|
|
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
|
|
row_h = chip_font.size + 10
|
|
max_rows = max(0, (cy0 + ch - 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(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
|
|
break
|
|
color = _event_color(event, owners_seen, palette_rgb)
|
|
draw.rounded_rectangle([x0 + 4, y + 1, x0 + 11, y + row_h - 5], radius=2, fill=color)
|
|
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
|
|
draw_text(img, (x0 + 16, y), _truncate_to_width(draw, text, chip_font, col_w - 20), chip_font)
|
|
y += row_h
|
|
|
|
return img
|
|
|
|
|
|
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
|
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> 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)
|
|
if photo_inlay is not None:
|
|
_paste_inlay(img, photo_inlay, orientation)
|
|
cx0, cy0, cw, ch = _content_region(orientation, photo_inlay is not None)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
header_font = ImageFont.load_default(size=16 if photo_inlay is None else 12)
|
|
day_font = ImageFont.load_default(size=18 if photo_inlay is None else 13)
|
|
|
|
today = datetime.now(tz).date()
|
|
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
|
|
weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))
|
|
|
|
col_w = (cw - MARGIN * 2) // 7
|
|
header_h = 28
|
|
grid_top = cy0 + MARGIN + header_h
|
|
row_h = (cy0 + ch - MARGIN - grid_top) // len(weeks)
|
|
|
|
day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
|
|
for col, name in enumerate(day_names):
|
|
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font, MUTED)
|
|
|
|
owners_seen: list[str] = []
|
|
dot_r = 6
|
|
for row, week in enumerate(weeks):
|
|
for col, day in enumerate(week):
|
|
x0 = cx0 + 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
|
|
text_color = FG if in_month else MUTED
|
|
if day == today:
|
|
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
|
|
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
|
|
|
|
day_events = _events_on_day(events, day, tz)
|
|
dot_x = x0 + 8
|
|
dot_y = y0 + row_h - dot_r * 2 - 6
|
|
for i, event in enumerate(day_events[:4]):
|
|
event_color = _event_color(event, owners_seen, palette_rgb)
|
|
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
|
dot_x += dot_r * 2 + 5
|
|
if len(day_events) > 4:
|
|
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
|
|
|
|
return img
|
|
|
|
|
|
_BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "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, week_start: int,
|
|
palette_rgb: list | None = None,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> Image.Image:
|
|
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
|
if view == "agenda":
|
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
weather_cities, weather_units)
|
|
elif view == "today_tomorrow":
|
|
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
weather_cities, weather_units)
|
|
elif view == "week":
|
|
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
|
|
weather_cities, weather_units)
|
|
elif view == "month":
|
|
# Never given weather -- no room for it at typical month-cell size,
|
|
# same reasoning that already keeps this view to density dots
|
|
# instead of literal event text (see _build_month's own docstring).
|
|
# Colors are still passed through, though -- that's a different
|
|
# concern (legibility of individual events) than weather's
|
|
# (space for a whole extra strip of content).
|
|
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
|
|
else:
|
|
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
|
weather_cities, weather_units)
|
|
|
|
if fetch_summary:
|
|
font = ImageFont.load_default(size=14)
|
|
logical_w, logical_h = img.size
|
|
draw_text(img, (MARGIN, logical_h - MARGIN - font.size), fetch_summary, font, MUTED)
|
|
|
|
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, week_start: int = 0,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> 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. weather_cities is routers/common.py's
|
|
get_or_refresh_weather() cache, or None/[] to omit the weather strip
|
|
entirely (also always omitted for view == "month")."""
|
|
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
|
palette_rgb, weather_cities, weather_units)
|
|
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, week_start: int = 0,
|
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> 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, week_start,
|
|
palette_rgb, weather_cities, weather_units)
|
|
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()
|