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
+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(...),