Add weather to calendar mode; fix CalDAV events never showing
Build and push server image / build-and-push (push) Successful in 48s
Build and push server image / build-and-push (push) Successful in 48s
Weather: multiple cities per frame, geocoded via Open-Meteo (no API key), shown above the event list on agenda/today & tomorrow/week views -- never month, no room for it there. Hand-drawn sun/cloud/rain/snow/ thunderstorm icons (no new font/icon asset, same primitives-only approach the rest of calendar_render.py already uses). City geocoding handles "City, State" qualifiers Open-Meteo's own search doesn't (disambiguates same-named cities, e.g. the three "Portland"s). CalDAV fix: events were never showing despite calendars discovering fine, because fetch_calendar_events relied on the calendar-query REPORT's server-side time-range filter, which real servers implement inconsistently (confirmed against a real server, not just guessed -- reproduced locally with Radicale). Switched to fetching every event unfiltered and doing all date-window filtering/expansion client-side, same approach already used for plain ICS feeds.
This commit is contained in:
+23
-12
@@ -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
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(...),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
& 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">
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user