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
|
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
|
self-hosted FastAPI server (`server/`) that pulls from Immich, does all
|
||||||
image processing (crop/dither/quantize/pack), and serves a placeable
|
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
|
CURRENT TODO
|
||||||
-add more actions for buttons (i.e. change widget/layout)
|
-add more actions for buttons (i.e. change widget/layout)
|
||||||
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
-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
|
-widget border option
|
||||||
-battery life widget
|
-battery life widget
|
||||||
-sharing layouts with linked users
|
-sharing layouts with linked users
|
||||||
|
|||||||
+72
-11
@@ -1,10 +1,10 @@
|
|||||||
# Widget system
|
# Widget system
|
||||||
|
|
||||||
A frame's panel isn't one fixed "mode" anymore -- it holds N independently
|
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
|
placed/sized widgets (photos/calendar/whiteboard/tasks/static image/text/
|
||||||
arranging icons on an Android home screen. A frame can hold several widgets of the
|
weather), like arranging icons on an Android home screen. A frame can hold
|
||||||
same type (e.g. two photo widgets pointed at different Immich albums side
|
several widgets of the same type (e.g. two photo widgets pointed at
|
||||||
by side).
|
different Immich albums side by side).
|
||||||
This replaced an earlier design where `Frame.mode` picked exactly one
|
This replaced an earlier design where `Frame.mode` picked exactly one
|
||||||
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
full-panel renderer; that column (and the other now-dead per-mode `Frame`
|
||||||
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
columns it left behind -- `album_id`, `calendar_*`, `whiteboard_*`, etc.)
|
||||||
@@ -20,14 +20,16 @@ a button press does.
|
|||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
- `Widget` (`server/app/models.py`): `id`, `frame_id`, `widget_type`
|
- `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
|
(grid cells), `sort_order`. Widgets never overlap (enforced server-side in
|
||||||
`routers/api_widgets.py`, re-validated regardless of what the client
|
`routers/api_widgets.py`, re-validated regardless of what the client
|
||||||
already checked) -- that's what keeps compositing simple: no z-order,
|
already checked) -- that's what keeps compositing simple: no z-order,
|
||||||
no blending, just N independent regions pasted onto one shared canvas.
|
no blending, just N independent regions pasted onto one shared canvas.
|
||||||
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
- Per-type 1:1 extension tables -- `PhotoWidgetConfig`,
|
||||||
`CalendarWidgetConfig`, `WhiteboardWidgetConfig`, `TaskWidgetConfig`,
|
`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
|
`ondelete="CASCADE"` -- rather than one wide table with every type's
|
||||||
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
mostly-irrelevant columns. `TextWidgetConfig.content` is parsed rich
|
||||||
text (paragraphs of styled runs), never raw HTML -- see
|
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
|
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
|
and sized independent of any calendar's view/footprint, then (migration
|
||||||
18) given the same multi-source shape a calendar widget already has.
|
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
|
- `FrameCalendar`/`FrameTaskList` are keyed by `widget_id` (not
|
||||||
`frame_id`) since a frame can now have more than one independent
|
`frame_id`) since a frame can now have more than one independent
|
||||||
calendar/tasks widget, each with its own included set. Identical
|
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`):
|
Each widget type has a minimum grid footprint (`grid.MIN_FOOTPRINT`):
|
||||||
photos 1x1, calendar 3x2 (a crammed calendar is illegible regardless of
|
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
|
Enforced both client-side
|
||||||
(UX, in the Layout tab's drag/resize canvas -- `static/frame_layout.js`)
|
(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
|
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
|
`app/widgets/` is the render/action registry -- one module per
|
||||||
`widget_type` (`photos.py`, `calendar.py`, `whiteboard.py`, `tasks.py`,
|
`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`:
|
- `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
|
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.
|
bad moment doesn't blank the whole panel.
|
||||||
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
- `ACTIONS: dict[str, Callable]` -- named button actions this type
|
||||||
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
supports (`"advance"`/`"back"` for photos and calendar, `"check_now"`
|
||||||
for whiteboard). Empty for tasks, static image, and text -- nothing to
|
for whiteboard and weather -- both throttled external fetches with a
|
||||||
advance/back/force for a passive checklist, a fixed uploaded image, or
|
forced-refetch action). Empty for tasks, static image, and text --
|
||||||
a fixed block of authored 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-
|
- `ACTION_LABELS: dict[str, str]` -- human labels for the button-
|
||||||
assignment UI.
|
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
|
saved layouts each with Apply/rename/delete, incompatible ones shown
|
||||||
greyed-out with a "different orientation" badge rather than hidden.
|
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)
|
## Known gaps (Phase 6, not yet done)
|
||||||
|
|
||||||
The original 8-phase rollout plan's last phase is still open:
|
The original 8-phase rollout plan's last phase is still open:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from .image_pipeline import (
|
|||||||
logical_render_size,
|
logical_render_size,
|
||||||
)
|
)
|
||||||
from .weather import weather_category
|
from .weather import weather_category
|
||||||
|
from .weather_render import draw_weather_row
|
||||||
|
|
||||||
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
|
||||||
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
|
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
|
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,
|
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,
|
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
||||||
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
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)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
y += _draw_weather_row(img, draw, text_x0, y, text_w, 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)
|
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)
|
day_events = _events_on_day(events, day, tz)
|
||||||
row_h = body_font.size + 14
|
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
|
y = MARGIN + header_h
|
||||||
# Columns are narrow, so only what actually fits gets drawn (see
|
# Columns are narrow, so only what actually fits gets drawn (see
|
||||||
# _draw_weather_row) -- typically one city, no label (the column
|
# weather_render.draw_weather_row) -- typically one city, no label
|
||||||
# itself makes which day it's for obvious; a city name wouldn't fit
|
# (the column itself makes which day it's for obvious; a city name
|
||||||
# anyway). Never more than that -- this is already the tight view.
|
# wouldn't fit anyway). Never more than that -- this is already
|
||||||
|
# the tight view.
|
||||||
weather_entries = _weather_for_day(weather_cities, day)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, 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)
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
||||||
|
palette_rgb=palette_rgb)
|
||||||
row_h = chip_font.size + 10
|
row_h = chip_font.size + 10
|
||||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
||||||
day_events = _events_on_day(events, day, tz)
|
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
|
# 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
|
# 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
|
# 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]] = {
|
MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
||||||
"photos": (1, 1),
|
"photos": (1, 1),
|
||||||
"calendar": (3, 2),
|
"calendar": (3, 2),
|
||||||
@@ -32,6 +34,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = {
|
|||||||
"tasks": (2, 2),
|
"tasks": (2, 2),
|
||||||
"static": (1, 1),
|
"static": (1, 1),
|
||||||
"text": (2, 1),
|
"text": (2, 1),
|
||||||
|
"weather": (2, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
Rect = tuple[int, int, int, int] # (x, y, w, h)
|
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 = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
(2, _migration_2),
|
(2, _migration_2),
|
||||||
@@ -648,6 +680,7 @@ MIGRATIONS = [
|
|||||||
(21, _migration_21),
|
(21, _migration_21),
|
||||||
(22, _migration_22),
|
(22, _migration_22),
|
||||||
(23, _migration_23),
|
(23, _migration_23),
|
||||||
|
(24, _migration_24),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -433,7 +433,7 @@ class Widget(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
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)
|
x: Mapped[int] = mapped_column(Integer)
|
||||||
y: Mapped[int] = mapped_column(Integer)
|
y: Mapped[int] = mapped_column(Integer)
|
||||||
w: 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)
|
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):
|
class TextWidgetConfig(Base):
|
||||||
"""One text widget's authored content + display settings -- another
|
"""One text widget's authored content + display settings -- another
|
||||||
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
no-live-upstream type like StaticWidgetConfig, just parsed rich text
|
||||||
@@ -618,6 +653,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
|||||||
"tasks": TaskWidgetConfig,
|
"tasks": TaskWidgetConfig,
|
||||||
"static": StaticWidgetConfig,
|
"static": StaticWidgetConfig,
|
||||||
"text": TextWidgetConfig,
|
"text": TextWidgetConfig,
|
||||||
|
"weather": WeatherWidgetConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db, widget_locked
|
from ..db import frame_locked, get_db, widget_locked
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import (
|
||||||
@@ -47,6 +47,7 @@ from ..models import (
|
|||||||
StaticWidgetConfig,
|
StaticWidgetConfig,
|
||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
TextWidgetConfig,
|
TextWidgetConfig,
|
||||||
|
WeatherWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
WIDGET_CONFIG_MODELS,
|
WIDGET_CONFIG_MODELS,
|
||||||
Widget,
|
Widget,
|
||||||
@@ -60,6 +61,7 @@ from .common import (
|
|||||||
get_or_refresh_calendar_events_for_widget,
|
get_or_refresh_calendar_events_for_widget,
|
||||||
get_or_refresh_tasks_for_widget,
|
get_or_refresh_tasks_for_widget,
|
||||||
get_or_refresh_weather_for_widget,
|
get_or_refresh_weather_for_widget,
|
||||||
|
get_or_refresh_weather_widget_data,
|
||||||
get_or_refresh_whiteboard_for_widget,
|
get_or_refresh_whiteboard_for_widget,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
@@ -283,6 +285,12 @@ def api_widget_config_save(
|
|||||||
text_font_family: str | None = Form(None),
|
text_font_family: str | None = Form(None),
|
||||||
text_align: str | None = Form(None),
|
text_align: str | None = Form(None),
|
||||||
text_background_color: 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
|
"""Every field optional -- same partial-update, form-urlencoded
|
||||||
convention as the old frame-level api_config_save, now scoped to one
|
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 = (
|
xcfg.background_color = (
|
||||||
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
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:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
cfg.stats_config_saves += 1
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
@@ -833,6 +871,124 @@ def api_widget_weather_city_remove(
|
|||||||
return {"status": "saved"}
|
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 -----------------------------------------
|
# --- Static image: upload/preview -----------------------------------------
|
||||||
|
|
||||||
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload")
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from ..models import (
|
|||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
User,
|
User,
|
||||||
Widget,
|
Widget,
|
||||||
|
WeatherWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -574,6 +575,69 @@ def get_or_refresh_weather_for_widget(db: Session, frame: Frame, widget: Widget)
|
|||||||
return result
|
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
|
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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import weather
|
||||||
from ..auth import can_view_frame, current_user
|
from ..auth import can_view_frame, current_user
|
||||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
@@ -38,6 +39,7 @@ from ..models import (
|
|||||||
TextWidgetConfig,
|
TextWidgetConfig,
|
||||||
User,
|
User,
|
||||||
UserFrame,
|
UserFrame,
|
||||||
|
WeatherWidgetConfig,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
Widget,
|
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,
|
"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}")
|
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
||||||
const WIDGET_LABELS = {
|
const WIDGET_LABELS = {
|
||||||
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
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) {
|
function showStatus(ok, message) {
|
||||||
|
|||||||
@@ -286,11 +286,11 @@ window.addEventListener('resize', () => {
|
|||||||
// icon was clicked).
|
// icon was clicked).
|
||||||
const DIALOG_INIT = {
|
const DIALOG_INIT = {
|
||||||
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog,
|
||||||
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog,
|
tasks: initTasksDialog, static: initStaticDialog, text: initTextDialog, weather: initWeatherDialog,
|
||||||
};
|
};
|
||||||
const DIALOG_CLOSE = {
|
const DIALOG_CLOSE = {
|
||||||
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog,
|
||||||
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog,
|
tasks: closeTasksDialog, static: closeStaticDialog, text: closeTextDialog, weather: closeWeatherDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
let openDialogWidgetType = null;
|
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_tasks.js"></script>
|
||||||
<script src="/static/widget_dialog_static.js"></script>
|
<script src="/static/widget_dialog_static.js"></script>
|
||||||
<script src="/static/widget_dialog_text.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/frame_layout.js"></script>
|
||||||
<script src="/static/saved_layouts.js"></script>
|
<script src="/static/saved_layouts.js"></script>
|
||||||
{% endblock %}
|
{% 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
|
"""Open-Meteo provider (see app/weather/__init__.py's PROVIDERS registry)
|
||||||
views only -- never month, there's no room, see calendar_render.py's
|
-- free geocoding + forecast APIs, no API key, no signup, no per-request
|
||||||
_BUILDERS). A frame can list multiple cities; each is geocoded once via
|
quota to manage. Backs both the calendar widget's embedded weather strip
|
||||||
Open-Meteo's free geocoding API (no API key, no signup, no per-request
|
(fetch_daily_forecast/weather_category, its original shape, untouched)
|
||||||
quota to manage) when added from the Calendar tab, then its daily
|
and the standalone weather widget's per-mode fetches below (fetch_current/
|
||||||
forecast is refreshed on its own throttle -- same shape idiom as
|
fetch_hourly/fetch_daily, which return already-normalized {"category":
|
||||||
calendar_feed.py's merge-fetch cache.
|
...} entries from the shared category set instead of a raw WMO code).
|
||||||
|
|
||||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||||
philosophy as calendar_feed.py/caldav_client.py.
|
philosophy as calendar_feed.py/caldav_client.py.
|
||||||
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from . import WeatherFetchError
|
||||||
|
|
||||||
HTTP_TIMEOUT_S = 15.0
|
HTTP_TIMEOUT_S = 15.0
|
||||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
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:
|
def weather_category(code: int) -> str:
|
||||||
"""Falls back to "cloudy" for any WMO code Open-Meteo might add later
|
"""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
|
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:
|
except (httpx.HTTPError, KeyError) as e:
|
||||||
raise WeatherFetchError(str(e)) from 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 __future__ import annotations
|
||||||
|
|
||||||
from . import calendar, photos, static_image, tasks, text, whiteboard
|
from . import calendar, photos, static_image, tasks, text, weather, whiteboard
|
||||||
|
|
||||||
WIDGET_TYPES = {
|
WIDGET_TYPES = {
|
||||||
"photos": photos,
|
"photos": photos,
|
||||||
@@ -45,4 +45,5 @@ WIDGET_TYPES = {
|
|||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"static": static_image,
|
"static": static_image,
|
||||||
"text": text,
|
"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
|
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")}
|
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
|
||||||
assert "font_family" in text_widget_columns # migration 22
|
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) ---
|
# --- 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."""
|
migrate."""
|
||||||
with db_module.engine.begin() as conn:
|
with db_module.engine.begin() as conn:
|
||||||
# static_widget_configs/text_widget_configs are migration 20/21
|
# static_widget_configs/text_widget_configs are migration 20/21
|
||||||
# tables, and saved_layouts/saved_layout_widgets/saved_layout_
|
# tables, saved_layouts/saved_layout_widgets/saved_layout_
|
||||||
# sources/saved_layout_button_actions are migration 23's (all
|
# sources/saved_layout_button_actions are migration 23's, and
|
||||||
# post-16, like the rest of this list) -- dropped here too so a
|
# weather_widget_configs is migration 24's (all post-16, like the
|
||||||
# real version-15 database is what's actually being simulated,
|
# rest of this list) -- dropped here too so a real version-15
|
||||||
# not "version 15 plus tables that wouldn't exist yet". Harmless
|
# database is what's actually being simulated, not "version 15
|
||||||
# to omit as long as no migration after the one that creates a
|
# plus tables that wouldn't exist yet". Harmless to omit as long
|
||||||
# table also ALTERs it (that's what let static_widget_configs go
|
# as no migration after the one that creates a table also ALTERs
|
||||||
# unlisted safely so far -- Base.metadata.create_all is
|
# or re-CREATEs it (that's what let a create_all-based migration
|
||||||
# idempotent against an already-present table with no later
|
# go unlisted safely so far), but static_widget_configs/
|
||||||
# ALTER to collide with), but text_widget_configs' migration 22
|
# text_widget_configs/weather_widget_configs all use a raw
|
||||||
# ALTER makes the gap a real "table already exists"/"duplicate
|
# CREATE TABLE (not create_all -- see migration 20's own
|
||||||
# column" collision instead of a silent no-op.
|
# 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",
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||||
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
"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(f"DROP TABLE {table}"))
|
||||||
conn.execute(text("DROP TABLE frame_calendars"))
|
conn.execute(text("DROP TABLE frame_calendars"))
|
||||||
conn.execute(text(
|
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/
|
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
||||||
calendar_key columns either."""
|
calendar_key columns either."""
|
||||||
with db_module.engine.begin() as conn:
|
with db_module.engine.begin() as conn:
|
||||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
# static_widget_configs/text_widget_configs/saved_layout_*/
|
||||||
# too -- see the comment on the identical setup in
|
# 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
|
# 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
|
# 20/21/23/24's raw CREATE TABLE collides with an already-present table
|
||||||
# otherwise, since this test replays 17 through 23 and none of
|
# otherwise, since this test replays 17 through 24 and none of
|
||||||
# these tables would really exist yet at a genuine pre-migration-17
|
# these tables would really exist yet at a genuine pre-migration-17
|
||||||
# schema_version).
|
# schema_version).
|
||||||
conn.execute(text("DROP TABLE task_widget_configs"))
|
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_sources"))
|
||||||
conn.execute(text("DROP TABLE saved_layout_widgets"))
|
conn.execute(text("DROP TABLE saved_layout_widgets"))
|
||||||
conn.execute(text("DROP TABLE saved_layouts"))
|
conn.execute(text("DROP TABLE saved_layouts"))
|
||||||
|
conn.execute(text("DROP TABLE weather_widget_configs"))
|
||||||
conn.execute(text(
|
conn.execute(text(
|
||||||
"CREATE TABLE calendar_widget_configs ("
|
"CREATE TABLE calendar_widget_configs ("
|
||||||
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
"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
|
the widget backfill, not as a numbered migration racing ahead of
|
||||||
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
||||||
with db_module.engine.begin() as conn:
|
with db_module.engine.begin() as conn:
|
||||||
# static_widget_configs/text_widget_configs/saved_layout_* dropped
|
# static_widget_configs/text_widget_configs/saved_layout_*/
|
||||||
# too -- see the comment on the identical setup in
|
# 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.
|
# 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",
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
||||||
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
||||||
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
"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(f"DROP TABLE {table}"))
|
||||||
conn.execute(text("DROP TABLE frame_calendars"))
|
conn.execute(text("DROP TABLE frame_calendars"))
|
||||||
conn.execute(text(
|
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,
|
StaticWidgetConfig,
|
||||||
TaskWidgetConfig,
|
TaskWidgetConfig,
|
||||||
TextWidgetConfig,
|
TextWidgetConfig,
|
||||||
|
WeatherWidgetConfig,
|
||||||
Widget,
|
Widget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,6 +81,18 @@ def _add_text_widget(db_session) -> Widget:
|
|||||||
return 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:
|
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
Image.new("RGB", size, color).save(buf, format="PNG")
|
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
|
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):
|
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
|
||||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
widget = _add_tasks_widget(db_session)
|
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)
|
widget = _add_static_widget(db_session)
|
||||||
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
|
||||||
assert resp.status_code == 400
|
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