Files
Honey 11c03c86b1 Weather: daily strip shows upcoming days, not today again
Today is already shown big at the top (temp/description/date), so
having it repeat as the first column of the daily strip was
redundant. forecast_days now means "how many upcoming days", fetching
one extra day from the API and dropping today from the strip --
high_today/low_today still come from that dropped day, unaffected.
Verified live: on a Saturday, the strip now starts at Sunday instead
of repeating Saturday.
2026-07-25 10:40:45 +02:00

375 lines
15 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 format_date_de, load_font, text_width, truncate_to_width
from screens.weather_icons import WMO_CODES, draw_weather_icon
BLACK = (0, 0, 0)
YELLOW = (255, 255, 0)
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": 5, "update_minutes": 30,
"show_feels_like": True,
"show_high_low": True,
"show_rain_chance": True,
"show_hourly_graph": True,
"show_city": True,
"show_date": 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", 5)
if forecast_days not in FORECAST_DAY_CHOICES:
forecast_days = 5
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",
# +1: forecast_days is "how many upcoming days in the daily
# strip", and today itself is dropped from that strip below
# (already shown big up top) -- so fetching just
# forecast_days would leave the strip one day short.
"forecast_days": forecast_days + 1,
"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],
})
high_today = days[0]["high"] if days else None
low_today = days[0]["low"] if days else None
days = days[1:] # drop today -- already shown big up top
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": high_today,
"low_today": low_today,
"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)
# Header line above the icon/temp block: city name (just the city
# itself, not the full "City, State, Country" geocode label -- too
# cluttered for a dashboard) and/or today's weekday + date, same
# format as the Clock screen. Nothing to show for city specifically
# with manually-entered coordinates, since there's no resolved place
# name for those.
header_font = load_font(size=int(scale * 0.045), bold=True)
header_parts = []
if settings.get("show_city", True) and settings.get("city"):
header_parts.append(settings["city"].split(",")[0].strip())
if settings.get("show_date", True):
header_parts.append(format_date_de())
header_label = truncate_to_width(draw, " · ".join(header_parts), header_font, width - 2 * pad) if header_parts else ""
header_h = header_gap = header_top = 0
if header_label:
header_left, header_top, header_right, header_bottom = draw.textbbox((0, 0), header_label, font=header_font)
header_h = header_bottom - header_top
header_gap = height * 0.025
total_top_h = header_h + header_gap + current_h
top_pad = (height - total_top_h) / 2 if lower_sections == 0 else height * 0.05
if header_label:
draw.text((pad, top_pad - header_top), header_label, font=header_font, fill="black")
block_top = top_pad + header_h + header_gap
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=YELLOW, 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")