Screen.poll_interval was a fixed class attribute the scheduler read directly -- added Screen.get_poll_interval(settings) so a screen can make it settings-driven instead. WeatherScreen overrides it to read a new "update_minutes" field (default 30, matching the old hardcoded value). New number input on the Weather settings page. Caught a real edge case while testing: `raw or 30` treats an explicit 0 as falsy and silently replaces it with 30 instead of clamping to the 1-minute floor. Not reachable via the UI (min="1" on the input) but the method should still be correct standalone -- fixed with an explicit None-check instead.
206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""Current weather + daily forecast via Open-Meteo (free, no API key)."""
|
|
|
|
import logging
|
|
from datetime import date
|
|
|
|
import requests
|
|
from PIL import Image, ImageDraw
|
|
|
|
from screens.base import Screen
|
|
from screens.render_utils import load_font, text_width
|
|
from screens.weather_icons import WMO_CODES, draw_weather_icon
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
HTTP_TIMEOUT = 10
|
|
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
|
FORECAST_DAY_CHOICES = (3, 5, 7)
|
|
|
|
WEEKDAY_ABBR_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
|
|
|
|
|
class WeatherScreen(Screen):
|
|
id = "weather"
|
|
display_name = "Weather"
|
|
poll_interval = 1800 # fallback default (30 min) if settings has no update_minutes yet
|
|
|
|
def default_settings(self) -> dict:
|
|
return {"city": "", "latitude": None, "longitude": None, "forecast_days": 3, "update_minutes": 30}
|
|
|
|
def get_poll_interval(self, settings: dict) -> int:
|
|
raw = settings.get("update_minutes")
|
|
try:
|
|
# `raw or 30` would also catch a legitimate 0 (falsy) and silently
|
|
# replace it with 30 instead of clamping to the 1-minute floor
|
|
# below -- only fall back to the default when it's genuinely unset.
|
|
minutes = int(raw) if raw is not None else 30
|
|
except (TypeError, ValueError):
|
|
minutes = 30
|
|
return max(1, minutes) * 60
|
|
|
|
def geocode(self, city: str):
|
|
"""Resolve a city name to coordinates via Open-Meteo's free geocoding
|
|
API (no key needed). Returns {"latitude", "longitude", "label"} or
|
|
None if nothing matched."""
|
|
response = requests.get(
|
|
GEOCODE_URL,
|
|
params={"name": city, "count": 1, "language": "de"},
|
|
timeout=HTTP_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
results = response.json().get("results") or []
|
|
if not results:
|
|
return None
|
|
|
|
top = results[0]
|
|
label_parts = [top.get("name"), top.get("admin1"), top.get("country")]
|
|
return {
|
|
"latitude": top["latitude"],
|
|
"longitude": top["longitude"],
|
|
"label": ", ".join(p for p in label_parts if p),
|
|
}
|
|
|
|
def check(self, settings: dict):
|
|
lat = settings.get("latitude")
|
|
lon = settings.get("longitude")
|
|
if lat is None or lon is None:
|
|
logger.warning("Weather screen has no latitude/longitude configured")
|
|
return None
|
|
|
|
forecast_days = settings.get("forecast_days", 3)
|
|
if forecast_days not in FORECAST_DAY_CHOICES:
|
|
forecast_days = 3
|
|
|
|
response = requests.get(
|
|
"https://api.open-meteo.com/v1/forecast",
|
|
params={
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"current_weather": "true",
|
|
"daily": "weather_code,temperature_2m_max,temperature_2m_min",
|
|
"forecast_days": forecast_days,
|
|
"timezone": "auto",
|
|
},
|
|
timeout=HTTP_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
|
|
current = payload.get("current_weather") or {}
|
|
if "temperature" not in current:
|
|
return None
|
|
|
|
code = int(current.get("weathercode", -1))
|
|
description, kind = WMO_CODES.get(code, ("Unbekannt", "clear"))
|
|
|
|
days = []
|
|
daily = payload.get("daily") or {}
|
|
for i, date_str in enumerate(daily.get("time", [])):
|
|
try:
|
|
weekday = date.fromisoformat(date_str).weekday()
|
|
except ValueError:
|
|
continue
|
|
day_code = int(daily["weather_code"][i])
|
|
_, day_kind = WMO_CODES.get(day_code, ("Unbekannt", "clear"))
|
|
days.append({
|
|
"label": WEEKDAY_ABBR_DE[weekday],
|
|
"kind": day_kind,
|
|
"high": daily["temperature_2m_max"][i],
|
|
"low": daily["temperature_2m_min"][i],
|
|
})
|
|
|
|
return {
|
|
"temperature": current["temperature"],
|
|
"windspeed": current.get("windspeed"),
|
|
"description": description,
|
|
"kind": kind,
|
|
"days": days,
|
|
}
|
|
|
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
|
width, height = resolution
|
|
image = Image.new("RGB", (width, height), "white")
|
|
draw = ImageDraw.Draw(image)
|
|
scale = min(width, height)
|
|
|
|
if not state:
|
|
font = load_font(size=int(scale * 0.06))
|
|
msg = "Kein Standort konfiguriert" if not (
|
|
settings.get("latitude") and settings.get("longitude")
|
|
) else "Wetterdaten nicht verfügbar"
|
|
mw = text_width(draw, msg, font)
|
|
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(120, 120, 120))
|
|
return image
|
|
|
|
days = state.get("days") or []
|
|
has_forecast = bool(days)
|
|
|
|
# Icon + temperature + description, stacked. Sized from `scale`
|
|
# directly (not a guessed fraction of current_h) and measured via
|
|
# actual text bounding boxes, so current_h can be *derived* from
|
|
# what the block actually needs -- rather than picking current_h
|
|
# first and hoping the content fits in it, which overflowed into
|
|
# the forecast row for some font metrics.
|
|
icon_size = scale * (0.24 if has_forecast else 0.5)
|
|
temp_font = load_font(size=int(scale * (0.13 if has_forecast else 0.22)), bold=True)
|
|
desc_font = load_font(size=int(scale * (0.045 if has_forecast else 0.05)))
|
|
|
|
temp_str = f"{round(state['temperature'])}°"
|
|
desc = state["description"]
|
|
temp_left, temp_top, temp_right, temp_bottom = draw.textbbox((0, 0), temp_str, font=temp_font)
|
|
temp_h = temp_bottom - temp_top
|
|
desc_left, desc_top, desc_right, desc_bottom = draw.textbbox((0, 0), desc, font=desc_font)
|
|
desc_h = desc_bottom - desc_top
|
|
|
|
icon_gap = icon_size * 0.12
|
|
temp_gap = temp_h * 0.4
|
|
block_h = icon_size + icon_gap + temp_h + temp_gap + desc_h
|
|
|
|
if has_forecast:
|
|
top_pad = height * 0.04
|
|
bottom_pad = height * 0.035
|
|
current_h = block_h + top_pad + bottom_pad
|
|
block_top = top_pad
|
|
else:
|
|
current_h = height
|
|
block_top = (current_h - block_h) / 2
|
|
|
|
icon_cy = block_top + icon_size / 2
|
|
draw_weather_icon(draw, state["kind"], width / 2, icon_cy, icon_size)
|
|
|
|
temp_y = block_top + icon_size + icon_gap
|
|
tw = temp_right - temp_left
|
|
draw.text(((width - tw) / 2, temp_y - temp_top), temp_str, font=temp_font, fill="black")
|
|
|
|
desc_y = temp_y + temp_h + temp_gap
|
|
dw = desc_right - desc_left
|
|
draw.text(((width - dw) / 2, desc_y - desc_top), desc, font=desc_font, fill=(90, 90, 90))
|
|
|
|
if has_forecast:
|
|
forecast_y = current_h
|
|
forecast_h = height - forecast_y
|
|
draw.rectangle((width * 0.06, forecast_y, width * 0.94, forecast_y + 2), fill=(0, 0, 0))
|
|
|
|
col_w = width / len(days)
|
|
label_font = load_font(size=int(scale * 0.052), bold=True)
|
|
day_temp_font = load_font(size=int(scale * 0.046), bold=True)
|
|
day_icon_size = min(col_w * 0.75, forecast_h * 0.48)
|
|
|
|
for i, day in enumerate(days):
|
|
cx = col_w * i + col_w / 2
|
|
y = forecast_y + forecast_h * 0.1
|
|
|
|
label = day["label"]
|
|
lw = text_width(draw, label, label_font)
|
|
draw.text((cx - lw / 2, y), label, font=label_font, fill=(60, 60, 60))
|
|
y += label_font.size * 1.4
|
|
|
|
draw_weather_icon(draw, day["kind"], cx, y + day_icon_size / 2, day_icon_size)
|
|
y += day_icon_size * 1.1
|
|
|
|
temps = f"{round(day['high'])}°/{round(day['low'])}°"
|
|
dtw = text_width(draw, temps, day_temp_font)
|
|
draw.text((cx - dtw / 2, y), temps, font=day_temp_font, fill=(50, 50, 50))
|
|
|
|
return image
|