Fix manual switch reverting instantly, add weather forecast

- scheduler: "Show X now" was a true one-shot -- reverted on the very next
  5s tick, causing the flash-to-X-then-flash-back-to-clock behavior. Now
  pins for 5 minutes (still preemptable by Navidrome priority, which stays
  authoritative). Added a "Resume automatic" action/button and status
  display showing what's pinned and until when.
- weather: daily forecast strip (3/5/7 days, selectable in settings) below
  current conditions, via Open-Meteo's daily= params in the same request.
  Fixed a bad symbol choice along the way -- DejaVu Sans has no glyph for
  the partly-cloudy emoji, was rendering as a tofu box; swapped to the
  plain cloud symbol, verified glyph coverage for the whole WMO code table.
This commit is contained in:
Honey committed 2026-07-21 23:06:16 +02:00
1 parent bfe4616f20
commit a19a7ea49e
7 files changed
+147 -40

No files matched your search

+79 -12
View File
@@ -1,6 +1,7 @@
"""Current weather via Open-Meteo (free, no API key -- just a lat/long)."""
"""Current weather + daily forecast via Open-Meteo (free, no API key)."""
import logging
from datetime import date
import requests
from PIL import Image, ImageDraw
@@ -12,13 +13,16 @@ 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"]
# 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", "⛅"),
2: ("Teilweise bewölkt", "☁"), # DejaVu Sans has no ⛅ glyph, falls back to a tofu box
3: ("Bedeckt", "☁"),
45: ("Nebel", "☁"),
48: ("Nebel", "☁"),
@@ -48,7 +52,7 @@ class WeatherScreen(Screen):
poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher
def default_settings(self) -> dict:
return {"city": "", "latitude": None, "longitude": None}
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
@@ -79,32 +83,64 @@ class WeatherScreen(Screen):
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"},
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()
current = response.json().get("current_weather") or {}
payload = response.json()
current = payload.get("current_weather") or {}
if "temperature" not in current:
return None
code = int(current.get("weathercode", -1))
description, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
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_symbol = WMO_CODES.get(day_code, ("Unbekannt", "?"))
days.append({
"label": WEEKDAY_ABBR_DE[weekday],
"symbol": day_symbol,
"high": daily["temperature_2m_max"][i],
"low": daily["temperature_2m_min"][i],
})
return {
"temperature": current["temperature"],
"windspeed": current.get("windspeed"),
"description": description,
"symbol": symbol,
"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(min(width, height) * 0.06))
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"
@@ -112,20 +148,51 @@ class WeatherScreen(Screen):
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))
days = state.get("days") or []
has_forecast = bool(days)
current_h = height * (0.56 if has_forecast else 1.0)
symbol_font = load_font(size=int(scale * (0.20 if has_forecast else 0.28)))
temp_font = load_font(size=int(scale * (0.15 if has_forecast else 0.22)), bold=True)
desc_font = load_font(size=int(scale * 0.05))
symbol = state["symbol"]
sw = text_width(draw, symbol, symbol_font)
draw.text(((width - sw) / 2, height * 0.14), symbol, font=symbol_font, fill="black")
draw.text(((width - sw) / 2, current_h * 0.08), 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")
draw.text(((width - tw) / 2, current_h * 0.42), 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))
draw.text(((width - dw) / 2, current_h * 0.78), desc, font=desc_font, fill=(90, 90, 90))
if has_forecast:
forecast_y = current_h
forecast_h = height - forecast_y
draw.line((width * 0.06, forecast_y, width * 0.94, forecast_y), fill=(224, 224, 224), width=2)
col_w = width / len(days)
label_font = load_font(size=int(scale * 0.042), bold=True)
day_symbol_font = load_font(size=int(scale * 0.085))
day_temp_font = load_font(size=int(scale * 0.038))
for i, day in enumerate(days):
cx = col_w * i + col_w / 2
y = forecast_y + forecast_h * 0.12
label = day["label"]
lw = text_width(draw, label, label_font)
draw.text((cx - lw / 2, y), label, font=label_font, fill=(90, 90, 90))
y += label_font.size * 1.5
dsw = text_width(draw, day["symbol"], day_symbol_font)
draw.text((cx - dsw / 2, y), day["symbol"], font=day_symbol_font, fill="black")
y += day_symbol_font.size * 1.2
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=(110, 110, 110))
return image