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 datetime import date, datetime, time as dtime
import logging
from datetime import date, datetime
import caldav
import icalendar
import recurring_ical_events
logger = logging.getLogger(__name__)
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]:
"""One CalDAV calendar's events in [window_start, window_end] -- same
event dict shape as calendar_feed.fetch_source_events (no
"owner_display_name"; the caller adds that). Fetches raw (unexpanded)
calendar objects and runs them through the same icalendar +
recurring_ical_events pipeline calendar_feed.py uses for ICS feeds,
rather than relying on server-side expand (RFC 4791 leaves plenty of
corner cases server implementations disagree on)."""
"owner_display_name"; the caller adds that).
Deliberately does NOT use the calendar-query REPORT's server-side
time-range filter (caldav.Calendar.date_search) -- RFC 4791 leaves
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:
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
calendar = caldav.Calendar(client=client, url=calendar_url)
objects = calendar.date_search(
start=datetime.combine(window_start, dtime.min),
end=datetime.combine(window_end, dtime.min),
expand=False,
)
objects = calendar.get_events()
except Exception as e:
raise CalDavError(str(e)) from e
@@ -89,7 +99,8 @@ def fetch_calendar_events(calendar_url: str, username: str, password: str,
try:
ical = icalendar.Calendar.from_ical(obj.data)
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
for occ in occurrences:
dtstart = occ.get("DTSTART")
+174 -20
View File
@@ -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()
+16
View File
@@ -145,6 +145,21 @@ def _migration_9(conn) -> None:
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 = [
(1, _migration_1),
(2, _migration_2),
@@ -155,6 +170,7 @@ MIGRATIONS = [
(7, _migration_7),
(8, _migration_8),
(9, _migration_9),
(10, _migration_10),
]
+15
View File
@@ -187,6 +187,21 @@ class Frame(Base):
# outage to everyone who looks at it.
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 --
current_asset_id: Mapped[str] = mapped_column(String, default="")
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.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 ..db import frame_locked, get_db
from ..image_pipeline import (
@@ -44,6 +44,7 @@ from .common import (
calendar_sources_for_frame,
fetch_source_and_faces,
get_or_refresh_calendar_events,
get_or_refresh_weather,
immich_client_for,
immich_creds,
list_assets,
@@ -100,6 +101,8 @@ def api_config_save(
calendar_view: str | None = Form(None),
calendar_photo_inlay: bool | 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),
db: Session = Depends(get_db),
):
@@ -177,6 +180,14 @@ def api_config_save(
cfg.calendar_photo_inlay = calendar_photo_inlay
if calendar_week_start is not None:
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
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)
photo_inlay = _calendar_photo_inlay(frame, db)
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(
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,
week_start=frame.calendar_week_start,
weather_cities=weather_cities, weather_units=frame.calendar_weather_units,
)
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")
def api_firmware_upload(
file: UploadFile = File(...),
+34 -1
View File
@@ -15,7 +15,7 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_feed, quiet_hours
from .. import calendar_feed, quiet_hours, weather
from ..db import frame_locked
from ..image_pipeline import render_frame
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_checked_at = now
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,
build_manage_content,
get_or_refresh_calendar_events,
get_or_refresh_weather,
immich_client_for,
immich_creds,
list_assets,
@@ -148,6 +149,7 @@ def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: d
inlay_wanted = locked.calendar_photo_inlay
events, summary = get_or_refresh_calendar_events(db, frame)
weather_cities = get_or_refresh_weather(db, frame)
photo_inlay = None
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,
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
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() {
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>
{% endif %}
</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 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