- scheduler: "Show X now" was a true one-shot -- reverted on the very next 5s tick, causing the flash-to-X-then-flash-back-to-clock behavior. Now pins for 5 minutes (still preemptable by Navidrome priority, which stays authoritative). Added a "Resume automatic" action/button and status display showing what's pinned and until when. - weather: daily forecast strip (3/5/7 days, selectable in settings) below current conditions, via Open-Meteo's daily= params in the same request. Fixed a bad symbol choice along the way -- DejaVu Sans has no glyph for the partly-cloudy emoji, was rendering as a tofu box; swapped to the plain cloud symbol, verified glyph coverage for the whole WMO code table.
199 lines
7.3 KiB
Python
199 lines
7.3 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
|
|
|
|
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"]
|
|
|
|
# WMO weather codes -> (German description, symbol). Not exhaustive, covers
|
|
# the common cases; unmapped codes fall back to a generic description.
|
|
WMO_CODES = {
|
|
0: ("Klarer Himmel", "☀"),
|
|
1: ("Überwiegend klar", "☀"),
|
|
2: ("Teilweise bewölkt", "☁"), # DejaVu Sans has no ⛅ glyph, falls back to a tofu box
|
|
3: ("Bedeckt", "☁"),
|
|
45: ("Nebel", "☁"),
|
|
48: ("Nebel", "☁"),
|
|
51: ("Leichter Nieselregen", "☂"),
|
|
53: ("Nieselregen", "☂"),
|
|
55: ("Starker Nieselregen", "☂"),
|
|
61: ("Leichter Regen", "☔"),
|
|
63: ("Regen", "☔"),
|
|
65: ("Starker Regen", "☔"),
|
|
71: ("Leichter Schneefall", "❄"),
|
|
73: ("Schneefall", "❄"),
|
|
75: ("Starker Schneefall", "❄"),
|
|
80: ("Regenschauer", "☔"),
|
|
81: ("Regenschauer", "☔"),
|
|
82: ("Heftige Regenschauer", "☔"),
|
|
85: ("Schneeschauer", "❄"),
|
|
86: ("Schneeschauer", "❄"),
|
|
95: ("Gewitter", "⚡"),
|
|
96: ("Gewitter mit Hagel", "⚡"),
|
|
99: ("Gewitter mit Hagel", "⚡"),
|
|
}
|
|
|
|
|
|
class WeatherScreen(Screen):
|
|
id = "weather"
|
|
display_name = "Weather"
|
|
poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher
|
|
|
|
def default_settings(self) -> dict:
|
|
return {"city": "", "latitude": None, "longitude": None, "forecast_days": 3}
|
|
|
|
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, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
|
|
|
|
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_symbol = WMO_CODES.get(day_code, ("Unbekannt", "?"))
|
|
days.append({
|
|
"label": WEEKDAY_ABBR_DE[weekday],
|
|
"symbol": day_symbol,
|
|
"high": daily["temperature_2m_max"][i],
|
|
"low": daily["temperature_2m_min"][i],
|
|
})
|
|
|
|
return {
|
|
"temperature": current["temperature"],
|
|
"windspeed": current.get("windspeed"),
|
|
"description": description,
|
|
"symbol": symbol,
|
|
"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)
|
|
current_h = height * (0.56 if has_forecast else 1.0)
|
|
|
|
symbol_font = load_font(size=int(scale * (0.20 if has_forecast else 0.28)))
|
|
temp_font = load_font(size=int(scale * (0.15 if has_forecast else 0.22)), bold=True)
|
|
desc_font = load_font(size=int(scale * 0.05))
|
|
|
|
symbol = state["symbol"]
|
|
sw = text_width(draw, symbol, symbol_font)
|
|
draw.text(((width - sw) / 2, current_h * 0.08), symbol, font=symbol_font, fill="black")
|
|
|
|
temp_str = f"{round(state['temperature'])}°"
|
|
tw = text_width(draw, temp_str, temp_font)
|
|
draw.text(((width - tw) / 2, current_h * 0.42), temp_str, font=temp_font, fill="black")
|
|
|
|
desc = state["description"]
|
|
dw = text_width(draw, desc, desc_font)
|
|
draw.text(((width - dw) / 2, current_h * 0.78), desc, font=desc_font, fill=(90, 90, 90))
|
|
|
|
if has_forecast:
|
|
forecast_y = current_h
|
|
forecast_h = height - forecast_y
|
|
draw.line((width * 0.06, forecast_y, width * 0.94, forecast_y), fill=(224, 224, 224), width=2)
|
|
|
|
col_w = width / len(days)
|
|
label_font = load_font(size=int(scale * 0.042), bold=True)
|
|
day_symbol_font = load_font(size=int(scale * 0.085))
|
|
day_temp_font = load_font(size=int(scale * 0.038))
|
|
|
|
for i, day in enumerate(days):
|
|
cx = col_w * i + col_w / 2
|
|
y = forecast_y + forecast_h * 0.12
|
|
|
|
label = day["label"]
|
|
lw = text_width(draw, label, label_font)
|
|
draw.text((cx - lw / 2, y), label, font=label_font, fill=(90, 90, 90))
|
|
y += label_font.size * 1.5
|
|
|
|
dsw = text_width(draw, day["symbol"], day_symbol_font)
|
|
draw.text((cx - dsw / 2, y), day["symbol"], font=day_symbol_font, fill="black")
|
|
y += day_symbol_font.size * 1.2
|
|
|
|
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=(110, 110, 110))
|
|
|
|
return image
|