chance, and an hourly temperature graph - Current conditions block redone horizontally (icon left, big temp + description + an extras line on the right) instead of stacked vertically. - New extras line: "feels like" (apparent_temperature), today's high/low, and rain chance (precipitation_probability_max) -- each independently toggleable in settings, all on by default. Switched from the legacy current_weather=true param to Open-Meteo's current= param, since that's what exposes apparent_temperature; confirmed field names/formats live before wiring them in. - New hourly-today temperature graph (also toggleable): a polyline across today's 24 hourly points, min/max temps labeled at their points, a red dot marking the current hour, and hour labels along the bottom. - When both the graph and the daily forecast strip are shown, they split the remaining vertical space; either can be toggled off to give the other more room, matching how forecast_days already worked.
340 lines
14 KiB
Python
340 lines
14 KiB
Python
"""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")
|