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:
1 parent
bfe4616f20
commit
a19a7ea49e
7 files changed
+147
-40
No files matched your search
+79
-12
@@ -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
|
||||
Reference in new issue
Block a user