Add weather to calendar mode; fix CalDAV events never showing
Build and push server image / build-and-push (push) Successful in 48s
Build and push server image / build-and-push (push) Successful in 48s
Weather: multiple cities per frame, geocoded via Open-Meteo (no API key), shown above the event list on agenda/today & tomorrow/week views -- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/ thunderstorm icons (no new font/icon asset, same primitives-only approach the rest of calendar_render.py already uses). City geocoding handles "City, State" qualifiers Open-Meteo's own search doesn't (disambiguates same-named cities, e.g. the three "Portland"s). CalDAV fix: events were never showing despite calendars discovering fine, because fetch_calendar_events relied on the calendar-query REPORT's server-side time-range filter, which real servers implement inconsistently (confirmed against a real server, not just guessed -- reproduced locally with Radicale). Switched to fetching every event unfiltered and doing all date-window filtering/expansion client-side, same approach already used for plain ICS feeds.
This commit is contained in:
+174
-20
@@ -25,6 +25,7 @@ from .image_pipeline import (
|
||||
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)",
|
||||
@@ -142,13 +143,135 @@ def _paste_inlay(img: Image.Image, photo_inlay: Image.Image, orientation: str) -
|
||||
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]) -> None:
|
||||
"""Draws one day's header + 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."""
|
||||
body_font: ImageFont.ImageFont, owners_seen: list[str],
|
||||
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
|
||||
@@ -158,6 +281,11 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
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)
|
||||
@@ -177,7 +305,8 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
|
||||
|
||||
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None) -> Image.Image:
|
||||
photo_inlay: Image.Image | 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:
|
||||
@@ -191,16 +320,19 @@ def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz:
|
||||
# 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)
|
||||
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen,
|
||||
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) -> Image.Image:
|
||||
photo_inlay: Image.Image | 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
|
||||
@@ -216,6 +348,7 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s
|
||||
|
||||
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
|
||||
@@ -225,13 +358,15 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s
|
||||
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)
|
||||
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
||||
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) -> Image.Image:
|
||||
photo_inlay: Image.Image | None, week_start: int, 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:
|
||||
@@ -241,6 +376,7 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
||||
|
||||
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
|
||||
@@ -258,6 +394,14 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
||||
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)
|
||||
@@ -333,18 +477,22 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
|
||||
|
||||
|
||||
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) -> Image.Image:
|
||||
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int,
|
||||
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)
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
|
||||
elif view == "today_tomorrow":
|
||||
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay)
|
||||
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
|
||||
elif view == "week":
|
||||
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start)
|
||||
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, 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).
|
||||
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start)
|
||||
else:
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
|
||||
|
||||
if fetch_summary:
|
||||
font = ImageFont.load_default(size=14)
|
||||
@@ -356,11 +504,15 @@ def _build(events: list[dict], view: str, browse_offset: int, orientation: str,
|
||||
|
||||
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) -> bytes:
|
||||
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."""
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start)
|
||||
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,
|
||||
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)
|
||||
@@ -368,11 +520,13 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
|
||||
|
||||
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) -> bytes:
|
||||
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)
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
||||
weather_cities, weather_units)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
|
||||
Reference in New Issue
Block a user