"""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