Files
Paperwall/screens/weather.py
T
Honey 8798466e5b Redesign weather with colorful hand-drawn icons, bigger forecast
New screens/weather_icons.py: vector icons drawn with PIL primitives using
the panel's actual 6-color palette (black/white/yellow/red/blue/green, from
epd4in0e's own getbuffer() quantization table) -- sun with rays, line-art
clouds, blue rain/snow, yellow lightning bolt, fog bands. Replaces the old
monochrome unicode-glyph approach entirely (also sidesteps font coverage
gaps -- that's what caused the earlier tofu-box bug).

Forecast row given significantly more vertical space per request. Caught
two real bugs while building this:
- Current-conditions block height was guessed independently of the space
  allocated to it, so it overflowed into the forecast row for real content.
  Now derives the space it needs from actual measured text/icon size
  instead of assuming both fit.
- `scale * 0.045 if has_forecast else 0.05` -- conditional-expression
  precedence bug, silently evaluated to a bare 0.05 (not scale * 0.05) in
  the else branch, crashing font loading with size=0 when forecast is
  empty. Only surfaced by testing that specific case.
- Sun icon's rays extended past the nominal icon_size box used for layout
  math, throckwing off vertical centering when no forecast is shown.
2026-07-21 23:42:22 +02:00

195 lines
7.3 KiB
Python

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