diff --git a/server/app/caldav_client.py b/server/app/caldav_client.py index 1ce7882..46684af 100644 --- a/server/app/caldav_client.py +++ b/server/app/caldav_client.py @@ -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") diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index a504698..4e722a6 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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() diff --git a/server/app/migration.py b/server/app/migration.py index 7aaa33a..589e936 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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), ] diff --git a/server/app/models.py b/server/app/models.py index 9605d07..4d721b1 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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) diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 80afc12..8b09590 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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(...), diff --git a/server/app/routers/common.py b/server/app/routers/common.py index 805b4c6..8e0b0ae 100644 --- a/server/app/routers/common.py +++ b/server/app/routers/common.py @@ -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 diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 144937d..7f42fb5 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -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, ) diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js index c0f1f51..e9ba077 100644 --- a/server/app/static/frame_calendar.js +++ b/server/app/static/frame_calendar.js @@ -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 = '
Last fetch: {{ frame.calendar_fetch_summary }}
{% endif %} + +Shown above the event list on Agenda, Agenda (today + & tomorrow), and Week views -- there's no room for it on Month.
+ + +Every city shows on every day -- add more than one if + people split their time between places.
+