Files
Paperwall/screens/weather.py
T
Honey 61348be674 Landscape orientation, redesigned web UI, rotation on/off + minutes, weather by city name
- Panel is mounted sideways: render landscape (600x400), rotate to the
  panel's native portrait orientation right before writing to hardware.
- Web UI: card-based layout, dark mode, active nav highlighting, save
  confirmation toasts, error banners.
- Rotation: separate enabled/disabled toggle (off pins to the first screen
  in the list) and the interval is now in minutes instead of raw seconds.
  Migrates existing config.json from the old interval_seconds field.
- Weather: type a city name instead of hunting down coordinates, resolved
  via Open-Meteo's free geocoding API. Manual lat/long still available as
  a fallback.
2026-07-21 22:51:16 +02:00

132 lines
4.7 KiB
Python

"""Current weather via Open-Meteo (free, no API key -- just a lat/long)."""
import logging
import requests
from PIL import Image, ImageDraw
from screens.base import Screen
from screens.render_utils import load_font, text_width
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 10
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
# WMO weather codes -> (German description, symbol). Not exhaustive, covers
# the common cases; unmapped codes fall back to a generic description.
WMO_CODES = {
0: ("Klarer Himmel", "☀"),
1: ("Überwiegend klar", "☀"),
2: ("Teilweise bewölkt", "⛅"),
3: ("Bedeckt", "☁"),
45: ("Nebel", "☁"),
48: ("Nebel", "☁"),
51: ("Leichter Nieselregen", "☂"),
53: ("Nieselregen", "☂"),
55: ("Starker Nieselregen", "☂"),
61: ("Leichter Regen", "☔"),
63: ("Regen", "☔"),
65: ("Starker Regen", "☔"),
71: ("Leichter Schneefall", "❄"),
73: ("Schneefall", "❄"),
75: ("Starker Schneefall", "❄"),
80: ("Regenschauer", "☔"),
81: ("Regenschauer", "☔"),
82: ("Heftige Regenschauer", "☔"),
85: ("Schneeschauer", "❄"),
86: ("Schneeschauer", "❄"),
95: ("Gewitter", "⚡"),
96: ("Gewitter mit Hagel", "⚡"),
99: ("Gewitter mit Hagel", "⚡"),
}
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}
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
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": lat, "longitude": lon, "current_weather": "true"},
timeout=HTTP_TIMEOUT,
)
response.raise_for_status()
current = response.json().get("current_weather") or {}
if "temperature" not in current:
return None
code = int(current.get("weathercode", -1))
description, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
return {
"temperature": current["temperature"],
"windspeed": current.get("windspeed"),
"description": description,
"symbol": symbol,
}
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)
if not state:
font = load_font(size=int(min(width, height) * 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
symbol_font = load_font(size=int(min(width, height) * 0.28))
temp_font = load_font(size=int(min(width, height) * 0.22), bold=True)
desc_font = load_font(size=int(min(width, height) * 0.055))
symbol = state["symbol"]
sw = text_width(draw, symbol, symbol_font)
draw.text(((width - sw) / 2, height * 0.14), symbol, font=symbol_font, fill="black")
temp_str = f"{round(state['temperature'])}°"
tw = text_width(draw, temp_str, temp_font)
draw.text(((width - tw) / 2, height * 0.46), temp_str, font=temp_font, fill="black")
desc = state["description"]
dw = text_width(draw, desc, desc_font)
draw.text(((width - dw) / 2, height * 0.68), desc, font=desc_font, fill=(90, 90, 90))
return image