diff --git a/screens/weather.py b/screens/weather.py index ab7a054..0121dc4 100644 --- a/screens/weather.py +++ b/screens/weather.py @@ -1,6 +1,7 @@ """Current weather + daily forecast via Open-Meteo (free, no API key).""" import logging +import time from datetime import date import requests @@ -10,6 +11,10 @@ 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 @@ -25,7 +30,13 @@ class WeatherScreen(Screen): 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} + 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") @@ -76,8 +87,13 @@ class WeatherScreen(Screen): params={ "latitude": lat, "longitude": lon, - "current_weather": "true", - "daily": "weather_code,temperature_2m_max,temperature_2m_min", + # "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", }, @@ -86,11 +102,11 @@ class WeatherScreen(Screen): response.raise_for_status() payload = response.json() - current = payload.get("current_weather") or {} - if "temperature" not in current: + current = payload.get("current") or {} + if "temperature_2m" not in current: return None - code = int(current.get("weathercode", -1)) + code = int(current.get("weather_code", -1)) description, kind = WMO_CODES.get(code, ("Unbekannt", "clear")) days = [] @@ -109,12 +125,31 @@ class WeatherScreen(Screen): "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"], - "windspeed": current.get("windspeed"), + "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: @@ -122,6 +157,7 @@ class WeatherScreen(Screen): 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)) @@ -134,72 +170,170 @@ class WeatherScreen(Screen): 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) - # 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))) + # 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 - icon_gap = icon_size * 0.12 - temp_gap = temp_h * 0.4 - block_h = icon_size + icon_gap + temp_h + temp_gap + desc_h + 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 - 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_cx = pad + icon_size / 2 + icon_cy = block_top + current_h / 2 + draw_weather_icon(draw, state["kind"], icon_cx, icon_cy, icon_size) - icon_cy = block_top + icon_size / 2 - draw_weather_icon(draw, state["kind"], width / 2, icon_cy, icon_size) + text_x = pad + icon_size + scale * 0.05 + text_top = block_top + (current_h - text_block_h) / 2 - 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") + draw.text((text_x, text_top - 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="black") + desc_y = text_top + temp_h + text_gap + draw.text((text_x, desc_y - desc_top), desc, font=desc_font, fill="black") - 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)) + 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") - 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) + if lower_sections == 0: + return image - for i, day in enumerate(days): - cx = col_w * i + col_w / 2 - y = forecast_y + forecast_h * 0.1 + current_bottom = block_top + current_h + height * 0.045 + remaining_h = height - current_bottom - height * 0.02 + content_w = width - 2 * pad - label = day["label"] - lw = text_width(draw, label, label_font) - draw.text((cx - lw / 2, y), label, font=label_font, fill="black") - 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="black") + 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") diff --git a/web/app.py b/web/app.py index 03efba4..54ed337 100644 --- a/web/app.py +++ b/web/app.py @@ -69,6 +69,12 @@ def create_app(config, screens: dict, scheduler) -> Flask: if forecast_days not in (3, 5, 7): forecast_days = 3 update_minutes = max(1, int(request.form.get("update_minutes", 30) or 30)) + display_toggles = { + "show_hourly_graph": "show_hourly_graph" in request.form, + "show_feels_like": "show_feels_like" in request.form, + "show_high_low": "show_high_low" in request.form, + "show_rain_chance": "show_rain_chance" in request.form, + } if city: try: @@ -84,6 +90,7 @@ def create_app(config, screens: dict, scheduler) -> Flask: "longitude": result["longitude"], "forecast_days": forecast_days, "update_minutes": update_minutes, + **display_toggles, }) return redirect(url_for("settings_weather", saved=1)) else: @@ -93,6 +100,7 @@ def create_app(config, screens: dict, scheduler) -> Flask: "longitude": float(lon_raw) if lon_raw else None, "forecast_days": forecast_days, "update_minutes": update_minutes, + **display_toggles, }) return redirect(url_for("settings_weather", saved=1)) diff --git a/web/templates/settings_weather.html b/web/templates/settings_weather.html index ba98486..ee925e6 100644 --- a/web/templates/settings_weather.html +++ b/web/templates/settings_weather.html @@ -34,6 +34,24 @@

Weather itself only refreshes the panel when something actually changed, so a shorter interval here mostly just means fresher data, not more flashing.

+
+ + + + +