Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
New widget type with four display modes -- current conditions, an hourly forecast strip, a multi-day forecast, and several cities' current day side by side -- backed by a pluggable provider registry (app/weather/, mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and NWS (US-only) both wired up now, Environment Canada documented as the next one to add given its more involved station/grid-lookup API. The calendar widget's existing embedded weather strip is untouched and still Open-Meteo-only; this lifts the same underlying icon-drawing primitives (now shared via app/weather_render.py, calendar_render.py still imports draw_weather_row unchanged) into a widget that can be placed and sized on its own. Icons are redrawn in the panel's actual ink colors (yellow sun/bolt, blue rain/snow) instead of flat black, and build_multi_city's icon/font sizing now scales with how many cities need to fit rather than the box's height alone -- both fixed after catching them via live browser verification, along with a mode-switch cache-shape crash and a mobile-width dialog overflow. New WeatherWidgetConfig table (migration 24), grid footprint, widget module, common.py fetch/cache helper, router endpoints (location set/ clear, city add/remove, preview), dialog template + JS, and full test coverage (providers, widget render, HTTP endpoints, migration replay). docs/widgets.md and CLAUDE.md's TODO updated accordingly.
This commit is contained in:
@@ -4,12 +4,12 @@ A DIY e-ink photo frame: an ESP32-C6 (`firmware/`, ESP-IDF) driving a
|
||||
Waveshare 7.3" E Ink Spectra 6 panel (800x480, 6-color, SPI), paired with a
|
||||
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
|
||||
image processing (crop/dither/quantize/pack), and serves a placeable
|
||||
photos/calendar/whiteboard widget system to the device.
|
||||
photos/calendar/whiteboard/weather widget system to the device.
|
||||
|
||||
CURRENT TODO
|
||||
-add more actions for buttons (i.e. change widget/layout)
|
||||
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
||||
-make a weather widget
|
||||
-add Environment Canada as a weather widget provider (app/weather/ -- station/grid-lookup API, more involved than Open-Meteo/NWS)
|
||||
-widget border option
|
||||
-battery life widget
|
||||
-sharing layouts with linked users
|
||||
|
||||
+72
-11
@@ -1,10 +1,10 @@
|
||||
# Widget system
|
||||
|
||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text), like
|
||||
arranging icons on an Android home screen. A frame can hold several widgets of the
|
||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
||||
by side).
|
||||
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
|
||||
weather), like arranging icons on an Android home screen. A frame can hold
|
||||
several widgets of the same type (e.g. two photo widgets pointed at
|
||||
different Immich albums side by side).
|
||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
||||
@@ -20,14 +20,16 @@ a button press does.
|
||||
## Data model
|
||||
|
||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` | `"text"`), `x`/`y`/`w`/`h`
|
||||
(`"photos"` | `"calendar"` | `"whiteboard"` | `"tasks"` | `"static"` |
|
||||
`"text"` | `"weather"`), `x`/`y`/`w`/`h`
|
||||
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||
already checked) -- that's what keeps compositing simple: no z-order,
|
||||
no blending, just N independent regions pasted onto one shared canvas.
|
||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
||||
`StaticWidgetConfig`, `TextWidgetConfig`, each keyed by `widget_id` with
|
||||
`StaticWidgetConfig`, `TextWidgetConfig`, `WeatherWidgetConfig`, each
|
||||
keyed by `widget_id` with
|
||||
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||
text (paragraphs of styled runs), never raw HTML -- see
|
||||
@@ -41,6 +43,10 @@ a button press does.
|
||||
into its own widget type (migration 17) so a task list can be placed
|
||||
and sized independent of any calendar's view/footprint, then (migration
|
||||
18) given the same multi-source shape a calendar widget already has.
|
||||
`WeatherWidgetConfig` similarly lifts `CalendarWidgetConfig`'s embedded
|
||||
weather strip (still present and unchanged, `weather_*` columns) out
|
||||
into its own placeable widget type (migration 24) -- see "Weather
|
||||
widget" below.
|
||||
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
|
||||
`frame_id`) since a frame can now have more than one independent
|
||||
calendar/tasks widget, each with its own included set. Identical
|
||||
@@ -65,7 +71,9 @@ orientation change rather than trying to remap coordinates.
|
||||
|
||||
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1.
|
||||
size-tier scaling), whiteboard 2x2, tasks 2x2, static image 1x1, text 2x1,
|
||||
weather 2x2 (its hourly/daily strips need the room; current/multi_city
|
||||
modes would tolerate smaller, but every mode shares one footprint value).
|
||||
Enforced both client-side
|
||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
||||
and server-side (`routers/api_widgets.py`) -- the client is never trusted
|
||||
@@ -75,7 +83,7 @@ alone.
|
||||
|
||||
`app/widgets/` is the render/action registry -- one module per
|
||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
||||
`static_image.py`, `text.py`), each exposing:
|
||||
`static_image.py`, `text.py`, `weather.py`), each exposing:
|
||||
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake) -> Image`:
|
||||
an unquantized RGB image exactly `target_w x target_h`, the widget's
|
||||
@@ -85,9 +93,10 @@ alone.
|
||||
bad moment doesn't blank the whole panel.
|
||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
||||
advance/back/force for a passive checklist, a fixed uploaded image, or
|
||||
a fixed block of authored text.
|
||||
for whiteboard and weather -- both throttled external fetches with a
|
||||
forced-refetch action). Empty for tasks, static image, and text --
|
||||
nothing to advance/back/force for a passive checklist, a fixed
|
||||
uploaded image, or a fixed block of authored text.
|
||||
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||
assignment UI.
|
||||
|
||||
@@ -190,6 +199,58 @@ The web UI lives in the Layout tab's "Saved layouts" card
|
||||
saved layouts each with Apply/rename/delete, incompatible ones shown
|
||||
greyed-out with a "different orientation" badge rather than hidden.
|
||||
|
||||
## Weather widget
|
||||
|
||||
A standalone widget type (`models.WeatherWidgetConfig`, `app/widgets/
|
||||
weather.py`) -- distinct from, and unrelated in code to,
|
||||
`CalendarWidgetConfig`'s own embedded weather strip (still present,
|
||||
still Open-Meteo-only, still working exactly as before). Four display
|
||||
modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like
|
||||
`calendar_view`):
|
||||
|
||||
- `current` -- one city's current temp + a condition icon.
|
||||
- `hourly` -- one city, a row of ticks across the day at a configurable
|
||||
interval (`hourly_interval_hours`: 3/4/6/12).
|
||||
- `daily` -- one city, a multi-day strip (`daily_days`, 1-14).
|
||||
- `multi_city` -- several cities' current-day high/low/icon side by
|
||||
side -- the calendar widget's embedded strip, as a standalone
|
||||
widget's whole content instead of a strip above an agenda day.
|
||||
|
||||
`current`/`hourly`/`daily` share one configured location
|
||||
(`city_label`/`city_latitude`/`city_longitude`, set via `POST .../
|
||||
weather-location`, geocoded through `weather.geocode_city`); `multi_city`
|
||||
has its own list (`cities`, add/remove via `POST .../weather-widget-
|
||||
cities/add`|`remove` -- named to avoid colliding with the calendar
|
||||
widget's own, differently-scoped `weather-cities/add`|`remove` routes,
|
||||
which share the same `{widget_id}`-parameterized path shape).
|
||||
|
||||
**Providers** (`app/weather/`, a dispatch registry over pluggable
|
||||
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
|
||||
provider` selects which of `app/weather.PROVIDERS` actually fetches --
|
||||
`"open_meteo"` (worldwide, no API key) or `"nws"` (api.weather.gov, US
|
||||
only, no API key, approximates "current" with the first hourly forecast
|
||||
period rather than a real station observation). Every provider function
|
||||
returns already-normalized `{"category": ...}` entries (one of `clear`/
|
||||
`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/`thunderstorm`) so
|
||||
`app/weather_render.py`'s drawing code never needs to know which
|
||||
provider supplied an entry. `geocode_city` (name -> lat/lon) always goes
|
||||
through Open-Meteo's free geocoder regardless of which provider is
|
||||
chosen to fetch with the result.
|
||||
|
||||
**Environment Canada is a deliberate gap, not an oversight** -- its free
|
||||
API (the MSC GeoMet OGC service) is built around station/grid lookups,
|
||||
not simple lat/lon REST like the two providers above, and would have
|
||||
meaningfully expanded the initial pass. Next provider to add if this
|
||||
gets revisited.
|
||||
|
||||
`app/weather_render.py` holds every weather-related drawing primitive:
|
||||
`draw_cloud`/`draw_weather_icon`/`draw_weather_row` (extracted out of
|
||||
`calendar_render.py`, which still imports `draw_weather_row` for its own
|
||||
embedded strip, unchanged) plus this widget's own `build_current`/
|
||||
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
|
||||
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
|
||||
`render_tasks_preview_png` relationship.
|
||||
|
||||
## Known gaps (Phase 6, not yet done)
|
||||
|
||||
The original 8-phase rollout plan's last phase is still open:
|
||||
|
||||
@@ -35,6 +35,7 @@ from .image_pipeline import (
|
||||
logical_render_size,
|
||||
)
|
||||
from .weather import weather_category
|
||||
from .weather_render import draw_weather_row
|
||||
|
||||
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
||||
@@ -389,98 +390,6 @@ def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]
|
||||
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], palette_rgb: list | None = None,
|
||||
@@ -504,8 +413,9 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
|
||||
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)
|
||||
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,
|
||||
palette_rgb=palette_rgb)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
row_h = body_font.size + 14
|
||||
@@ -720,13 +630,15 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
||||
|
||||
y = 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_render.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)
|
||||
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,
|
||||
palette_rgb=palette_rgb)
|
||||
row_h = chip_font.size + 10
|
||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
|
||||
+4
-1
@@ -24,7 +24,9 @@ GRID_SHORT = 5
|
||||
# scaling (see calendar_render.py); whiteboard needs enough room to be
|
||||
# worth looking at; photos can go as small as a single cell; tasks needs
|
||||
# enough width for a due-date prefix plus a couple words of summary
|
||||
# without truncating on every row.
|
||||
# without truncating on every row; weather needs enough room for its
|
||||
# hourly/daily strips to stay legible (its current/multi_city modes
|
||||
# would tolerate smaller, but every mode shares one footprint value).
|
||||
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"photos": (1, 1),
|
||||
"calendar": (3, 2),
|
||||
@@ -32,6 +34,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||
"tasks": (2, 2),
|
||||
"static": (1, 1),
|
||||
"text": (2, 1),
|
||||
"weather": (2, 2),
|
||||
}
|
||||
|
||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
||||
|
||||
@@ -624,6 +624,38 @@ def _migration_23(conn) -> None:
|
||||
))
|
||||
|
||||
|
||||
def _migration_24(conn) -> None:
|
||||
"""New widget type: standalone weather (current/hourly/daily/
|
||||
multi_city display modes, pluggable Open-Meteo/NWS providers -- see
|
||||
models.WeatherWidgetConfig, app/weather/, app/widgets/weather.py).
|
||||
Lifts the calendar widget's embedded weather strip's underlying
|
||||
fetch/render building blocks (app/weather/open_meteo.py, the icon-
|
||||
drawing primitives now in app/weather_render.py) out into a widget
|
||||
that can be placed/sized on its own -- CalendarWidgetConfig's own
|
||||
weather_* columns are untouched, still working exactly as before.
|
||||
|
||||
Raw CREATE TABLE, not Base.metadata.create_all, same reasoning as
|
||||
migration 20/21/23's own comments: create_all always reflects
|
||||
models.py's CURRENT shape, so replaying the full chain on an old
|
||||
database could collide with a later migration's ALTER TABLE on this
|
||||
same table."""
|
||||
conn.execute(text(
|
||||
"CREATE TABLE weather_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
"mode TEXT NOT NULL DEFAULT 'current', "
|
||||
"provider TEXT NOT NULL DEFAULT 'open_meteo', "
|
||||
"units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
||||
"city_label TEXT, "
|
||||
"city_latitude REAL, "
|
||||
"city_longitude REAL, "
|
||||
"hourly_interval_hours INTEGER NOT NULL DEFAULT 4, "
|
||||
"daily_days INTEGER NOT NULL DEFAULT 5, "
|
||||
"cities TEXT, "
|
||||
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
||||
"cached TEXT)"
|
||||
))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -648,6 +680,7 @@ MIGRATIONS = [
|
||||
(21, _migration_21),
|
||||
(22, _migration_22),
|
||||
(23, _migration_23),
|
||||
(24, _migration_24),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+37
-1
@@ -433,7 +433,7 @@ class Widget(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks"
|
||||
widget_type: Mapped[str] = mapped_column(String) # "photos" | "calendar" | "whiteboard" | "tasks" | "static" | "text" | "weather"
|
||||
x: Mapped[int] = mapped_column(Integer)
|
||||
y: Mapped[int] = mapped_column(Integer)
|
||||
w: Mapped[int] = mapped_column(Integer)
|
||||
@@ -553,6 +553,41 @@ class WhiteboardWidgetConfig(Base):
|
||||
cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
|
||||
class WeatherWidgetConfig(Base):
|
||||
"""One weather widget's settings + cached-fetch state. Four display
|
||||
modes (see app/widgets/weather.py): "current" (one city, current
|
||||
temp + icon), "hourly" (one city, a row of ticks across the day),
|
||||
"daily" (one city, a multi-day strip), "multi_city" (several cities'
|
||||
current-day high/low/icon side by side -- the calendar widget's
|
||||
embedded weather strip, lifted out into its own widget type).
|
||||
`provider` selects which of app/weather/'s PROVIDERS actually fetches
|
||||
("open_meteo" | "nws" -- see that package's own module docstring).
|
||||
`cached`'s shape depends on `mode`: {"temp","category"} for current,
|
||||
a list of {"time","temp","category"} for hourly, a
|
||||
{"YYYY-MM-DD": {...}} dict for daily, or a list of
|
||||
{"label","high","low","category"} for multi_city."""
|
||||
|
||||
__tablename__ = "weather_widget_configs"
|
||||
|
||||
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True)
|
||||
mode: Mapped[str] = mapped_column(String, default="current") # current | hourly | daily | multi_city
|
||||
provider: Mapped[str] = mapped_column(String, default="open_meteo") # open_meteo | nws
|
||||
units: Mapped[str] = mapped_column(String, default="fahrenheit") # fahrenheit | celsius
|
||||
# Single-location modes only (current/hourly/daily) -- geocoded once
|
||||
# via weather.geocode_city() when set, same idiom as
|
||||
# CalendarWidgetConfig.weather_cities' per-entry shape.
|
||||
city_label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
city_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
city_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
hourly_interval_hours: Mapped[int] = mapped_column(Integer, default=4)
|
||||
daily_days: Mapped[int] = mapped_column(Integer, default=5)
|
||||
# multi_city mode only -- [{"label", "latitude", "longitude"}, ...],
|
||||
# same shape as CalendarWidgetConfig.weather_cities.
|
||||
cities: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
cached: Mapped[dict | list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
|
||||
class TextWidgetConfig(Base):
|
||||
"""One text widget's authored content + display settings -- another
|
||||
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
||||
@@ -618,6 +653,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
"tasks": TaskWidgetConfig,
|
||||
"static": StaticWidgetConfig,
|
||||
"text": TextWidgetConfig,
|
||||
"weather": WeatherWidgetConfig,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
|
||||
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, weather_render, webdav_client
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db, widget_locked
|
||||
from ..image_pipeline import (
|
||||
@@ -47,6 +47,7 @@ from ..models import (
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
WIDGET_CONFIG_MODELS,
|
||||
Widget,
|
||||
@@ -60,6 +61,7 @@ from .common import (
|
||||
get_or_refresh_calendar_events_for_widget,
|
||||
get_or_refresh_tasks_for_widget,
|
||||
get_or_refresh_weather_for_widget,
|
||||
get_or_refresh_weather_widget_data,
|
||||
get_or_refresh_whiteboard_for_widget,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
@@ -283,6 +285,12 @@ def api_widget_config_save(
|
||||
text_font_family: str | None = Form(None),
|
||||
text_align: str | None = Form(None),
|
||||
text_background_color: str | None = Form(None),
|
||||
# weather
|
||||
weather_mode: str | None = Form(None),
|
||||
weather_provider: str | None = Form(None),
|
||||
weather_units: str | None = Form(None),
|
||||
weather_hourly_interval_hours: int | None = Form(None),
|
||||
weather_daily_days: int | None = Form(None),
|
||||
):
|
||||
"""Every field optional -- same partial-update, form-urlencoded
|
||||
convention as the old frame-level api_config_save, now scoped to one
|
||||
@@ -379,6 +387,36 @@ def api_widget_config_save(
|
||||
xcfg.background_color = (
|
||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
||||
)
|
||||
elif widget.widget_type == "weather":
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
||||
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
||||
if weather_mode != wcfg.mode:
|
||||
# A stale cache is a different shape under a
|
||||
# different mode (a single-temp dict vs. an hourly
|
||||
# list vs. a daily dict vs. a city list) -- clear it
|
||||
# outright (not just force a refetch attempt) so a
|
||||
# get_or_refresh_weather_widget_data call that happens
|
||||
# to fail on the very first fetch under the new mode
|
||||
# doesn't fall back to the old mode's incompatible
|
||||
# cached shape.
|
||||
wcfg.checked_at = 0.0
|
||||
wcfg.cached = None
|
||||
wcfg.mode = weather_mode
|
||||
if weather_provider is not None and weather_provider in weather.PROVIDERS:
|
||||
if weather_provider != wcfg.provider:
|
||||
wcfg.checked_at = 0.0
|
||||
wcfg.provider = weather_provider
|
||||
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
|
||||
if weather_units != wcfg.units:
|
||||
# Cached temps are in the old unit -- force a refetch
|
||||
# rather than showing stale numbers under a new unit
|
||||
# label (same idiom as calendar_weather_units above).
|
||||
wcfg.checked_at = 0.0
|
||||
wcfg.units = weather_units
|
||||
if weather_hourly_interval_hours is not None:
|
||||
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
||||
if weather_daily_days is not None:
|
||||
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
@@ -833,6 +871,124 @@ def api_widget_weather_city_remove(
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
# --- Weather widget: location/cities/preview -------------------------------
|
||||
#
|
||||
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
|
||||
# "weather-cities") specifically to avoid colliding with the calendar
|
||||
# widget's own /weather-cities/add|remove route *patterns* above -- both
|
||||
# are registered against the same {widget_id}-parameterized path shape,
|
||||
# so a literal name clash there would silently shadow one of them
|
||||
# regardless of each handler's own _require_widget_type check.
|
||||
|
||||
class WeatherLocationRequest(BaseModel):
|
||||
name: str | None # None clears the location; else a free-text city name to geocode
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
|
||||
def api_widget_weather_location(
|
||||
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Sets (or clears) this weather widget's single configured location
|
||||
-- the current/hourly/daily modes' one city. A widget-wide display
|
||||
setting like calendar_view/weather_units, not personal data, hence
|
||||
require_widget_control rather than the calendar/tasks owner-adds/
|
||||
anyone-mutes split (there's only ever one location and no per-person
|
||||
ownership of it)."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
if body.name is None:
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.city_label = None
|
||||
cfg.city_latitude = None
|
||||
cfg.city_longitude = None
|
||||
cfg.cached = None
|
||||
cfg.checked_at = 0.0
|
||||
return {"status": "saved", "city": None}
|
||||
try:
|
||||
city = weather.geocode_city(body.name)
|
||||
except weather.WeatherFetchError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cfg.city_label = city["label"]
|
||||
cfg.city_latitude = city["latitude"]
|
||||
cfg.city_longitude = city["longitude"]
|
||||
cfg.cached = None
|
||||
cfg.checked_at = 0.0 # pick up the new location promptly
|
||||
return {"status": "saved", "city": city}
|
||||
|
||||
|
||||
class WeatherWidgetCityAddRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
|
||||
def api_widget_weather_widget_city_add(
|
||||
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""multi_city mode's city list -- same shape/gating as the calendar
|
||||
widget's own weather-cities/add above, just scoped to this widget's
|
||||
own WeatherWidgetConfig.cities."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
try:
|
||||
city = weather.geocode_city(body.name)
|
||||
except weather.WeatherFetchError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cities = list(cfg.cities or [])
|
||||
if any(c["label"] == city["label"] for c in cities):
|
||||
raise HTTPException(400, f"{city['label']} is already on this widget's list")
|
||||
cities.append(city)
|
||||
cfg.cities = cities
|
||||
cfg.checked_at = 0.0 # pick up the new city promptly
|
||||
return {"status": "saved", "city": city}
|
||||
|
||||
|
||||
class WeatherWidgetCityRemoveRequest(BaseModel):
|
||||
label: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
|
||||
def api_widget_weather_widget_city_remove(
|
||||
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
||||
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
|
||||
cfg.cities = cities
|
||||
if cfg.cached:
|
||||
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
|
||||
def api_widget_preview_weather(
|
||||
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The same throttled fetch cache a live device render would use, run
|
||||
through the panel composition/quantization pipeline -- "how it will
|
||||
look on the frame", same convention as the other preview endpoints.
|
||||
force=True (the "Refresh now" button) bypasses the fetch throttle."""
|
||||
frame, widget = frame_widget
|
||||
_require_widget_type(widget, "weather")
|
||||
wcfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
|
||||
if data is None:
|
||||
if wcfg.mode == "multi_city":
|
||||
raise HTTPException(400, "No cities added to this widget yet")
|
||||
raise HTTPException(400, "No location set on this widget yet")
|
||||
png = weather_render.render_weather_preview_png(
|
||||
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
||||
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
# --- Static image: upload/preview -----------------------------------------
|
||||
|
||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
||||
|
||||
@@ -31,6 +31,7 @@ from ..models import (
|
||||
TaskWidgetConfig,
|
||||
User,
|
||||
Widget,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
)
|
||||
|
||||
@@ -574,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
||||
return result
|
||||
|
||||
|
||||
HOURLY_FETCH_HOURS = 48 # 2 days -- comfortably covers every hourly_interval_hours option (3/4/6/12) at any widget width
|
||||
|
||||
|
||||
def get_or_refresh_weather_widget_data(db: Session, frame: Frame, widget: Widget, force: bool = False):
|
||||
"""Throttled fetch cache (weather.CHECK_INTERVAL_S) for the standalone
|
||||
weather widget (see app/widgets/weather.py) -- reads/writes
|
||||
WeatherWidgetConfig. What gets fetched depends on cfg.mode: current/
|
||||
hourly/daily need a single configured location (city_latitude/
|
||||
city_longitude); multi_city needs cfg.cities. None if not configured
|
||||
yet, so render() falls back to a placeholder -- same convention as
|
||||
get_or_refresh_whiteboard_for_widget. force=True (the "Refresh now"
|
||||
button) bypasses the throttle entirely.
|
||||
|
||||
A single-location mode's fetch failure keeps the last-known cached
|
||||
value (same reasoning as get_or_refresh_whiteboard_for_widget); a
|
||||
multi_city fetch fails per-city (like get_or_refresh_weather_for_
|
||||
widget's calendar-strip counterpart) so one broken city doesn't blank
|
||||
the others."""
|
||||
cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
if cfg.mode == "multi_city":
|
||||
if not cfg.cities:
|
||||
return None
|
||||
elif cfg.city_latitude is None or cfg.city_longitude is None:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
if not force and cfg.cached is not None and now - cfg.checked_at < weather.CHECK_INTERVAL_S:
|
||||
return cfg.cached
|
||||
|
||||
if cfg.mode == "multi_city":
|
||||
previous = {c["label"]: c for c in (cfg.cached or [])}
|
||||
result = []
|
||||
for city in cfg.cities:
|
||||
try:
|
||||
today = weather.fetch_daily(cfg.provider, city["latitude"], city["longitude"], cfg.units, 1)
|
||||
d = next(iter(today.values())) if today else previous.get(city["label"], {})
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather for %s: %s", city["label"], e)
|
||||
d = previous.get(city["label"], {})
|
||||
result.append({
|
||||
"label": city["label"], "high": d.get("high"), "low": d.get("low"),
|
||||
"category": d.get("category", "cloudy"),
|
||||
})
|
||||
else:
|
||||
try:
|
||||
if cfg.mode == "current":
|
||||
result = weather.fetch_current(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units)
|
||||
elif cfg.mode == "hourly":
|
||||
result = weather.fetch_hourly(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||
hours=HOURLY_FETCH_HOURS)
|
||||
else: # "daily"
|
||||
result = weather.fetch_daily(cfg.provider, cfg.city_latitude, cfg.city_longitude, cfg.units,
|
||||
cfg.daily_days)
|
||||
except weather.WeatherFetchError as e:
|
||||
logger.warning("Could not refresh weather widget %d: %s", widget.id, e)
|
||||
return cfg.cached
|
||||
|
||||
with widget_locked(db, frame.id, widget.id) as (_, _, locked_cfg):
|
||||
locked_cfg.cached = result
|
||||
locked_cfg.checked_at = now
|
||||
return result
|
||||
|
||||
|
||||
TASKS_COMPLETED_WINDOW_HOURS = 24 # how far back TaskWidgetConfig.show_completed looks
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import weather
|
||||
from ..auth import can_view_frame, current_user
|
||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||
from ..db import get_db
|
||||
@@ -38,6 +39,7 @@ from ..models import (
|
||||
TextWidgetConfig,
|
||||
User,
|
||||
UserFrame,
|
||||
WeatherWidgetConfig,
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
@@ -265,4 +267,11 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
|
||||
"viewer_has_webdav_creds": viewer_has_webdav_creds,
|
||||
})
|
||||
|
||||
if widget.widget_type == "weather":
|
||||
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
return templates.TemplateResponse("_widget_dialog_weather.html", {
|
||||
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
|
||||
"weather_provider_labels": weather.PROVIDER_LABELS,
|
||||
})
|
||||
|
||||
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||
const WIDGET_LABELS = {
|
||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
||||
static: 'Static image', text: 'Text',
|
||||
static: 'Static image', text: 'Text', weather: 'Weather',
|
||||
};
|
||||
|
||||
function showStatus(ok, message) {
|
||||
|
||||
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
|
||||
// icon was clicked).
|
||||
const DIALOG_INIT = {
|
||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
|
||||
};
|
||||
const DIALOG_CLOSE = {
|
||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
|
||||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
|
||||
};
|
||||
|
||||
let openDialogWidgetType = null;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Weather widget dialog: mode/provider/units settings, location (single-
|
||||
// city modes) or a city list (multi_city mode), and the rendered
|
||||
// preview. Not a page-load script -- frame_layout.js fetches this
|
||||
// widget's dialog HTML fragment, injects it into the shared <dialog>,
|
||||
// points window.FRAME_API at this specific widget
|
||||
// (/api/frames/{id}/widgets/{widget_id}), then calls initWeatherDialog().
|
||||
|
||||
// Only one of "Location" (current/hourly/daily -- one city) or "Cities"
|
||||
// (multi_city -- a list) is ever relevant at a time; the interval/days
|
||||
// rows are each specific to one mode too.
|
||||
function updateWeatherFieldVisibility() {
|
||||
const mode = document.getElementById('weather_mode').value;
|
||||
document.getElementById('weather-hourly-interval-row').style.display = mode === 'hourly' ? '' : 'none';
|
||||
document.getElementById('weather-daily-days-row').style.display = mode === 'daily' ? '' : 'none';
|
||||
document.getElementById('weather-location-section').style.display = mode === 'multi_city' ? 'none' : '';
|
||||
document.getElementById('weather-cities-section').style.display = mode === 'multi_city' ? '' : 'none';
|
||||
}
|
||||
|
||||
function addWeatherWidgetCityRow(label) {
|
||||
const list = document.getElementById('weather-widget-city-list');
|
||||
const empty = document.getElementById('weather-widget-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-widget-city-remove';
|
||||
btn.dataset.label = label;
|
||||
btn.textContent = 'Remove';
|
||||
btn.addEventListener('click', removeWeatherWidgetCity);
|
||||
li.appendChild(span);
|
||||
li.appendChild(btn);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
async function removeWeatherWidgetCity(e) {
|
||||
const label = e.target.dataset.label;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-widget-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-widget-city-list');
|
||||
if (!list.querySelector('li')) {
|
||||
list.innerHTML = '<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>';
|
||||
}
|
||||
showStatus(true, `${label} removed.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadWeatherPreview(force) {
|
||||
const suffix = force ? '&force=1' : '';
|
||||
document.getElementById('weather-preview').src = `${window.FRAME_API}/preview/weather?_=${Date.now()}${suffix}`;
|
||||
}
|
||||
|
||||
function initWeatherDialog() {
|
||||
document.getElementById('weather_mode').addEventListener('change', updateWeatherFieldVisibility);
|
||||
updateWeatherFieldVisibility();
|
||||
|
||||
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
weather_mode: document.getElementById('weather_mode').value,
|
||||
weather_provider: document.getElementById('weather_provider').value,
|
||||
weather_units: document.getElementById('weather_units').value,
|
||||
weather_hourly_interval_hours: document.getElementById('weather_hourly_interval_hours').value,
|
||||
weather_daily_days: document.getElementById('weather_daily_days').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.');
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-location-set').addEventListener('click', async () => {
|
||||
const input = document.getElementById('weather-location-input');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||
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();
|
||||
document.getElementById('weather-location-current').textContent = `Currently: ${data.city.label}`;
|
||||
input.value = '';
|
||||
showStatus(true, `Location set to ${data.city.label}.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-location-clear').addEventListener('click', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-location`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: null }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
document.getElementById('weather-location-current').textContent = 'No location set yet.';
|
||||
showStatus(true, 'Location cleared.');
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.weather-widget-city-remove').forEach((el) => el.addEventListener('click', removeWeatherWidgetCity));
|
||||
|
||||
document.getElementById('weather-widget-city-add').addEventListener('click', async () => {
|
||||
const input = document.getElementById('weather-widget-city-input');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/weather-widget-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();
|
||||
addWeatherWidgetCityRow(data.city.label);
|
||||
input.value = '';
|
||||
showStatus(true, `Added ${data.city.label}.`);
|
||||
loadWeatherPreview(false);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
||||
loadWeatherPreview(false);
|
||||
}
|
||||
|
||||
function closeWeatherDialog() {
|
||||
// Nothing to tear down -- no poll interval, unlike the photos dialog.
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<h2 class="dialog-title">Weather widget</h2>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Display</h2>
|
||||
<form id="weather-config-form">
|
||||
<label>Mode
|
||||
<select id="weather_mode">
|
||||
<option value="current" {% if weather_cfg.mode == "current" %}selected{% endif %}>Current conditions</option>
|
||||
<option value="hourly" {% if weather_cfg.mode == "hourly" %}selected{% endif %}>Hourly forecast</option>
|
||||
<option value="daily" {% if weather_cfg.mode == "daily" %}selected{% endif %}>Multi-day forecast</option>
|
||||
<option value="multi_city" {% if weather_cfg.mode == "multi_city" %}selected{% endif %}>Multiple cities</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Weather source
|
||||
<select id="weather_provider">
|
||||
{% for value, label in weather_provider_labels.items() %}
|
||||
<option value="{{ value }}" {% if weather_cfg.provider == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations.</p>
|
||||
<label>Units
|
||||
<select id="weather_units">
|
||||
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
<option value="celsius" {% if weather_cfg.units == "celsius" %}selected{% endif %}>Celsius</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="weather-hourly-interval-row">
|
||||
<label>Hours between ticks
|
||||
<select id="weather_hourly_interval_hours">
|
||||
{% for hours in (3, 4, 6, 12) %}
|
||||
<option value="{{ hours }}" {% if weather_cfg.hourly_interval_hours == hours %}selected{% endif %}>{{ hours }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div id="weather-daily-days-row">
|
||||
<label>Days to show
|
||||
<input type="number" id="weather_daily_days" min="1" max="14" value="{{ weather_cfg.daily_days }}">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="weather-location-section" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Location</h2>
|
||||
<p class="sub" id="weather-location-current">
|
||||
{% if weather_cfg.city_label %}Currently: {{ weather_cfg.city_label }}{% else %}No location set yet.{% endif %}
|
||||
</p>
|
||||
<div class="checkbox-row" style="flex-wrap: wrap;">
|
||||
<input type="text" id="weather-location-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1 1 160px;">
|
||||
<button type="button" class="btn-inline" id="weather-location-set">Set</button>
|
||||
<button type="button" class="btn-inline secondary" id="weather-location-clear">Clear</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="weather-cities-section" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Cities</h2>
|
||||
<ul class="calendar-user-list" id="weather-widget-city-list">
|
||||
{% for c in weather_cfg.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-widget-city-remove" data-label="{{ c.label }}">Remove</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="sub" id="weather-widget-city-empty">No cities added yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="checkbox-row" style="margin-top: 10px;">
|
||||
<input type="text" id="weather-widget-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
|
||||
<button type="button" class="btn-inline" id="weather-widget-city-add">Add</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
<img class="preview-img" id="weather-preview" alt="Weather preview">
|
||||
<button type="button" class="secondary" id="weather-preview-refresh">Refresh now</button>
|
||||
</section>
|
||||
@@ -74,6 +74,7 @@
|
||||
<script src="/static/widget_dialog_tasks.js"></script>
|
||||
<script src="/static/widget_dialog_static.js"></script>
|
||||
<script src="/static/widget_dialog_text.js"></script>
|
||||
<script src="/static/widget_dialog_weather.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
<script src="/static/saved_layouts.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Weather provider registry -- the app/widgets/ "dispatch registry over
|
||||
pluggable implementations" pattern applied to weather data sources
|
||||
instead of widget types. Open-Meteo (app/weather/open_meteo.py, no API
|
||||
key, worldwide) and NWS (app/weather/nws.py, no API key, US-only) are the
|
||||
two providers wired up now; Environment Canada is a documented next step
|
||||
(docs/widgets.md), not included yet -- its free API is built around
|
||||
station/grid lookups (the MSC GeoMet OGC service), not simple lat/lon
|
||||
REST like these two, and would have meaningfully expanded this pass.
|
||||
|
||||
geocode_city stays Open-Meteo-backed regardless of which provider is
|
||||
chosen to actually fetch forecasts -- it's just free-text-name-to-lat/lon
|
||||
resolution, done once when a location is added, and Open-Meteo's
|
||||
geocoder covers the whole world where NWS's own data plainly doesn't.
|
||||
|
||||
Every provider module exposes the same four functions:
|
||||
geocode_city(name) -> {"label", "latitude", "longitude"} (open_meteo only, see above)
|
||||
fetch_current(lat, lon, units) -> {"temp", "category"}
|
||||
fetch_hourly(lat, lon, units, hours) -> [{"time", "temp", "category"}, ...]
|
||||
fetch_daily(lat, lon, units, days) -> {"YYYY-MM-DD": {"high", "low", "category"}, ...}
|
||||
"category" is always one of the shared set (clear/partly_cloudy/cloudy/
|
||||
fog/rain/snow/thunderstorm) that app/weather_render.py's icon-drawing
|
||||
knows how to draw -- callers never need to know which provider supplied
|
||||
an entry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class WeatherFetchError(Exception):
|
||||
"""Geocoding or forecast fetch failed -- network, no match, an
|
||||
unexpected response shape, or (NWS) a location outside its US
|
||||
coverage. Raised loudly; callers (the dialog's location-set/preview
|
||||
endpoints, get_or_refresh_*_for_widget) decide what to do."""
|
||||
|
||||
|
||||
from . import nws, open_meteo # noqa: E402 -- after WeatherFetchError, which both submodules import
|
||||
|
||||
# Re-exported for existing call sites (routers/common.py, routers/
|
||||
# api_widgets.py, calendar_render.py) -- all Open-Meteo-only and
|
||||
# untouched by the provider abstraction below.
|
||||
from .open_meteo import ( # noqa: E402,F401
|
||||
CHECK_INTERVAL_S,
|
||||
fetch_daily_forecast,
|
||||
geocode_city,
|
||||
weather_category,
|
||||
)
|
||||
|
||||
PROVIDERS = {"open_meteo": open_meteo, "nws": nws}
|
||||
PROVIDER_LABELS = {"open_meteo": "Open-Meteo", "nws": "National Weather Service (US)"}
|
||||
|
||||
|
||||
def fetch_current(provider: str, latitude: float, longitude: float, units: str) -> dict:
|
||||
return PROVIDERS[provider].fetch_current(latitude, longitude, units)
|
||||
|
||||
|
||||
def fetch_hourly(provider: str, latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
||||
return PROVIDERS[provider].fetch_hourly(latitude, longitude, units, hours)
|
||||
|
||||
|
||||
def fetch_daily(provider: str, latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
||||
return PROVIDERS[provider].fetch_daily(latitude, longitude, units, days)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""NWS (National Weather Service, api.weather.gov) provider -- US
|
||||
locations only, free, no API key, but requires a `User-Agent` header
|
||||
identifying the calling app (NWS API policy: unidentified traffic gets
|
||||
throttled/blocked). No geocoder of its own; app/weather/__init__.py's
|
||||
geocode_city (Open-Meteo's) resolves a place name to lat/lon regardless
|
||||
of which provider is then chosen to fetch with it.
|
||||
|
||||
Unlike Open-Meteo's numeric WMO codes, NWS periods carry a `shortForecast`
|
||||
text description and an `icon` URL encoding a condition code (e.g.
|
||||
".../icons/land/day/tsra,40?size=medium") -- _category_from_period below
|
||||
normalizes either into the same shared category set
|
||||
(clear/partly_cloudy/cloudy/fog/rain/snow/thunderstorm) Open-Meteo's
|
||||
weather_category() already produces, so app/weather_render.py's build_*
|
||||
functions never need to know which provider supplied an entry.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from . import WeatherFetchError
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
BASE_URL = "https://api.weather.gov"
|
||||
# NWS blocks/deprioritizes traffic with no identifying User-Agent -- a
|
||||
# contact-ish string is their documented convention, not a real account.
|
||||
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
|
||||
|
||||
# NWS's icon condition codes (https://api.weather.gov/icons), grouped into
|
||||
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
|
||||
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
|
||||
# sky condition plus wind -- stripped before lookup, since this project's
|
||||
# hand-drawn icons (app/weather_render.py) don't have a separate windy
|
||||
# glyph.
|
||||
_ICON_CODE_CATEGORIES = {
|
||||
"skc": "clear", "clear": "clear",
|
||||
"few": "partly_cloudy", "sct": "partly_cloudy",
|
||||
"bkn": "cloudy", "ovc": "cloudy",
|
||||
"fog": "fog", "haze": "fog", "smoke": "fog", "dust": "fog",
|
||||
"rain": "rain", "rain_showers": "rain", "rain_showers_hi": "rain",
|
||||
"showers": "rain", "drizzle": "rain",
|
||||
"snow": "snow", "rain_snow": "snow", "sleet": "snow",
|
||||
"fzra": "snow", "rain_fzra": "snow", "snow_fzra": "snow",
|
||||
"tsra": "thunderstorm", "tsra_sct": "thunderstorm", "tsra_hi": "thunderstorm",
|
||||
}
|
||||
|
||||
# Fallback keyword match against shortForecast text, in priority order --
|
||||
# used when the icon URL can't be parsed at all (unexpected shape) or its
|
||||
# condition code isn't in the table above (NWS occasionally adds new
|
||||
# icon variants).
|
||||
_TEXT_CATEGORY_KEYWORDS = [
|
||||
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"),
|
||||
("snow", "snow"), ("sleet", "snow"), ("ice", "snow"),
|
||||
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
|
||||
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
|
||||
("overcast", "cloudy"), ("cloudy", "cloudy"),
|
||||
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
|
||||
]
|
||||
|
||||
|
||||
def _category_from_icon(icon_url: str) -> str | None:
|
||||
"""The first condition code segment out of an icon URL path like
|
||||
".../icons/land/day/tsra,40/skc,20?size=medium" -- only the first
|
||||
(the dominant/nearest-term condition) is used, same "one glyph per
|
||||
entry" simplicity as Open-Meteo's single-WMO-code-per-day shape."""
|
||||
path = icon_url.split("?")[0]
|
||||
segments = [s for s in path.split("/") if s]
|
||||
for i, seg in enumerate(segments):
|
||||
if seg in ("day", "night") and i + 1 < len(segments):
|
||||
code = segments[i + 1].split(",")[0]
|
||||
code = code.removeprefix("wind_")
|
||||
return _ICON_CODE_CATEGORIES.get(code)
|
||||
return None
|
||||
|
||||
|
||||
def _category_from_text(text: str) -> str:
|
||||
lowered = text.lower()
|
||||
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
|
||||
if keyword in lowered:
|
||||
return category
|
||||
return "cloudy" # same generic-icon fallback Open-Meteo's weather_category uses
|
||||
|
||||
|
||||
def _category_from_period(period: dict) -> str:
|
||||
icon = period.get("icon") or ""
|
||||
category = _category_from_icon(icon) if icon else None
|
||||
return category or _category_from_text(period.get("shortForecast") or "")
|
||||
|
||||
|
||||
def _convert_temp(value: float, from_unit: str, to_units: str) -> float:
|
||||
"""NWS periods report temperatureUnit per-period (almost always "F"
|
||||
for US points) -- converts to the widget's own requested `units`
|
||||
("fahrenheit"/"celsius") only if they actually differ, so this is a
|
||||
no-op in the common case."""
|
||||
to_unit = "F" if to_units == "fahrenheit" else "C"
|
||||
if from_unit == to_unit:
|
||||
return value
|
||||
if from_unit == "F" and to_unit == "C":
|
||||
return (value - 32) * 5 / 9
|
||||
return value * 9 / 5 + 32
|
||||
|
||||
|
||||
def _points(latitude: float, longitude: float) -> dict:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{BASE_URL}/points/{latitude:.4f},{longitude:.4f}", headers=HEADERS, timeout=HTTP_TIMEOUT_S
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["properties"]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(f"NWS points lookup failed (is this location in the US?): {e}") from e
|
||||
|
||||
|
||||
def _periods(url: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["properties"]["periods"]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
|
||||
"""{"temp": float, "category": str} -- NWS has no simple by-
|
||||
coordinate "current conditions" endpoint (that needs a second
|
||||
stations-list + latest-observation lookup); this approximates
|
||||
"current" with the first hourly forecast period instead, which is
|
||||
plenty for a frame that only refreshes every few hours."""
|
||||
points = _points(latitude, longitude)
|
||||
periods = _periods(points["forecastHourly"])
|
||||
if not periods:
|
||||
raise WeatherFetchError("NWS returned no hourly forecast periods")
|
||||
period = periods[0]
|
||||
temp = _convert_temp(period["temperature"], period.get("temperatureUnit", "F"), units)
|
||||
return {"temp": temp, "category": _category_from_period(period)}
|
||||
|
||||
|
||||
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
||||
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
|
||||
one entry per hour -- NWS's forecastHourly is already 1-hour
|
||||
resolution, same as Open-Meteo's."""
|
||||
points = _points(latitude, longitude)
|
||||
periods = _periods(points["forecastHourly"])
|
||||
return [
|
||||
{
|
||||
"time": p["startTime"],
|
||||
"temp": _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units),
|
||||
"category": _category_from_period(p),
|
||||
}
|
||||
for p in periods[:hours]
|
||||
]
|
||||
|
||||
|
||||
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
||||
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
|
||||
-- NWS's /forecast returns day/night period pairs (12h each, ~7 days
|
||||
/ 14 periods), not one row per day; this pairs them by calendar date
|
||||
(a daytime period's high, the following night's low) and clamps to
|
||||
however many full dates actually came back once `days` is asked for
|
||||
more than that -- no error, just fewer days, same graceful-
|
||||
degradation idiom as app/weather_render.py's draw_weather_row."""
|
||||
points = _points(latitude, longitude)
|
||||
periods = _periods(points["forecast"])
|
||||
|
||||
by_date: dict[str, dict] = {}
|
||||
order: list[str] = []
|
||||
for p in periods:
|
||||
day = datetime.fromisoformat(p["startTime"]).date().isoformat()
|
||||
entry = by_date.setdefault(day, {"category": None})
|
||||
if day not in order:
|
||||
order.append(day)
|
||||
temp = _convert_temp(p["temperature"], p.get("temperatureUnit", "F"), units)
|
||||
if p["isDaytime"]:
|
||||
entry["high"] = temp
|
||||
entry["category"] = _category_from_period(p)
|
||||
else:
|
||||
entry["low"] = temp
|
||||
if entry["category"] is None:
|
||||
entry["category"] = _category_from_period(p)
|
||||
|
||||
result = {}
|
||||
for day in order[:max(1, days)]:
|
||||
entry = by_date[day]
|
||||
if "high" not in entry and "low" not in entry:
|
||||
continue
|
||||
result[day] = {
|
||||
"high": entry.get("high", entry.get("low")),
|
||||
"low": entry.get("low", entry.get("high")),
|
||||
"category": entry["category"] or "cloudy",
|
||||
}
|
||||
return result
|
||||
@@ -1,10 +1,10 @@
|
||||
"""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.
|
||||
"""Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
|
||||
-- free geocoding + forecast APIs, no API key, no signup, no per-request
|
||||
quota to manage. Backs both the calendar widget's embedded weather strip
|
||||
(fetch_daily_forecast/weather_category, its original shape, untouched)
|
||||
and the standalone weather widget's per-mode fetches below (fetch_current/
|
||||
fetch_hourly/fetch_daily, which return already-normalized {"category":
|
||||
...} entries from the shared category set instead of a raw WMO code).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py.
|
||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from . import WeatherFetchError
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
@@ -36,12 +38,6 @@ _CODE_CATEGORIES = {
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -133,3 +129,67 @@ def fetch_daily_forecast(latitude: float, longitude: float, units: str) -> dict[
|
||||
}
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
# --- Standalone weather widget fetches (app/widgets/weather.py) --------
|
||||
#
|
||||
# Unlike fetch_daily_forecast above (kept as-is for the calendar widget's
|
||||
# embedded strip, which does its own weather_category(code) lookup),
|
||||
# these return already-normalized {"category": ...} entries so
|
||||
# app/weather_render.py's build_* functions never need to know which
|
||||
# provider supplied the data (see app/weather/nws.py, which normalizes
|
||||
# its own icon/text shape to the same category set).
|
||||
|
||||
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
|
||||
"""{"temp": float, "category": str} for right now."""
|
||||
try:
|
||||
resp = httpx.get(FORECAST_URL, params={
|
||||
"latitude": latitude, "longitude": longitude,
|
||||
"current": "temperature_2m,weathercode",
|
||||
"temperature_unit": units,
|
||||
"timezone": "auto",
|
||||
}, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
current = resp.json()["current"]
|
||||
return {"temp": current["temperature_2m"], "category": weather_category(current["weathercode"])}
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
||||
"""[{"time": ISO 8601 string, "temp": float, "category": str}, ...],
|
||||
one entry per hour, for the next `hours` hours (Open-Meteo's hourly
|
||||
data is always 1-hour resolution regardless of how far apart the
|
||||
ticks a caller actually wants to display are -- see
|
||||
app/weather_render.py's build_hourly, which samples every Nth
|
||||
entry)."""
|
||||
forecast_days = max(1, -(-hours // 24)) # ceil division -- enough days to cover `hours`
|
||||
try:
|
||||
resp = httpx.get(FORECAST_URL, params={
|
||||
"latitude": latitude, "longitude": longitude,
|
||||
"hourly": "temperature_2m,weathercode",
|
||||
"temperature_unit": units,
|
||||
"timezone": "auto",
|
||||
"forecast_days": forecast_days,
|
||||
}, timeout=HTTP_TIMEOUT_S)
|
||||
resp.raise_for_status()
|
||||
hourly = resp.json()["hourly"]
|
||||
return [
|
||||
{"time": t, "temp": temp, "category": weather_category(code)}
|
||||
for t, temp, code in zip(hourly["time"], hourly["temperature_2m"], hourly["weathercode"])
|
||||
][:hours]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
||||
"""{"YYYY-MM-DD": {"high": float, "low": float, "category": str}, ...}
|
||||
-- same request as fetch_daily_forecast, just normalized to a
|
||||
category instead of a raw WMO code, and clamped to `days` (Open-
|
||||
Meteo's own real max is FORECAST_DAYS)."""
|
||||
clamped = max(1, min(days, FORECAST_DAYS))
|
||||
raw = fetch_daily_forecast(latitude, longitude, units)
|
||||
return {
|
||||
day: {"high": d["high"], "low": d["low"], "category": weather_category(d["code"])}
|
||||
for day, d in list(raw.items())[:clamped]
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Weather icon-drawing primitives (draw_cloud/draw_weather_icon/
|
||||
draw_weather_row -- extracted out of calendar_render.py, which still
|
||||
imports draw_weather_row for its own embedded weather strip, unchanged)
|
||||
plus the standalone weather widget's four per-mode renderers
|
||||
(build_current/build_hourly/build_daily/build_multi_city, dispatched by
|
||||
build()) and its preview-PNG wrapper -- the weather analogue of
|
||||
calendar_render.py's own _build_tasks/render_tasks_preview_png
|
||||
relationship.
|
||||
|
||||
Every build_* function takes already-normalized data (see app/weather/'s
|
||||
provider modules -- a `category` key from the shared clear/partly_cloudy/
|
||||
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
|
||||
returns an RGB Image exactly target_w x target_h, same contract every
|
||||
other widget renderer in this project follows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
from datetime import date, datetime
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
|
||||
|
||||
MARGIN = 20
|
||||
BG = (255, 255, 255)
|
||||
FG = (0, 0, 0)
|
||||
MUTED = (110, 110, 110)
|
||||
RULE = (0, 0, 0)
|
||||
|
||||
|
||||
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
||||
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
||||
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
|
||||
already this module's BG/FG) -- same resolution idiom as
|
||||
calendar_render.py's _event_colors, so a custom palette override
|
||||
(Frame.palette_rgb) still gets its own actual yellow/blue, not a
|
||||
hardcoded RGB triple."""
|
||||
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
|
||||
|
||||
|
||||
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
|
||||
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
||||
with a clean outline -- drawn as one outline-color pass slightly
|
||||
larger than the shapes, then the same shapes again in `fill` 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=outline)
|
||||
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
|
||||
for x0, y0, x1, y1 in lobes:
|
||||
draw.ellipse([x0, y0, x1, y1], fill=fill)
|
||||
draw.rectangle(base, fill=fill)
|
||||
|
||||
|
||||
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
|
||||
"""A filled disc + 8 thick radiating rays -- the standard weather-app
|
||||
"sun" glyph, in the panel's actual yellow ink rather than a bare
|
||||
outline (a plain circle-with-4-ticks at small sizes read as a
|
||||
crosshair/target, not a sun)."""
|
||||
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
|
||||
for i in range(8):
|
||||
angle = i * (math.pi / 4)
|
||||
dx, dy = math.cos(angle), math.sin(angle)
|
||||
draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)],
|
||||
fill=color, width=max(2, round(r * 0.14)))
|
||||
|
||||
|
||||
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
|
||||
"""A rounded teardrop (point up, bulb down) -- the standard rain
|
||||
glyph, not a bare diagonal tick."""
|
||||
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
|
||||
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
|
||||
|
||||
|
||||
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
|
||||
"""A 6-pointed asterisk -- the standard snowflake glyph."""
|
||||
for i in range(3):
|
||||
angle = i * (math.pi / 3)
|
||||
dx, dy = math.cos(angle) * r, math.sin(angle) * r
|
||||
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.3)))
|
||||
|
||||
|
||||
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
|
||||
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
|
||||
3-segment line."""
|
||||
points = [
|
||||
(cx + size * 0.15, cy - size * 0.7),
|
||||
(cx - size * 0.35, cy + size * 0.05),
|
||||
(cx - size * 0.05, cy + size * 0.05),
|
||||
(cx - size * 0.2, cy + size * 0.7),
|
||||
(cx + size * 0.4, cy - size * 0.1),
|
||||
(cx + size * 0.05, cy - size * 0.1),
|
||||
]
|
||||
draw.polygon(points, fill=color)
|
||||
|
||||
|
||||
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
|
||||
palette_rgb: list | None = None) -> None:
|
||||
"""A small glyph for one weather category -- no custom font/icon
|
||||
asset (hand-primitives only, same approach calendar_render.py uses
|
||||
elsewhere for e.g. month view's density dots), but drawn in the
|
||||
panel's own ink colors (yellow sun/bolt, blue rain/snow) rather than
|
||||
flat black -- a plain monochrome outline at small sizes read as
|
||||
abstract shapes (a "sun" that looked like a crosshair), not
|
||||
recognizable weather icons."""
|
||||
yellow = _ink(palette_rgb, 2)
|
||||
blue = _ink(palette_rgb, 4)
|
||||
|
||||
if category == "clear":
|
||||
draw_sun(draw, cx, cy, r, yellow)
|
||||
return
|
||||
|
||||
if category == "partly_cloudy":
|
||||
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
|
||||
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
|
||||
return
|
||||
|
||||
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
|
||||
draw_cloud(draw, cx, cloud_cy, r)
|
||||
|
||||
if category == "fog":
|
||||
for i in range(3):
|
||||
y = cy + r * 0.55 + i * (r * 0.4)
|
||||
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
||||
elif category == "rain":
|
||||
for dx in (-0.55, 0, 0.55):
|
||||
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
|
||||
elif category == "snow":
|
||||
for dx in (-0.55, 0, 0.55):
|
||||
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
|
||||
elif category == "thunderstorm":
|
||||
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
|
||||
|
||||
|
||||
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, palette_rgb: list | None = None) -> 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"], palette_rgb)
|
||||
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
|
||||
|
||||
|
||||
# --- Standalone weather widget (app/widgets/weather.py) -----------------
|
||||
|
||||
def _format_hour_label(iso_time: str) -> str:
|
||||
dt = datetime.fromisoformat(iso_time)
|
||||
text = dt.strftime("%I %p").lstrip("0")
|
||||
return text if text else "12 AM"
|
||||
|
||||
|
||||
def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
|
||||
min_size: int = 9) -> int:
|
||||
"""Largest size <= max_size at which every string in `texts` fits
|
||||
within max_width -- used to size a per-column label/temp font against
|
||||
the actual column width instead of an icon-radius-derived guess,
|
||||
which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
|
||||
next column at a large icon size on a narrow column."""
|
||||
for size in range(max_size, min_size - 1, -1):
|
||||
font = ImageFont.load_default(size=size)
|
||||
if all(draw.textlength(t, font=font) <= max_width for t in texts):
|
||||
return size
|
||||
return min_size
|
||||
|
||||
|
||||
def _day_label(day_date: date) -> str:
|
||||
delta = (day_date - date.today()).days
|
||||
if delta == 0:
|
||||
return "Today"
|
||||
if delta == 1:
|
||||
return "Tomorrow"
|
||||
return day_date.strftime("%a")
|
||||
|
||||
|
||||
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
"""One big icon + big temp number + (optional) city label, centered --
|
||||
entry is {"temp", "category"} or None if nothing's been fetched yet
|
||||
(callers normally catch that earlier and show a placeholder instead,
|
||||
but this degrades to a blank canvas rather than erroring either
|
||||
way)."""
|
||||
img = Image.new("RGB", (target_w, target_h), BG)
|
||||
if not entry:
|
||||
return img
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
icon_r = max(20, min(target_w, target_h) // 4)
|
||||
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
|
||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
||||
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
temp_size = max(24, min(target_w, target_h) // 3)
|
||||
temp_font = ImageFont.load_default(size=temp_size)
|
||||
temp_text = f"{round(entry['temp'])}°{unit_suffix}"
|
||||
bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
|
||||
temp_y = cy + icon_r + 12
|
||||
draw_text(img, (target_w // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)
|
||||
|
||||
if city_label:
|
||||
label_size = max(12, temp_size // 3)
|
||||
label_font = ImageFont.load_default(size=label_size)
|
||||
lbbox = draw.textbbox((0, 0), city_label, font=label_font)
|
||||
draw_text(img, (target_w // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
|
||||
city_label, label_font, MUTED)
|
||||
return img
|
||||
|
||||
|
||||
def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", interval_hours: int = 4, city_label: str = "") -> Image.Image:
|
||||
"""A row of ticks across the day, one every `interval_hours` hours
|
||||
(entries is always 1-hour resolution -- see app/weather's provider
|
||||
fetch_hourly), each showing an hour label, icon, and temp. Same
|
||||
"draw however many fit" graceful degradation as draw_weather_row if
|
||||
the box is too narrow for every tick."""
|
||||
img = Image.new("RGB", (target_w, target_h), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
text_x0 = MARGIN
|
||||
text_w = target_w - MARGIN * 2
|
||||
y = MARGIN
|
||||
|
||||
title_size = max(14, min(target_w, target_h) // 16)
|
||||
if city_label:
|
||||
title_font = ImageFont.load_default(size=title_size)
|
||||
draw_text(img, (text_x0, y), city_label, title_font)
|
||||
y += title_size + 10
|
||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
||||
y += 12
|
||||
|
||||
# Capped to however many columns actually fit at a legible width
|
||||
# (narrower widgets/smaller intervals just show fewer ticks) rather
|
||||
# than cramming every sampled tick in regardless of how narrow that
|
||||
# makes each one -- same graceful-degradation idiom as
|
||||
# draw_weather_row's own per-pixel-width stopping point.
|
||||
min_col_w = 46
|
||||
max_ticks = max(1, text_w // min_col_w)
|
||||
ticks = entries[::max(1, interval_hours)][:max_ticks]
|
||||
if not ticks:
|
||||
return img
|
||||
col_w = max(1, text_w // len(ticks))
|
||||
icon_r = max(10, min(col_w // 3, (target_h - y - MARGIN) // 4))
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
time_labels = [_format_hour_label(e["time"]) for e in ticks]
|
||||
temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
|
||||
label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
|
||||
label_font = ImageFont.load_default(size=label_size)
|
||||
|
||||
for i, entry in enumerate(ticks):
|
||||
cx = text_x0 + i * col_w + col_w // 2
|
||||
time_label = time_labels[i]
|
||||
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
|
||||
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
|
||||
cy = y + label_size + 10 + icon_r
|
||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
||||
temp_label = temp_labels[i]
|
||||
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
|
||||
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
|
||||
return img
|
||||
|
||||
|
||||
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
|
||||
"""A day-by-day strip (day label, icon, high/low), however many days
|
||||
fit `target_w` (`daily` is already clamped to the widget's own
|
||||
configured day count by app/weather's provider fetch_daily -- this
|
||||
just draws whatever it's handed, same "stop once it doesn't fit"
|
||||
graceful degradation as draw_weather_row)."""
|
||||
img = Image.new("RGB", (target_w, target_h), BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
text_x0 = MARGIN
|
||||
text_w = target_w - MARGIN * 2
|
||||
y = MARGIN
|
||||
|
||||
title_size = max(14, min(target_w, target_h) // 16)
|
||||
if city_label:
|
||||
title_font = ImageFont.load_default(size=title_size)
|
||||
draw_text(img, (text_x0, y), city_label, title_font)
|
||||
y += title_size + 10
|
||||
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
|
||||
y += 12
|
||||
|
||||
days = list(daily.items())
|
||||
if not days:
|
||||
return img
|
||||
col_w = max(1, text_w // len(days))
|
||||
icon_r = max(12, min(col_w // 3, (target_h - y - MARGIN) // 4))
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
|
||||
temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
|
||||
label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
|
||||
label_font = ImageFont.load_default(size=label_size)
|
||||
|
||||
for i, (_, d) in enumerate(days):
|
||||
x0 = text_x0 + i * col_w
|
||||
label, temps = labels[i], temps_strs[i]
|
||||
lbbox = draw.textbbox((0, 0), label, font=label_font)
|
||||
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
|
||||
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
|
||||
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
|
||||
tbbox = draw.textbbox((0, 0), temps, font=label_font)
|
||||
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
|
||||
return img
|
||||
|
||||
|
||||
def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit") -> Image.Image:
|
||||
"""Several cities' current-day high/low/icon side by side -- directly
|
||||
reuses draw_weather_row (the same layout calendar_render.py's
|
||||
embedded strip uses), just as the whole widget's own content instead
|
||||
of a strip above an agenda day."""
|
||||
img = Image.new("RGB", (target_w, target_h), BG)
|
||||
if not cities:
|
||||
return img
|
||||
draw = ImageDraw.Draw(img)
|
||||
# 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, and the dialog's own "Cities" management
|
||||
# list), not for a compact display row. Same shortening
|
||||
# calendar_render.py's _weather_for_day already does for its own
|
||||
# embedded strip.
|
||||
cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
|
||||
text_w = target_w - MARGIN * 2
|
||||
# Sized against how many entries actually need to fit side by side,
|
||||
# not just the box's height -- an icon/font picked from target_h
|
||||
# alone (as this used to do) drew each entry so wide that only the
|
||||
# first city ever fit, and draw_weather_row's own "stop once it
|
||||
# doesn't fit" degradation silently dropped every city after it,
|
||||
# even in an ordinary-sized widget with plenty of cities configured.
|
||||
col_w = max(1, text_w // len(cities))
|
||||
icon_r = max(10, min(col_w // 6, target_h // 6, 40))
|
||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||
labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
|
||||
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
|
||||
font = ImageFont.load_default(size=font_size)
|
||||
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
|
||||
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
|
||||
show_labels=True, palette_rgb=palette_rgb)
|
||||
return img
|
||||
|
||||
|
||||
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
|
||||
units: str = "fahrenheit", city_label: str = "", interval_hours: int = 4) -> Image.Image:
|
||||
"""Dispatches to the right build_* function for this widget's
|
||||
configured mode -- shared by app/widgets/weather.py's render() and
|
||||
render_weather_preview_png below, so the two never drift apart."""
|
||||
if mode == "current":
|
||||
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
|
||||
if mode == "hourly":
|
||||
return build_hourly(data, target_w, target_h, palette_rgb, units, interval_hours, city_label)
|
||||
if mode == "daily":
|
||||
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
|
||||
if mode == "multi_city":
|
||||
return build_multi_city(data, target_w, target_h, palette_rgb, units)
|
||||
return Image.new("RGB", (target_w, target_h), BG) # unreachable via a valid config -- see WeatherWidgetConfig.mode
|
||||
|
||||
|
||||
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
|
||||
units: str = "fahrenheit", manage: dict | None = None,
|
||||
city_label: str = "", interval_hours: int = 4) -> bytes:
|
||||
"""Same pipeline as calendar_render.render_tasks_preview_png -- a
|
||||
normal browser-viewable PNG in logical (upright) orientation."""
|
||||
target_w, target_h = logical_render_size(orientation)
|
||||
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
@@ -36,7 +36,7 @@ Each module in this package exposes:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import calendar, photos, static_image, tasks, text, whiteboard
|
||||
from . import calendar, photos, static_image, tasks, text, weather, whiteboard
|
||||
|
||||
WIDGET_TYPES = {
|
||||
"photos": photos,
|
||||
@@ -45,4 +45,5 @@ WIDGET_TYPES = {
|
||||
"tasks": tasks,
|
||||
"static": static_image,
|
||||
"text": text,
|
||||
"weather": weather,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Weather widget: one of four display modes (see models.
|
||||
WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s
|
||||
PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render.
|
||||
py's build() dispatch. No real "next"/"back" concept (same as
|
||||
whiteboard) -- a single "check now" action forces a re-fetch bypassing
|
||||
the normal throttle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import weather_render
|
||||
from ..models import Frame, WeatherWidgetConfig, Widget
|
||||
from ..routers.common import get_or_refresh_weather_widget_data
|
||||
from ._shared import placeholder_image
|
||||
|
||||
ACTION_LABELS = {"check_now": "Check for updates"}
|
||||
|
||||
|
||||
def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
|
||||
is_normal_wake: bool = True) -> Image.Image:
|
||||
"""is_normal_wake is unused here -- see app/widgets/photos.py's
|
||||
identical note; every widget type's render() shares one call
|
||||
signature regardless of which ones actually care."""
|
||||
cfg = db.get(WeatherWidgetConfig, widget.id)
|
||||
data = get_or_refresh_weather_widget_data(db, frame, widget)
|
||||
if data is None:
|
||||
return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"])
|
||||
return weather_render.build(
|
||||
cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units,
|
||||
city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours,
|
||||
)
|
||||
|
||||
|
||||
def _check_now(db: Session, frame: Frame, widget: Widget) -> None:
|
||||
get_or_refresh_weather_widget_data(db, frame, widget, force=True)
|
||||
|
||||
|
||||
ACTIONS = {"check_now": _check_now}
|
||||
@@ -79,6 +79,9 @@ def test_expected_columns_exist_on_current_schema():
|
||||
assert "text_widget_configs" in inspector.get_table_names() # migration 21
|
||||
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
|
||||
assert "font_family" in text_widget_columns # migration 22
|
||||
assert "weather_widget_configs" in inspector.get_table_names() # migration 24
|
||||
weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
|
||||
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns
|
||||
|
||||
|
||||
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
|
||||
@@ -188,22 +191,23 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
|
||||
migrate."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs are migration 20/21
|
||||
# tables, and saved_layouts/saved_layout_widgets/saved_layout_
|
||||
# sources/saved_layout_button_actions are migration 23's (all
|
||||
# post-16, like the rest of this list) -- dropped here too so a
|
||||
# real version-15 database is what's actually being simulated,
|
||||
# not "version 15 plus tables that wouldn't exist yet". Harmless
|
||||
# to omit as long as no migration after the one that creates a
|
||||
# table also ALTERs it (that's what let static_widget_configs go
|
||||
# unlisted safely so far -- Base.metadata.create_all is
|
||||
# idempotent against an already-present table with no later
|
||||
# ALTER to collide with), but text_widget_configs' migration 22
|
||||
# ALTER makes the gap a real "table already exists"/"duplicate
|
||||
# column" collision instead of a silent no-op.
|
||||
# tables, saved_layouts/saved_layout_widgets/saved_layout_
|
||||
# sources/saved_layout_button_actions are migration 23's, and
|
||||
# weather_widget_configs is migration 24's (all post-16, like the
|
||||
# rest of this list) -- dropped here too so a real version-15
|
||||
# database is what's actually being simulated, not "version 15
|
||||
# plus tables that wouldn't exist yet". Harmless to omit as long
|
||||
# as no migration after the one that creates a table also ALTERs
|
||||
# or re-CREATEs it (that's what let a create_all-based migration
|
||||
# go unlisted safely so far), but static_widget_configs/
|
||||
# text_widget_configs/weather_widget_configs all use a raw
|
||||
# CREATE TABLE (not create_all -- see migration 20's own
|
||||
# docstring on why), so an already-present one is a real "table
|
||||
# already exists" collision, not a silent no-op.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
||||
"saved_layout_widgets", "saved_layouts", "widgets"):
|
||||
"saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
@@ -270,11 +274,12 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
|
||||
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
||||
calendar_key columns either."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||
# too -- see the comment on the identical setup in
|
||||
# static_widget_configs/text_widget_configs/saved_layout_*/
|
||||
# weather_widget_configs dropped too -- see the comment on the
|
||||
# identical setup in
|
||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
|
||||
# 20/21/23's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 23 and none of
|
||||
# 20/21/23/24's raw CREATE TABLE collides with an already-present table
|
||||
# otherwise, since this test replays 17 through 24 and none of
|
||||
# these tables would really exist yet at a genuine pre-migration-17
|
||||
# schema_version).
|
||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
||||
@@ -286,6 +291,7 @@ def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(d
|
||||
conn.execute(text("DROP TABLE saved_layout_sources"))
|
||||
conn.execute(text("DROP TABLE saved_layout_widgets"))
|
||||
conn.execute(text("DROP TABLE saved_layouts"))
|
||||
conn.execute(text("DROP TABLE weather_widget_configs"))
|
||||
conn.execute(text(
|
||||
"CREATE TABLE calendar_widget_configs ("
|
||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
||||
@@ -380,13 +386,14 @@ def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(d
|
||||
the widget backfill, not as a numbered migration racing ahead of
|
||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||
with db_module.engine.begin() as conn:
|
||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
||||
# too -- see the comment on the identical setup in
|
||||
# static_widget_configs/text_widget_configs/saved_layout_*/
|
||||
# weather_widget_configs dropped too -- see the comment on the
|
||||
# identical setup in
|
||||
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
|
||||
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
||||
"saved_layout_widgets", "saved_layouts", "widgets"):
|
||||
"saved_layout_widgets", "saved_layouts", "weather_widget_configs", "widgets"):
|
||||
conn.execute(text(f"DROP TABLE {table}"))
|
||||
conn.execute(text("DROP TABLE frame_calendars"))
|
||||
conn.execute(text(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""app/weather/'s provider modules -- pure-logic, no HTTP/DB: monkeypatches
|
||||
httpx.get with canned responses. Covers category normalization (Open-
|
||||
Meteo's WMO codes, NWS's icon-URL/text shapes) landing on the same shared
|
||||
category set, and each provider's fetch_current/fetch_hourly/fetch_daily
|
||||
shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.weather import WeatherFetchError, nws, open_meteo
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, data: dict):
|
||||
self._data = data
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._data
|
||||
|
||||
|
||||
# --- open_meteo -----------------------------------------------------------
|
||||
|
||||
def test_open_meteo_fetch_current(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse(
|
||||
{"current": {"temperature_2m": 72.5, "weathercode": 3}}
|
||||
))
|
||||
result = open_meteo.fetch_current(45.5, -122.6, "fahrenheit")
|
||||
assert result == {"temp": 72.5, "category": "cloudy"}
|
||||
|
||||
|
||||
def test_open_meteo_fetch_hourly_respects_hours_limit(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse({
|
||||
"hourly": {
|
||||
"time": [f"2026-07-27T{h:02d}:00" for h in range(24)],
|
||||
"temperature_2m": list(range(24)),
|
||||
"weathercode": [0] * 24,
|
||||
}
|
||||
}))
|
||||
result = open_meteo.fetch_hourly(45.5, -122.6, "fahrenheit", hours=6)
|
||||
assert len(result) == 6
|
||||
assert result[0] == {"time": "2026-07-27T00:00", "temp": 0, "category": "clear"}
|
||||
|
||||
|
||||
def test_open_meteo_fetch_daily_normalizes_category_and_clamps_days(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse({
|
||||
"daily": {
|
||||
"time": ["2026-07-27", "2026-07-28", "2026-07-29"],
|
||||
"weathercode": [95, 71, 61],
|
||||
"temperature_2m_max": [80, 60, 65],
|
||||
"temperature_2m_min": [65, 45, 50],
|
||||
}
|
||||
}))
|
||||
result = open_meteo.fetch_daily(45.5, -122.6, "fahrenheit", days=2)
|
||||
assert list(result.keys()) == ["2026-07-27", "2026-07-28"]
|
||||
assert result["2026-07-27"] == {"high": 80, "low": 65, "category": "thunderstorm"}
|
||||
assert result["2026-07-28"]["category"] == "snow"
|
||||
|
||||
|
||||
def test_open_meteo_weather_category_unknown_code_falls_back_to_cloudy():
|
||||
assert open_meteo.weather_category(9999) == "cloudy"
|
||||
|
||||
|
||||
def test_open_meteo_raises_weather_fetch_error_on_http_failure(monkeypatch):
|
||||
def _raise(*a, **kw):
|
||||
raise httpx.ConnectError("boom")
|
||||
monkeypatch.setattr(httpx, "get", _raise)
|
||||
with pytest.raises(WeatherFetchError):
|
||||
open_meteo.fetch_current(45.5, -122.6, "fahrenheit")
|
||||
|
||||
|
||||
# --- nws --------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("icon_url,expected", [
|
||||
("https://api.weather.gov/icons/land/day/skc?size=medium", "clear"),
|
||||
("https://api.weather.gov/icons/land/night/few?size=medium", "partly_cloudy"),
|
||||
("https://api.weather.gov/icons/land/day/bkn?size=medium", "cloudy"),
|
||||
("https://api.weather.gov/icons/land/day/tsra,40?size=medium", "thunderstorm"),
|
||||
("https://api.weather.gov/icons/land/night/wind_skc?size=medium", "clear"),
|
||||
("https://api.weather.gov/icons/land/day/snow,80?size=medium", "snow"),
|
||||
("https://api.weather.gov/icons/land/day/rain_showers,60?size=medium", "rain"),
|
||||
])
|
||||
def test_nws_category_from_icon(icon_url, expected):
|
||||
assert nws._category_from_icon(icon_url) == expected
|
||||
|
||||
|
||||
def test_nws_category_from_icon_unrecognized_code_falls_back_to_none():
|
||||
assert nws._category_from_icon("https://api.weather.gov/icons/land/day/mystery_code?size=medium") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("Chance Thunderstorms", "thunderstorm"),
|
||||
("Snow likely", "snow"),
|
||||
("Rain showers", "rain"),
|
||||
("Patchy Fog", "fog"),
|
||||
("Mostly Sunny", "clear"),
|
||||
("Something Unrelated", "cloudy"),
|
||||
])
|
||||
def test_nws_category_from_text_fallback(text, expected):
|
||||
assert nws._category_from_text(text) == expected
|
||||
|
||||
|
||||
def test_nws_convert_temp_fahrenheit_to_celsius():
|
||||
assert round(nws._convert_temp(32, "F", "celsius"), 1) == 0.0
|
||||
|
||||
|
||||
def test_nws_convert_temp_no_op_when_units_already_match():
|
||||
assert nws._convert_temp(75, "F", "fahrenheit") == 75
|
||||
|
||||
|
||||
def _points_response():
|
||||
return _FakeResponse({"properties": {
|
||||
"forecast": "https://api.weather.gov/gridpoints/PQR/1,1/forecast",
|
||||
"forecastHourly": "https://api.weather.gov/gridpoints/PQR/1,1/forecast/hourly",
|
||||
}})
|
||||
|
||||
|
||||
def _hourly_periods_response(n=6):
|
||||
return _FakeResponse({"properties": {"periods": [
|
||||
{"startTime": f"2026-07-27T{h:02d}:00:00-07:00", "temperature": 60 + h, "temperatureUnit": "F",
|
||||
"shortForecast": "Sunny", "icon": "https://api.weather.gov/icons/land/day/skc?size=medium"}
|
||||
for h in range(n)
|
||||
]}})
|
||||
|
||||
|
||||
def test_nws_fetch_current_uses_first_hourly_period(monkeypatch):
|
||||
def _get(url, headers, timeout):
|
||||
if "/points/" in url:
|
||||
return _points_response()
|
||||
return _hourly_periods_response()
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
|
||||
result = nws.fetch_current(45.5, -122.6, "fahrenheit")
|
||||
assert result == {"temp": 60, "category": "clear"}
|
||||
|
||||
|
||||
def test_nws_fetch_hourly_respects_hours_limit(monkeypatch):
|
||||
def _get(url, headers, timeout):
|
||||
if "/points/" in url:
|
||||
return _points_response()
|
||||
return _hourly_periods_response(n=24)
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
|
||||
result = nws.fetch_hourly(45.5, -122.6, "fahrenheit", hours=4)
|
||||
assert len(result) == 4
|
||||
assert result[0]["category"] == "clear"
|
||||
|
||||
|
||||
def test_nws_fetch_daily_pairs_day_night_periods_by_date(monkeypatch):
|
||||
def _get(url, headers, timeout):
|
||||
if "/points/" in url:
|
||||
return _points_response()
|
||||
return _FakeResponse({"properties": {"periods": [
|
||||
{"startTime": "2026-07-27T06:00:00-07:00", "isDaytime": True, "temperature": 80,
|
||||
"temperatureUnit": "F", "shortForecast": "Sunny",
|
||||
"icon": "https://api.weather.gov/icons/land/day/skc?size=medium"},
|
||||
{"startTime": "2026-07-27T18:00:00-07:00", "isDaytime": False, "temperature": 60,
|
||||
"temperatureUnit": "F", "shortForecast": "Clear",
|
||||
"icon": "https://api.weather.gov/icons/land/night/skc?size=medium"},
|
||||
{"startTime": "2026-07-28T06:00:00-07:00", "isDaytime": True, "temperature": 75,
|
||||
"temperatureUnit": "F", "shortForecast": "Rain",
|
||||
"icon": "https://api.weather.gov/icons/land/day/rain,80?size=medium"},
|
||||
{"startTime": "2026-07-28T18:00:00-07:00", "isDaytime": False, "temperature": 55,
|
||||
"temperatureUnit": "F", "shortForecast": "Rain",
|
||||
"icon": "https://api.weather.gov/icons/land/night/rain,80?size=medium"},
|
||||
]}})
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
|
||||
result = nws.fetch_daily(45.5, -122.6, "fahrenheit", days=2)
|
||||
assert result == {
|
||||
"2026-07-27": {"high": 80, "low": 60, "category": "clear"},
|
||||
"2026-07-28": {"high": 75, "low": 55, "category": "rain"},
|
||||
}
|
||||
|
||||
|
||||
def test_nws_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch):
|
||||
"""Asking for more days than NWS actually returned degrades gracefully
|
||||
(fewer days), not an error -- same idiom as Open-Meteo's own clamp."""
|
||||
def _get(url, headers, timeout):
|
||||
if "/points/" in url:
|
||||
return _points_response()
|
||||
return _FakeResponse({"properties": {"periods": [
|
||||
{"startTime": "2026-07-27T06:00:00-07:00", "isDaytime": True, "temperature": 80,
|
||||
"temperatureUnit": "F", "shortForecast": "Sunny",
|
||||
"icon": "https://api.weather.gov/icons/land/day/skc?size=medium"},
|
||||
]}})
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
|
||||
result = nws.fetch_daily(45.5, -122.6, "fahrenheit", days=7)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_nws_raises_weather_fetch_error_when_points_lookup_fails(monkeypatch):
|
||||
def _raise(*a, **kw):
|
||||
raise httpx.ConnectError("boom")
|
||||
monkeypatch.setattr(httpx, "get", _raise)
|
||||
with pytest.raises(WeatherFetchError):
|
||||
nws.fetch_current(45.5, -122.6, "fahrenheit")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""app.weather_render -- pure-logic drawing helpers, no HTTP/DB.
|
||||
Mirrors calendar_render.py's own testing posture (this module has no
|
||||
dedicated test file there either, since it's exercised indirectly via
|
||||
test_widgets_calendar.py) but covers the non-obvious behavior worth
|
||||
pinning down directly: build_multi_city's label shortening, and that
|
||||
weather icons actually use the panel's ink colors (not flat black --
|
||||
caught via browser verification that a monochrome sun read as an
|
||||
unrecognizable crosshair glyph, not a sun)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from app import weather_render
|
||||
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
||||
|
||||
|
||||
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
|
||||
"""Cached entries carry the full disambiguated geocoder label (e.g.
|
||||
"Seattle, Washington, United States" -- see get_or_refresh_weather_
|
||||
widget_data/weather.geocode_city). Drawing the whole thing made a
|
||||
single city's row overflow past the widget's own width in practice
|
||||
(caught via browser verification) -- only the city name should reach
|
||||
draw_weather_row, same shortening calendar_render.py's
|
||||
_weather_for_day already does for its own embedded strip."""
|
||||
seen_labels = []
|
||||
real_draw_weather_row = weather_render.draw_weather_row
|
||||
|
||||
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True, palette_rgb=None):
|
||||
seen_labels.extend(e["label"] for e in entries)
|
||||
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels,
|
||||
palette_rgb)
|
||||
|
||||
monkeypatch.setattr(weather_render, "draw_weather_row", spy)
|
||||
|
||||
cities = [
|
||||
{"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"},
|
||||
{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"},
|
||||
]
|
||||
img = weather_render.build_multi_city(cities, 400, 150)
|
||||
assert img.size == (400, 150)
|
||||
assert seen_labels == ["Seattle", "Portland"]
|
||||
|
||||
|
||||
def test_build_multi_city_empty_list_returns_blank_canvas():
|
||||
img = weather_render.build_multi_city([], 400, 150)
|
||||
assert img.size == (400, 150)
|
||||
|
||||
|
||||
def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]:
|
||||
return {c for _, c in img.getcolors(maxcolors=100_000)}
|
||||
|
||||
|
||||
def test_draw_weather_icon_clear_uses_panel_yellow_not_flat_black():
|
||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear")
|
||||
colors = _colors_present(img)
|
||||
assert tuple(DEFAULT_PALETTE_RGB[2]) in colors # yellow
|
||||
assert (0, 0, 0) not in colors # no flat-black sun
|
||||
|
||||
|
||||
def test_draw_weather_icon_rain_uses_panel_blue():
|
||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "rain")
|
||||
assert tuple(DEFAULT_PALETTE_RGB[4]) in _colors_present(img) # blue raindrops
|
||||
|
||||
|
||||
def test_draw_weather_icon_thunderstorm_uses_panel_yellow():
|
||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "thunderstorm")
|
||||
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow bolt
|
||||
|
||||
|
||||
def test_draw_weather_icon_respects_a_custom_frame_palette():
|
||||
"""A frame with an Advanced-configuration palette override (see
|
||||
Frame.palette_rgb) should still get ITS actual yellow, not the
|
||||
hardcoded default -- same resolution rule as calendar_render.py's
|
||||
_event_colors."""
|
||||
custom_palette = [(0, 0, 0), (255, 255, 255), (10, 20, 30), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
|
||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear", palette_rgb=custom_palette)
|
||||
assert (10, 20, 30) in _colors_present(img)
|
||||
@@ -20,6 +20,7 @@ from app.models import (
|
||||
StaticWidgetConfig,
|
||||
TaskWidgetConfig,
|
||||
TextWidgetConfig,
|
||||
WeatherWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
|
||||
@@ -80,6 +81,18 @@ def _add_text_widget(db_session) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def _add_weather_widget(db_session, **cfg_kwargs) -> Widget:
|
||||
import time
|
||||
|
||||
widget = Widget(frame_id=1, widget_type="weather", x=0, y=0, w=2, h=2,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||
db_session.commit()
|
||||
return widget
|
||||
|
||||
|
||||
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
||||
@@ -150,6 +163,73 @@ def test_config_save_updates_a_tasks_widget(client, db_session):
|
||||
assert cfg.show_completed is True
|
||||
|
||||
|
||||
def test_config_save_updates_a_weather_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"weather_mode": "hourly", "weather_provider": "nws", "weather_units": "celsius",
|
||||
"weather_hourly_interval_hours": "6", "weather_daily_days": "3"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.mode == "hourly"
|
||||
assert cfg.provider == "nws"
|
||||
assert cfg.units == "celsius"
|
||||
assert cfg.hourly_interval_hours == 6
|
||||
assert cfg.daily_days == 3
|
||||
|
||||
|
||||
def test_config_save_ignores_an_invalid_weather_mode(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"weather_mode": "not_a_real_mode"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.mode == "current" # unchanged -- invalid value silently ignored, same as calendar_view's own validation
|
||||
|
||||
|
||||
def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db_session, monkeypatch):
|
||||
"""Regression test: a widget's `cached` shape depends on its mode (a
|
||||
single-temp dict for current, a list for hourly/multi_city, a dict
|
||||
for daily). Switching modes without clearing the old cache used to
|
||||
crash the very next preview -- get_or_refresh_weather_widget_data's
|
||||
multi_city branch tried `c["label"]` against a leftover "current"-
|
||||
mode dict, TypeError: string indices must be integers -- rather than
|
||||
just triggering a fresh fetch shaped for the new mode."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current", cached={"temp": 70.0, "category": "clear"},
|
||||
checked_at=1e15) # far in the future -- would still be "fresh" if not cleared
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/config",
|
||||
data={"weather_mode": "multi_city"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.cached is None
|
||||
|
||||
cfg.cities = [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
|
||||
db_session.commit()
|
||||
monkeypatch.setattr(
|
||||
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
|
||||
lambda db, frame, widget, force=False: [
|
||||
{"label": "Portland", "high": 75, "low": 55, "category": "clear"},
|
||||
],
|
||||
)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_tasks_widget(db_session)
|
||||
@@ -420,3 +500,130 @@ def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
|
||||
widget = _add_static_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- weather: location/cities/preview ---------------------------------
|
||||
|
||||
def _mock_geocode(monkeypatch, label="Portland, Oregon, United States", latitude=45.5, longitude=-122.6):
|
||||
monkeypatch.setattr(
|
||||
"app.routers.api_widgets.weather.geocode_city",
|
||||
lambda name: {"label": label, "latitude": latitude, "longitude": longitude},
|
||||
)
|
||||
|
||||
|
||||
def test_weather_location_set_and_clear(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current")
|
||||
_mock_geocode(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-location",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["city"]["label"] == "Portland, Oregon, United States"
|
||||
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.city_label == "Portland, Oregon, United States"
|
||||
assert cfg.city_latitude == 45.5
|
||||
assert cfg.city_longitude == -122.6
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-location",
|
||||
json={"name": None},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.city_label is None
|
||||
assert cfg.city_latitude is None
|
||||
|
||||
|
||||
def test_weather_location_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_calendar_widget(db_session)
|
||||
_mock_geocode(monkeypatch)
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-location",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_weather_widget_cities_add_and_remove(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="multi_city")
|
||||
_mock_geocode(monkeypatch)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.cities == [{"label": "Portland, Oregon, United States", "latitude": 45.5, "longitude": -122.6}]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400 # already on the list
|
||||
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/remove",
|
||||
json={"label": "Portland, Oregon, United States"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
cfg = db_session.get(WeatherWidgetConfig, widget.id)
|
||||
assert cfg.cities == []
|
||||
|
||||
|
||||
def test_weather_widget_cities_400s_for_a_widget_that_is_not_weather(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_calendar_widget(db_session)
|
||||
_mock_geocode(monkeypatch)
|
||||
resp = client.post(
|
||||
f"/api/frames/1/widgets/{widget.id}/weather-widget-cities/add",
|
||||
json={"name": "Portland, OR"},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_weather_400s_before_anything_is_configured(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current")
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_weather_400s_before_any_city_for_multi_city_mode(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="multi_city")
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_preview_weather_renders_after_location_is_set(client, db_session, monkeypatch):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_weather_widget(db_session, mode="current")
|
||||
monkeypatch.setattr(
|
||||
"app.routers.api_widgets.get_or_refresh_weather_widget_data",
|
||||
lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_preview_weather_400s_for_a_widget_that_is_not_weather(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget = _add_static_widget(db_session)
|
||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather")
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""app.widgets.weather -- unit-level, no HTTP: constructs Widget/
|
||||
WeatherWidgetConfig rows directly and monkeypatches the underlying fetch
|
||||
call (get_or_refresh_weather_widget_data, whose own throttle/provider-
|
||||
dispatch logic is covered by test_weather_providers.py and exercised at
|
||||
the HTTP layer in test_widget_config_and_queue_endpoints.py) -- this file
|
||||
is about the widget wiring itself: does render() produce a correctly-
|
||||
sized image for each of the four modes, does it fall back to a
|
||||
placeholder when unconfigured, does check_now force a refetch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app import grid, widgets
|
||||
from app.models import Frame, WeatherWidgetConfig, Widget
|
||||
|
||||
|
||||
def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]:
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget = Widget(frame_id=frame.id, widget_type="weather", x=0, y=0, w=2, h=2,
|
||||
sort_order=0, created_at=time.time())
|
||||
db_session.add(widget)
|
||||
db_session.flush()
|
||||
db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs))
|
||||
db_session.commit()
|
||||
return frame, widget
|
||||
|
||||
|
||||
def test_render_falls_back_to_placeholder_when_not_configured(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, mode="current")
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: None)
|
||||
|
||||
img = widgets.weather.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_current_mode(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, mode="current", city_label="Portland")
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
|
||||
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
|
||||
|
||||
img = widgets.weather.render(db_session, frame, widget, 300, 200)
|
||||
assert img.size == (300, 200)
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_render_hourly_mode(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, mode="hourly", city_label="Seattle", hourly_interval_hours=4)
|
||||
hourly = [{"time": f"2026-07-27T{h:02d}:00", "temp": 60 + h, "category": "rain"} for h in range(24)]
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: hourly)
|
||||
|
||||
img = widgets.weather.render(db_session, frame, widget, 400, 300)
|
||||
assert img.size == (400, 300)
|
||||
|
||||
|
||||
def test_render_daily_mode(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5)
|
||||
daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)}
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily)
|
||||
|
||||
img = widgets.weather.render(db_session, frame, widget, 400, 300)
|
||||
assert img.size == (400, 300)
|
||||
|
||||
|
||||
def test_render_multi_city_mode(db_session, monkeypatch):
|
||||
"""cached entries carry the full disambiguated geocoder label (see
|
||||
get_or_refresh_weather_widget_data/weather.geocode_city) -- render()
|
||||
must still produce a correctly-sized image (weather_render.
|
||||
build_multi_city shortens to just the city name for display, same as
|
||||
calendar_render.py's _weather_for_day)."""
|
||||
frame, widget = _make_widget(db_session, mode="multi_city")
|
||||
cities = [
|
||||
{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"},
|
||||
{"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"},
|
||||
]
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities)
|
||||
|
||||
img = widgets.weather.render(db_session, frame, widget, 400, 300)
|
||||
assert img.size == (400, 300)
|
||||
|
||||
|
||||
def test_render_at_minimum_grid_footprint(db_session, monkeypatch):
|
||||
"""grid.MIN_FOOTPRINT["weather"] is (2, 2) cells -- on an 8x5 grid
|
||||
against a full 800x480 panel that's a 200x192 box, the smallest a
|
||||
weather widget can actually be placed at."""
|
||||
assert grid.MIN_FOOTPRINT["weather"] == (2, 2)
|
||||
frame, widget = _make_widget(db_session, mode="current")
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data",
|
||||
lambda db, frame, widget: {"temp": 72.0, "category": "clear"})
|
||||
|
||||
img = widgets.weather.render(db_session, frame, widget, 200, 192)
|
||||
assert img.size == (200, 192)
|
||||
|
||||
|
||||
def test_check_now_forces_a_refetch(db_session, monkeypatch):
|
||||
frame, widget = _make_widget(db_session, mode="current")
|
||||
calls = []
|
||||
|
||||
def _fake_refresh(db, frame, widget, force=False):
|
||||
calls.append(force)
|
||||
return {"temp": 70.0, "category": "clear"}
|
||||
|
||||
monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", _fake_refresh)
|
||||
widgets.weather.ACTIONS["check_now"](db_session, frame, widget)
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_action_labels():
|
||||
assert set(widgets.weather.ACTIONS.keys()) == {"check_now"}
|
||||
assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"}
|
||||
Reference in New Issue
Block a user