"""Current weather + daily forecast via Open-Meteo (free, no API key).""" import logging import time 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 BLACK = (0, 0, 0) BLUE = (0, 0, 255) RED = (200, 0, 0) 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, "show_feels_like": True, "show_high_low": True, "show_rain_chance": True, "show_hourly_graph": True, } 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" (not the legacy "current_weather=true") is what # exposes apparent_temperature. Confirmed live which fields # come back plain-local (no Z suffix) under timezone=auto, # same as the "daily" block already relied on below. "current": "temperature_2m,apparent_temperature,weather_code", "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max", "hourly": "temperature_2m", "forecast_days": forecast_days, "timezone": "auto", }, timeout=HTTP_TIMEOUT, ) response.raise_for_status() payload = response.json() current = payload.get("current") or {} if "temperature_2m" not in current: return None code = int(current.get("weather_code", -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], }) rain_chance = None probs = daily.get("precipitation_probability_max") or [] if probs and probs[0] is not None: rain_chance = int(probs[0]) # Today's hourly points, for the day-graph -- "hourly" spans all of # forecast_days, filtered down to just today's date string prefix. today_str = date.today().isoformat() hourly = payload.get("hourly") or {} hourly_today = [ {"hour": int(t[11:13]), "temp": temp} for t, temp in zip(hourly.get("time", []), hourly.get("temperature_2m", [])) if t.startswith(today_str) ] return { "temperature": current["temperature_2m"], "feels_like": current.get("apparent_temperature"), "description": description, "kind": kind, "days": days, "high_today": days[0]["high"] if days else None, "low_today": days[0]["low"] if days else None, "rain_chance": rain_chance, "hourly_today": hourly_today, } 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) pad = width * 0.06 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="black") return image days = state.get("days") or [] has_forecast = bool(days) hourly = state.get("hourly_today") or [] show_graph = settings.get("show_hourly_graph", True) and len(hourly) >= 2 lower_sections = int(has_forecast) + int(show_graph) # Current conditions: icon on the left, temp/description/extras # stacked on the right -- sized from `scale` and measured via actual # text bounding boxes so current_h can be *derived* from what the # block actually needs, same principle as the original stacked # layout, just laid out horizontally now. icon_size = scale * 0.34 temp_font = load_font(size=int(scale * 0.15), bold=True) desc_font = load_font(size=int(scale * 0.055)) extra_font = load_font(size=int(scale * 0.04)) temp_str = f"{round(state['temperature'])}°" desc = state["description"] extra_parts = [] if settings.get("show_feels_like", True) and state.get("feels_like") is not None: extra_parts.append(f"Gefühlt {round(state['feels_like'])}°") if ( settings.get("show_high_low", True) and state.get("high_today") is not None and state.get("low_today") is not None ): extra_parts.append(f"H {round(state['high_today'])}° L {round(state['low_today'])}°") if settings.get("show_rain_chance", True) and state.get("rain_chance") is not None: extra_parts.append(f"{state['rain_chance']}% Regen") extra_line = " · ".join(extra_parts) 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 extra_top = extra_h = 0 if extra_line: extra_left, extra_top, extra_right, extra_bottom = draw.textbbox((0, 0), extra_line, font=extra_font) extra_h = extra_bottom - extra_top text_gap = temp_h * 0.22 text_block_h = temp_h + text_gap + desc_h + ((text_gap + extra_h) if extra_line else 0) current_h = max(icon_size, text_block_h) top_pad = (height - current_h) / 2 if lower_sections == 0 else height * 0.05 block_top = top_pad icon_cx = pad + icon_size / 2 icon_cy = block_top + current_h / 2 draw_weather_icon(draw, state["kind"], icon_cx, icon_cy, icon_size) text_x = pad + icon_size + scale * 0.05 text_top = block_top + (current_h - text_block_h) / 2 draw.text((text_x, text_top - temp_top), temp_str, font=temp_font, fill="black") desc_y = text_top + temp_h + text_gap draw.text((text_x, desc_y - desc_top), desc, font=desc_font, fill="black") if extra_line: extra_y = desc_y + desc_h + text_gap draw.text((text_x, extra_y - extra_top), extra_line, font=extra_font, fill="black") if lower_sections == 0: return image current_bottom = block_top + current_h + height * 0.045 remaining_h = height - current_bottom - height * 0.02 content_w = width - 2 * pad if show_graph and has_forecast: graph_h = remaining_h * 0.48 self._draw_hourly_graph(draw, hourly, pad, current_bottom, content_w, graph_h, scale) self._draw_daily_forecast( draw, days, pad, current_bottom + graph_h + height * 0.015, content_w, remaining_h - graph_h - height * 0.015, scale, ) elif show_graph: self._draw_hourly_graph(draw, hourly, pad, current_bottom, content_w, remaining_h, scale) elif has_forecast: self._draw_daily_forecast(draw, days, pad, current_bottom, content_w, remaining_h, scale) return image def _draw_daily_forecast(self, draw, days, x, y, w, h, scale): draw.rectangle((x, y, x + w, y + 2), fill=BLACK) content_top = y + h * 0.1 content_h = h - h * 0.1 col_w = w / 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, content_h * 0.48) for i, day in enumerate(days): cx = x + col_w * i + col_w / 2 yy = content_top label = day["label"] lw = text_width(draw, label, label_font) draw.text((cx - lw / 2, yy), label, font=label_font, fill="black") yy += label_font.size * 1.4 draw_weather_icon(draw, day["kind"], cx, yy + day_icon_size / 2, day_icon_size) yy += 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, yy), temps, font=day_temp_font, fill="black") def _draw_hourly_graph(self, draw, hourly, x, y, w, h, scale): """A simple polyline of today's hourly temps -- min/max labeled at their points, a red dot marking the current hour, and a handful of evenly-spaced hour labels along the bottom.""" temps = [pt["temp"] for pt in hourly] lo, hi = min(temps), max(temps) span = max(hi - lo, 1.0) # avoid a divide-by-zero on a dead-flat day point_font = load_font(size=int(scale * 0.032)) axis_font = load_font(size=int(scale * 0.028)) top_margin = point_font.size * 1.5 bottom_margin = axis_font.size * 1.6 plot_top = y + top_margin plot_h = max(1.0, h - top_margin - bottom_margin) n = len(hourly) step_x = w / (n - 1) def point_xy(i): px = x + step_x * i py = plot_top + plot_h * (1 - (hourly[i]["temp"] - lo) / span) return px, py points = [point_xy(i) for i in range(n)] draw.line(points, fill=BLUE, width=max(2, int(scale * 0.006)), joint="curve") def label_point(i): # Always above the point, never below -- a below-point label on # a trough sitting right at the plot's left/right edge would # otherwise land in the same spot as the hour-axis labels. px, py = points[i] label = f"{round(hourly[i]['temp'])}°" lw = text_width(draw, label, point_font) ly = py - point_font.size * 1.25 lx = min(max(px - lw / 2, x), x + w - lw) draw.text((lx, ly), label, font=point_font, fill="black") max_i = max(range(n), key=lambda i: hourly[i]["temp"]) min_i = min(range(n), key=lambda i: hourly[i]["temp"]) label_point(max_i) if min_i != max_i: label_point(min_i) now_hour = time.localtime().tm_hour now_i = next((i for i, pt in enumerate(hourly) if pt["hour"] == now_hour), None) if now_i is not None: px, py = points[now_i] r = max(3, int(scale * 0.011)) draw.ellipse((px - r, py - r, px + r, py + r), fill=RED) axis_y = y + h - axis_font.size tick_idxs = sorted(set((0, n // 4, n // 2, (3 * n) // 4, n - 1))) for i in tick_idxs: label = f"{hourly[i]['hour']:02d}" lw = text_width(draw, label, axis_font) px, _ = points[i] lx = min(max(px - lw / 2, x), x + w - lw) draw.text((lx, axis_y), label, font=axis_font, fill="black")