Add weather to calendar mode; fix CalDAV events never showing
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:
2026-07-22 22:22:11 -04:00
parent acdb929a99
commit ffce798754
10 changed files with 580 additions and 34 deletions
+23 -12
View File
@@ -24,12 +24,15 @@ calendar and an ICS subscription behave identically once fetched.
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, time as dtime import logging
from datetime import date, datetime
import caldav import caldav
import icalendar import icalendar
import recurring_ical_events import recurring_ical_events
logger = logging.getLogger(__name__)
HTTP_TIMEOUT_S = 15 HTTP_TIMEOUT_S = 15
@@ -68,19 +71,26 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str,
window_start: date, window_end: date) -> list[dict]: window_start: date, window_end: date) -> list[dict]:
"""One CalDAV calendar's events in [window_start, window_end] -- same """One CalDAV calendar's events in [window_start, window_end] -- same
event dict shape as calendar_feed.fetch_source_events (no event dict shape as calendar_feed.fetch_source_events (no
"owner_display_name"; the caller adds that). Fetches raw (unexpanded) "owner_display_name"; the caller adds that).
calendar objects and runs them through the same icalendar +
recurring_ical_events pipeline calendar_feed.py uses for ICS feeds, Deliberately does NOT use the calendar-query REPORT's server-side
rather than relying on server-side expand (RFC 4791 leaves plenty of time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
corner cases server implementations disagree on).""" that corner case underspecified and real servers disagree on it
(the caldav package's own docs warn "servers often behave
differently when presented with a search request"; confirmed here
too, once against a real server, as a calendar whose events just
silently never came back despite discovery/auth both working
fine). Instead this fetches every event in the calendar unfiltered
(get_events() is a plain "list VEVENTs" REPORT with no time-range
element -- the much more universally-supported case) and does 100%
of the date-window filtering/recurrence-expansion client-side via
icalendar + recurring_ical_events, exactly like calendar_feed.py
already does for plain ICS feeds. Heavier per-fetch (the whole
calendar, not just the window) but far more reliable."""
try: try:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S) client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url) calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.date_search( objects = calendar.get_events()
start=datetime.combine(window_start, dtime.min),
end=datetime.combine(window_end, dtime.min),
expand=False,
)
except Exception as e: except Exception as e:
raise CalDavError(str(e)) from e raise CalDavError(str(e)) from e
@@ -89,7 +99,8 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str,
try: try:
ical = icalendar.Calendar.from_ical(obj.data) ical = icalendar.Calendar.from_ical(obj.data)
occurrences = recurring_ical_events.of(ical).between(window_start, window_end) occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
except Exception: # one malformed resource shouldn't blank the whole calendar except Exception as e: # one malformed resource shouldn't blank the whole calendar
logger.warning("Could not parse a CalDAV event from %s: %s", calendar_url, e)
continue continue
for occ in occurrences: for occ in occurrences:
dtstart = occ.get("DTSTART") dtstart = occ.get("DTSTART")
+174 -20
View File
@@ -25,6 +25,7 @@ from .image_pipeline import (
draw_text, draw_text,
logical_render_size, logical_render_size,
) )
from .weather import weather_category
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"] CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)", 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)) 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, 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, region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
body_font: ImageFont.ImageFont, owners_seen: list[str]) -> None: body_font: ImageFont.ImageFont, owners_seen: list[str],
"""Draws one day's header + event rows within `region` (x0, y0, w, h) weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
-- factored out of _build_agenda so the today-and-tomorrow view weather_units: str = "fahrenheit") -> None:
(_build_today_tomorrow) can stack two of these vertically without """Draws one day's header + weather strip (if any) + event rows
duplicating the row-layout/truncation logic.""" 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 x0, y0, w, h = region
text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
text_w = w - MARGIN * 2 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) draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12 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) day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14 row_h = body_font.size + 14
max_rows = max(0, (y0 + h - MARGIN - y) // row_h) 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, 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) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG) img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None: 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. # keeps it actually informative.
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24) title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
body_font = ImageFont.load_default(size=22) 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) day = datetime.now(tz).date() + timedelta(days=browse_offset)
owners_seen: list[str] = [] 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 return img
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, 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 """Two _draw_agenda_day sections stacked vertically within the content
region (below each other rather than side-by-side -- narrower than 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 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) 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) 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) start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
section_h = ch // 2 section_h = ch // 2
@@ -225,13 +358,15 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s
if i > 0: if i > 0:
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE) 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, _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 return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo, 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) logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG) img = Image.new("RGB", (logical_w, logical_h), BG)
if photo_inlay is not None: 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) 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) 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() today = datetime.now(tz).date()
days_since_start = (today.weekday() - week_start) % 7 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) draw_text(img, (x0 + 6, cy0 + MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)
y = cy0 + MARGIN + header_h 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 row_h = chip_font.size + 10
max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h) max_rows = max(0, (cy0 + ch - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) 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, 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") tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
if view == "agenda": 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": 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": 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": 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) img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start)
else: 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: if fetch_summary:
font = ImageFont.load_default(size=14) 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, 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, 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 """Renders one of CALENDAR_VIEWS to the panel's packed format. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
other renderer honors.""" other renderer honors. weather_cities is routers/common.py's
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start) 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) 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)
@@ -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, 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, 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 """Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame.""" 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) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO() buf = io.BytesIO()
+16
View File
@@ -145,6 +145,21 @@ def _migration_9(conn) -> None:
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included")) conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
def _migration_10(conn) -> None:
"""Optional weather strip for calendar mode (agenda/today & tomorrow/
week views -- never month, see calendar_render.py's _BUILDERS).
Multiple cities per frame (calendar_weather_cities), each geocoded
once via weather.py's Open-Meteo lookup (no API key) and their daily
forecasts refreshed on their own throttle, same shape idiom as
calendar_checked_at/calendar_cached_events. Off by default -- no
existing frame's render changes until its Calendar tab turns it on."""
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_enabled INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cities TEXT"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
MIGRATIONS = [ MIGRATIONS = [
(1, _migration_1), (1, _migration_1),
(2, _migration_2), (2, _migration_2),
@@ -155,6 +170,7 @@ MIGRATIONS = [
(7, _migration_7), (7, _migration_7),
(8, _migration_8), (8, _migration_8),
(9, _migration_9), (9, _migration_9),
(10, _migration_10),
] ]
+15
View File
@@ -187,6 +187,21 @@ class Frame(Base):
# outage to everyone who looks at it. # outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="") calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# Optional weather strip, agenda/today & tomorrow/week views only --
# never month, there's no room (see calendar_render.py's _BUILDERS).
# Off by default.
calendar_weather_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
calendar_weather_units: Mapped[str] = mapped_column(String, default="fahrenheit") # "fahrenheit" | "celsius"
# [{"label", "latitude", "longitude"}, ...] -- each geocoded once via
# weather.geocode_city() when added from the Calendar tab.
calendar_weather_cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# Throttled per-city forecast cache (see routers/common.py's
# get_or_refresh_weather) -- same shape idiom as
# calendar_checked_at/calendar_cached_events above.
# [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}, ...]
calendar_weather_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_weather_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# -- state -- # -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="") current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0) current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
+60 -1
View File
@@ -25,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 calendar_render, gitea_releases, photo_queue, quiet_hours from .. import calendar_render, gitea_releases, photo_queue, quiet_hours, weather
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 (
@@ -44,6 +44,7 @@ from .common import (
calendar_sources_for_frame, calendar_sources_for_frame,
fetch_source_and_faces, fetch_source_and_faces,
get_or_refresh_calendar_events, get_or_refresh_calendar_events,
get_or_refresh_weather,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
list_assets, list_assets,
@@ -100,6 +101,8 @@ def api_config_save(
calendar_view: str | None = Form(None), calendar_view: str | None = Form(None),
calendar_photo_inlay: bool | None = Form(None), calendar_photo_inlay: bool | None = Form(None),
calendar_week_start: int | None = Form(None), calendar_week_start: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | 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),
): ):
@@ -177,6 +180,14 @@ def api_config_save(
cfg.calendar_photo_inlay = calendar_photo_inlay cfg.calendar_photo_inlay = calendar_photo_inlay
if calendar_week_start is not None: if calendar_week_start is not None:
cfg.calendar_week_start = max(0, min(6, calendar_week_start)) cfg.calendar_week_start = max(0, min(6, calendar_week_start))
if calendar_weather_enabled is not None:
cfg.calendar_weather_enabled = calendar_weather_enabled
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
if calendar_weather_units != cfg.calendar_weather_units:
# Cached forecasts are in the old unit -- force a refetch
# rather than showing stale numbers under a new unit label.
cfg.calendar_weather_checked_at = 0.0
cfg.calendar_weather_units = calendar_weather_units
cfg.stats_config_saves += 1 cfg.stats_config_saves += 1
return {"status": "saved"} return {"status": "saved"}
@@ -497,14 +508,62 @@ def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session
events, summary = get_or_refresh_calendar_events(db, frame) events, summary = get_or_refresh_calendar_events(db, frame)
photo_inlay = _calendar_photo_inlay(frame, db) photo_inlay = _calendar_photo_inlay(frame, db)
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda" view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
weather_cities = get_or_refresh_weather(db, frame)
png = calendar_render.render_calendar_preview_png( png = calendar_render.render_calendar_preview_png(
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation, 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, palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
week_start=frame.calendar_week_start, week_start=frame.calendar_week_start,
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
) )
return Response(content=png, media_type="image/png") return Response(content=png, media_type="image/png")
class WeatherCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/weather-cities/add")
def api_weather_city_add(
body: WeatherCityAddRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Geocodes a free-text city name (e.g. "Portland, OR") and adds it
to this frame's weather strip -- a frame-wide display setting (like
calendar_view), not personal data, so this is gated the same way as
api_config_save rather than the calendar-select owner/mute split."""
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with frame_locked(db, frame.id) as cfg:
cities = list(cfg.calendar_weather_cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this frame's list")
cities.append(city)
cfg.calendar_weather_cities = cities
cfg.calendar_weather_checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/weather-cities/remove")
def api_weather_city_remove(
body: WeatherCityRemoveRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
with frame_locked(db, frame.id) as cfg:
cities = [c for c in (cfg.calendar_weather_cities or []) if c["label"] != body.label]
cfg.calendar_weather_cities = cities
cached = [c for c in (cfg.calendar_weather_cached or []) if c["label"] != body.label]
cfg.calendar_weather_cached = cached
return {"status": "saved"}
@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(...),
+34 -1
View File
@@ -15,7 +15,7 @@ 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 .. import calendar_feed, quiet_hours, weather
from ..db import frame_locked 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
@@ -422,3 +422,36 @@ def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict
locked.calendar_fetch_summary = summary locked.calendar_fetch_summary = summary
locked.calendar_checked_at = now locked.calendar_checked_at = now
return events, summary return events, summary
def get_or_refresh_weather(db: Session, frame: Frame) -> list[dict]:
"""Frame-level throttled per-city forecast cache (weather.CHECK_INTERVAL_S,
much longer than calendar_feed's -- weather doesn't need to be
that fresh). [] if weather's off or no cities are configured. A city
whose refetch fails keeps its last-known days rather than going
blank for one bad cycle -- calendar_render.py would otherwise show a
real city as having no forecast at all just because one refresh hit
a network hiccup."""
if not frame.calendar_weather_enabled or not frame.calendar_weather_cities:
return []
now = time.time()
if (frame.calendar_weather_cached is not None
and now - frame.calendar_weather_checked_at < weather.CHECK_INTERVAL_S):
return frame.calendar_weather_cached
previous_days = {c["label"]: c.get("days", {}) for c in (frame.calendar_weather_cached or [])}
result = []
for city in frame.calendar_weather_cities:
try:
days = weather.fetch_daily_forecast(
city["latitude"], city["longitude"], frame.calendar_weather_units
)
except weather.WeatherFetchError as e:
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
days = previous_days.get(city["label"], {})
result.append({"label": city["label"], "days": days})
with frame_locked(db, frame.id) as locked:
locked.calendar_weather_cached = result
locked.calendar_weather_checked_at = now
return result
+3
View File
@@ -35,6 +35,7 @@ from .common import (
RECHARGE_LOOKBACK, RECHARGE_LOOKBACK,
build_manage_content, build_manage_content,
get_or_refresh_calendar_events, get_or_refresh_calendar_events,
get_or_refresh_weather,
immich_client_for, immich_client_for,
immich_creds, immich_creds,
list_assets, list_assets,
@@ -148,6 +149,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
inlay_wanted = locked.calendar_photo_inlay inlay_wanted = locked.calendar_photo_inlay
events, summary = get_or_refresh_calendar_events(db, frame) events, summary = get_or_refresh_calendar_events(db, frame)
weather_cities = get_or_refresh_weather(db, frame)
photo_inlay = None photo_inlay = None
if inlay_wanted and _frame_configured(frame): if inlay_wanted and _frame_configured(frame):
@@ -171,6 +173,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
events, view=view, browse_offset=browse_offset, orientation=frame.orientation, events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, palette_rgb=frame.palette_rgb, timezone=frame.timezone,
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start, photo_inlay=photo_inlay, fetch_summary=summary, manage=manage, week_start=week_start,
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
) )
+83
View File
@@ -52,6 +52,89 @@ document.querySelectorAll('.calendar-toggle').forEach((el) => {
}); });
}); });
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_weather_enabled: String(document.getElementById('weather_enabled').checked),
calendar_weather_units: document.getElementById('weather_units').value,
});
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);
}
});
function addWeatherCityRow(label) {
const list = document.getElementById('weather-city-list');
const empty = document.getElementById('weather-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity));
document.getElementById('weather-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
function loadCalendarPreview() { function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`; document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
} }
+37
View File
@@ -81,6 +81,43 @@
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p> <p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
{% endif %} {% endif %}
</section> </section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Weather</h2>
<p class="sub">Shown above the event list on Agenda, Agenda (today
&amp; tomorrow), and Week views -- there's no room for it on Month.</p>
<form id="weather-config-form">
<div class="checkbox-row">
<input type="checkbox" id="weather_enabled" {% if frame.calendar_weather_enabled %}checked{% endif %}>
<label for="weather_enabled">Show weather</label>
</div>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if frame.calendar_weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if frame.calendar_weather_units == "celsius" %}selected{% endif %}>Celsius</option>
</select>
</label>
<button type="submit">Save</button>
</form>
<h3 style="margin-top: 20px; font-size: 14px;">Cities</h3>
<p class="sub">Every city shows on every day -- add more than one if
people split their time between places.</p>
<ul class="calendar-user-list" id="weather-city-list">
{% for c in frame.calendar_weather_cities or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
</li>
{% else %}
<li class="sub" id="weather-city-empty">No cities added yet.</li>
{% endfor %}
</ul>
<div class="checkbox-row" style="margin-top: 10px;">
<input type="text" id="weather-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
<button type="button" class="btn-inline" id="weather-city-add">Add</button>
</div>
</section>
</div> </div>
<div class="side-col"> <div class="side-col">
+135
View File
@@ -0,0 +1,135 @@
"""Weather strip for calendar frame mode (agenda/today & tomorrow/week
views only -- never month, there's no room, see calendar_render.py's
_BUILDERS). A frame can list multiple cities; each is geocoded once via
Open-Meteo's free geocoding API (no API key, no signup, no per-request
quota to manage) when added from the Calendar tab, then its daily
forecast is refreshed on its own throttle -- same shape idiom as
calendar_feed.py's merge-fetch cache.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py.
"""
from __future__ import annotations
import httpx
HTTP_TIMEOUT_S = 15.0
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
CHECK_INTERVAL_S = 3 * 60 * 60 # weather doesn't need calendar_feed's 20-minute cadence
FORECAST_DAYS = 14 # comfortably covers the week view's furthest browse-forward
# Open-Meteo's WMO weather codes (https://open-meteo.com/en/docs), grouped
# into the handful of icon categories calendar_render.py actually draws.
_CODE_CATEGORIES = {
0: "clear",
1: "partly_cloudy", 2: "partly_cloudy",
3: "cloudy",
45: "fog", 48: "fog",
51: "rain", 53: "rain", 55: "rain", 56: "rain", 57: "rain",
61: "rain", 63: "rain", 65: "rain", 66: "rain", 67: "rain",
80: "rain", 81: "rain", 82: "rain",
71: "snow", 73: "snow", 75: "snow", 77: "snow", 85: "snow", 86: "snow",
95: "thunderstorm", 96: "thunderstorm", 99: "thunderstorm",
}
class WeatherFetchError(Exception):
"""Geocoding or forecast fetch failed -- network, no match, or an
unexpected response shape. Raised loudly; callers (the Calendar
tab's add-city endpoint, get_or_refresh_weather) decide what to do."""
def weather_category(code: int) -> str:
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later
that isn't in the table above -- an unrecognized code shouldn't drop
a day's weather entirely, just render with a generic icon."""
return _CODE_CATEGORIES.get(code, "cloudy")
# Open-Meteo's geocoder matches on the bare place name only -- "Portland,
# OR" returns zero results even though "Portland" alone returns three
# (OR/ME/IN, disambiguated by population-ranked order). So a ", <state>"
# or ", <country>" qualifier is split off client-side and used to filter
# among the candidates instead of being sent as part of the search term.
_US_STATE_ABBREVIATIONS = {
"al": "alabama", "ak": "alaska", "az": "arizona", "ar": "arkansas", "ca": "california",
"co": "colorado", "ct": "connecticut", "de": "delaware", "fl": "florida", "ga": "georgia",
"hi": "hawaii", "id": "idaho", "il": "illinois", "in": "indiana", "ia": "iowa",
"ks": "kansas", "ky": "kentucky", "la": "louisiana", "me": "maine", "md": "maryland",
"ma": "massachusetts", "mi": "michigan", "mn": "minnesota", "ms": "mississippi", "mo": "missouri",
"mt": "montana", "ne": "nebraska", "nv": "nevada", "nh": "new hampshire", "nj": "new jersey",
"nm": "new mexico", "ny": "new york", "nc": "north carolina", "nd": "north dakota", "oh": "ohio",
"ok": "oklahoma", "or": "oregon", "pa": "pennsylvania", "ri": "rhode island", "sc": "south carolina",
"sd": "south dakota", "tn": "tennessee", "tx": "texas", "ut": "utah", "vt": "vermont",
"va": "virginia", "wa": "washington", "wv": "west virginia", "wi": "wisconsin", "wy": "wyoming",
"dc": "district of columbia",
}
def _matches_qualifier(result: dict, qualifier: str) -> bool:
q = qualifier.strip().lower()
expanded = _US_STATE_ABBREVIATIONS.get(q, q)
admin1 = (result.get("admin1") or "").lower()
country = (result.get("country") or "").lower()
country_code = (result.get("country_code") or "").lower()
return expanded in admin1 or expanded in country or q == country_code
def geocode_city(name: str) -> dict:
"""Best-match {"label", "latitude", "longitude"} for a free-text city
name, optionally qualified with a state/country (e.g. "Portland, OR")
via Open-Meteo's geocoder. label is the resolved place name (city +
admin1/country when available), not necessarily what the user typed
-- shown back so they can confirm it found the right place before
it's saved."""
query, _, qualifier = name.partition(",")
query, qualifier = query.strip(), qualifier.strip()
try:
resp = httpx.get(GEOCODE_URL, params={"name": query, "count": 10}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
raise WeatherFetchError(str(e)) from e
results = data.get("results") or []
if not results:
raise WeatherFetchError(f"No location found matching {name!r}")
if qualifier:
qualified = [r for r in results if _matches_qualifier(r, qualifier)]
if not qualified:
raise WeatherFetchError(f"No location found matching {name!r}")
results = qualified
r = results[0]
parts = [r["name"]]
if r.get("admin1"):
parts.append(r["admin1"])
if r.get("country"):
parts.append(r["country"])
return {"label": ", ".join(parts), "latitude": r["latitude"], "longitude": r["longitude"]}
def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[str, dict]:
"""{"YYYY-MM-DD": {"code": int, "high": float, "low": float}, ...}
for the next FORECAST_DAYS days, already in `units`
("fahrenheit"/"celsius") -- Open-Meteo converts server-side, so
there's no client-side unit math to get wrong."""
try:
resp = httpx.get(FORECAST_URL, params={
"latitude": latitude, "longitude": longitude,
"daily": "weathercode,temperature_2m_max,temperature_2m_min",
"temperature_unit": units,
"timezone": "auto",
"forecast_days": FORECAST_DAYS,
}, timeout=HTTP_TIMEOUT_S)
resp.raise_for_status()
daily = resp.json()["daily"]
return {
day: {"code": code, "high": high, "low": low}
for day, code, high, low in zip(
daily["time"], daily["weathercode"], daily["temperature_2m_max"], daily["temperature_2m_min"]
)
}
except (httpx.HTTPError, KeyError) as e:
raise WeatherFetchError(str(e)) from e