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