- GET /api/weather?lat=&lon= (Open-Meteo, 30-min TTL-Cache) - Zecken-Warnung regelbasiert: März–Okt + Temp > 7°C - Karte: Wetterchip oben rechts nach GPS-Fix - stats.py + achievements.py: is_public-Filter entfernt — private Routen zählen jetzt für eigene km/Achievements - SW by-v320, APP_VER 308
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""
|
||
BAN YARO — Wetter via Open-Meteo
|
||
- get_weather_summary(): Push-Job, prüft 5 deutsche Städte
|
||
- get_weather_for_location(): API-Endpoint, beliebiger Standort mit TTL-Cache
|
||
"""
|
||
|
||
import time
|
||
import logging
|
||
import httpx
|
||
from datetime import datetime
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# WMO-Wettercodes → (Beschreibung, Phosphor-Icon-Name)
|
||
_WMO = {
|
||
0: ('Klar', 'sun'),
|
||
1: ('Überwiegend klar', 'cloud-sun'),
|
||
2: ('Teilweise bewölkt', 'cloud-sun'),
|
||
3: ('Bedeckt', 'cloud'),
|
||
45: ('Nebel', 'cloud-fog'),
|
||
48: ('Gefrierender Nebel', 'cloud-fog'),
|
||
51: ('Leichter Nieselregen', 'cloud-rain'),
|
||
53: ('Nieselregen', 'cloud-rain'),
|
||
55: ('Starker Nieselregen', 'cloud-rain'),
|
||
61: ('Leichter Regen', 'cloud-rain'),
|
||
63: ('Regen', 'cloud-rain'),
|
||
65: ('Starker Regen', 'cloud-rain'),
|
||
71: ('Leichter Schnee', 'snowflake'),
|
||
73: ('Schnee', 'snowflake'),
|
||
75: ('Starker Schnee', 'snowflake'),
|
||
77: ('Schneekörner', 'snowflake'),
|
||
80: ('Leichte Schauer', 'cloud-rain'),
|
||
81: ('Schauer', 'cloud-rain'),
|
||
82: ('Starke Schauer', 'cloud-rain'),
|
||
85: ('Schneeschauer', 'snowflake'),
|
||
86: ('Starke Schneeschauer', 'snowflake'),
|
||
95: ('Gewitter', 'cloud-lightning'),
|
||
96: ('Gewitter mit Hagel', 'cloud-lightning'),
|
||
99: ('Schweres Gewitter', 'cloud-lightning'),
|
||
}
|
||
|
||
# TTL-Cache: (round(lat,1), round(lon,1)) → (timestamp, data)
|
||
_location_cache: dict = {}
|
||
_CACHE_TTL = 1800 # 30 Minuten
|
||
|
||
|
||
async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||
"""Holt aktuelles Wetter für einen Standort. 30-min TTL-Cache."""
|
||
key = (round(lat, 1), round(lon, 1))
|
||
now = time.time()
|
||
if key in _location_cache:
|
||
ts, cached = _location_cache[key]
|
||
if now - ts < _CACHE_TTL:
|
||
return cached
|
||
|
||
url = (
|
||
"https://api.open-meteo.com/v1/forecast"
|
||
f"?latitude={lat}&longitude={lon}"
|
||
"¤t=temperature_2m,apparent_temperature,weathercode,windspeed_10m,is_day"
|
||
"&daily=precipitation_probability_max,uv_index_max"
|
||
"&timezone=Europe%2FBerlin&forecast_days=1"
|
||
)
|
||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||
resp = await client.get(url)
|
||
resp.raise_for_status()
|
||
raw = resp.json()
|
||
|
||
cur = raw.get('current', {})
|
||
daily = raw.get('daily', {})
|
||
|
||
temp = cur.get('temperature_2m')
|
||
feels_like = cur.get('apparent_temperature')
|
||
wcode = cur.get('weathercode', 0)
|
||
wind = cur.get('windspeed_10m')
|
||
is_day = cur.get('is_day', 1)
|
||
precip = (daily.get('precipitation_probability_max') or [None])[0]
|
||
uv = (daily.get('uv_index_max') or [None])[0]
|
||
|
||
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
|
||
if wcode == 0 and not is_day:
|
||
icon = 'moon'
|
||
|
||
month = datetime.now().month
|
||
zecken = None
|
||
if temp is not None and temp > 7.0 and 3 <= month <= 10:
|
||
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
|
||
|
||
data = {
|
||
'temp_c': temp,
|
||
'feels_like_c': feels_like,
|
||
'weathercode': wcode,
|
||
'desc': desc,
|
||
'icon': icon,
|
||
'wind_kmh': wind,
|
||
'precip_prob': precip,
|
||
'uv_index': uv,
|
||
'is_day': bool(is_day),
|
||
'zecken_warnung': zecken,
|
||
}
|
||
_location_cache[key] = (now, data)
|
||
return data
|
||
|
||
# Wichtige deutsche Städte als Stichprobe
|
||
CITIES = [
|
||
{"name": "Berlin", "lat": 52.52, "lon": 13.41},
|
||
{"name": "München", "lat": 48.14, "lon": 11.58},
|
||
{"name": "Hamburg", "lat": 53.55, "lon": 10.00},
|
||
{"name": "Frankfurt", "lat": 50.11, "lon": 8.68},
|
||
{"name": "Köln", "lat": 50.94, "lon": 6.96},
|
||
]
|
||
|
||
# WMO-Wettercodes 95–99 = Gewitter
|
||
THUNDERSTORM_CODES = {95, 96, 99}
|
||
|
||
|
||
async def get_weather_summary() -> dict:
|
||
"""
|
||
Holt Tagesprognose für mehrere deutsche Städte von Open-Meteo.
|
||
Gibt zurück: {"max_temp_c": float, "thunderstorm": bool}
|
||
"""
|
||
max_temp = -999.0
|
||
thunderstorm = False
|
||
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
for city in CITIES:
|
||
url = (
|
||
"https://api.open-meteo.com/v1/forecast"
|
||
f"?latitude={city['lat']}&longitude={city['lon']}"
|
||
"&daily=temperature_2m_max,precipitation_probability_max,weathercode"
|
||
"&timezone=Europe%2FBerlin&forecast_days=1"
|
||
)
|
||
try:
|
||
resp = await client.get(url)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
daily = data.get("daily", {})
|
||
temps = daily.get("temperature_2m_max", [None])
|
||
precip_probs = daily.get("precipitation_probability_max", [None])
|
||
weathercodes = daily.get("weathercode", [None])
|
||
|
||
temp = temps[0] if temps else None
|
||
precip_prob = precip_probs[0] if precip_probs else None
|
||
weathercode = weathercodes[0] if weathercodes else None
|
||
|
||
if temp is not None and temp > max_temp:
|
||
max_temp = temp
|
||
|
||
if (
|
||
precip_prob is not None and precip_prob > 50
|
||
and weathercode is not None and int(weathercode) in THUNDERSTORM_CODES
|
||
):
|
||
thunderstorm = True
|
||
logger.info(f"Gewitter erkannt: {city['name']} (Code {weathercode}, {precip_prob}% Niederschlag)")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Wetter-Abruf für {city['name']} fehlgeschlagen: {e}")
|
||
|
||
if max_temp == -999.0:
|
||
max_temp = 0.0
|
||
|
||
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
|
||
return {"max_temp_c": max_temp, "thunderstorm": thunderstorm}
|