Files
Paperwall/screens/weather.py
T
Honey 132e133b1d All screens: drop gray text, matching the Journey fix
The epd4in0e panel's palette has no gray at all (just black/white/
yellow/red/blue/green -- see epd4in0e.py's putpalette), so any gray
RGB fill gets Floyd-Steinberg dithered into a black/white checkerboard
across the whole glyph body. At the small font sizes most of these
screens use (Navidrome's artist/album/genre/source lines, Departures'
direction/platform columns, Weather's description/forecast labels,
Clock's date line, the placeholder messages), that reads as blurry,
half-rendered text -- same root cause already fixed in Journey.

Switched every gray fill/outline to solid black across navidrome.py,
weather.py, departures.py, clock.py, and picture.py. Left the
intentionally-faint row-divider hairlines (228,228,228) alone --
those are meant to almost disappear, not readable text. Removed
departures.py's now-unused MUTED constant.
2026-07-23 11:56:24 +02:00

206 lines
7.8 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 # 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}
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_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="black")
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="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))
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="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")
return image