235 lines
8.3 KiB
Python
235 lines
8.3 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from typing import Any, Dict, List, Optional
|
||
|
|
|
||
|
|
WEATHER_ICONS: Dict[int, str] = {
|
||
|
|
113: "\u2600\ufe0f", # Clear/Sunny
|
||
|
|
116: "\u26c5", # Partly Cloudy
|
||
|
|
119: "\u2601\ufe0f", # Cloudy
|
||
|
|
122: "\u2601\ufe0f", # Overcast
|
||
|
|
143: "\ud83c\udf2b\ufe0f", # Mist
|
||
|
|
176: "\ud83c\udf26\ufe0f", # Patchy rain nearby
|
||
|
|
179: "\ud83c\udf28\ufe0f", # Patchy snow nearby
|
||
|
|
182: "\ud83c\udf28\ufe0f", # Patchy sleet nearby
|
||
|
|
185: "\ud83c\udf28\ufe0f", # Patchy freezing drizzle nearby
|
||
|
|
200: "\u26c8\ufe0f", # Thundery outbreaks nearby
|
||
|
|
227: "\ud83c\udf28\ufe0f", # Blowing snow
|
||
|
|
230: "\u2744\ufe0f", # Blizzard
|
||
|
|
248: "\ud83c\udf2b\ufe0f", # Fog
|
||
|
|
260: "\ud83c\udf2b\ufe0f", # Freezing fog
|
||
|
|
263: "\ud83c\udf26\ufe0f", # Patchy light drizzle
|
||
|
|
266: "\ud83c\udf27\ufe0f", # Light drizzle
|
||
|
|
281: "\ud83c\udf28\ufe0f", # Freezing drizzle
|
||
|
|
284: "\ud83c\udf28\ufe0f", # Heavy freezing drizzle
|
||
|
|
293: "\ud83c\udf26\ufe0f", # Patchy light rain
|
||
|
|
296: "\ud83c\udf27\ufe0f", # Light rain
|
||
|
|
299: "\ud83c\udf27\ufe0f", # Moderate rain at times
|
||
|
|
302: "\ud83c\udf27\ufe0f", # Moderate rain
|
||
|
|
305: "\ud83c\udf27\ufe0f", # Heavy rain at times
|
||
|
|
308: "\ud83c\udf27\ufe0f", # Heavy rain
|
||
|
|
311: "\ud83c\udf28\ufe0f", # Light freezing rain
|
||
|
|
314: "\ud83c\udf28\ufe0f", # Moderate or heavy freezing rain
|
||
|
|
317: "\ud83c\udf28\ufe0f", # Light sleet
|
||
|
|
320: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet
|
||
|
|
323: "\ud83c\udf28\ufe0f", # Patchy light snow
|
||
|
|
326: "\u2744\ufe0f", # Light snow
|
||
|
|
329: "\u2744\ufe0f", # Patchy moderate snow
|
||
|
|
332: "\u2744\ufe0f", # Moderate snow
|
||
|
|
335: "\u2744\ufe0f", # Patchy heavy snow
|
||
|
|
338: "\u2744\ufe0f", # Heavy snow
|
||
|
|
350: "\ud83c\udf28\ufe0f", # Ice pellets
|
||
|
|
353: "\ud83c\udf26\ufe0f", # Light rain shower
|
||
|
|
356: "\ud83c\udf27\ufe0f", # Moderate or heavy rain shower
|
||
|
|
359: "\ud83c\udf27\ufe0f", # Torrential rain shower
|
||
|
|
362: "\ud83c\udf28\ufe0f", # Light sleet showers
|
||
|
|
365: "\ud83c\udf28\ufe0f", # Moderate or heavy sleet showers
|
||
|
|
368: "\u2744\ufe0f", # Light snow showers
|
||
|
|
371: "\u2744\ufe0f", # Moderate or heavy snow showers
|
||
|
|
374: "\ud83c\udf28\ufe0f", # Light showers of ice pellets
|
||
|
|
377: "\ud83c\udf28\ufe0f", # Moderate or heavy showers of ice pellets
|
||
|
|
386: "\u26c8\ufe0f", # Patchy light rain with thunder
|
||
|
|
389: "\u26c8\ufe0f", # Moderate or heavy rain with thunder
|
||
|
|
392: "\u26c8\ufe0f", # Patchy light snow with thunder
|
||
|
|
395: "\u26c8\ufe0f", # Moderate or heavy snow with thunder
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _get_weather_icon(code: int) -> str:
|
||
|
|
"""Map a WWO weather code to an emoji icon."""
|
||
|
|
return WEATHER_ICONS.get(code, "\ud83c\udf24\ufe0f")
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_current_condition(condition: Dict[str, Any], location: str) -> Dict[str, Any]:
|
||
|
|
"""Parse a single current_condition entry from the wttr.in JSON."""
|
||
|
|
weather_code = int(condition.get("weatherCode", 113))
|
||
|
|
descriptions = condition.get("weatherDesc", [])
|
||
|
|
description = descriptions[0].get("value", "Unknown") if descriptions else "Unknown"
|
||
|
|
|
||
|
|
return {
|
||
|
|
"location": location,
|
||
|
|
"temp": int(condition.get("temp_C", 0)),
|
||
|
|
"feels_like": int(condition.get("FeelsLikeC", 0)),
|
||
|
|
"humidity": int(condition.get("humidity", 0)),
|
||
|
|
"wind_kmh": int(condition.get("windspeedKmph", 0)),
|
||
|
|
"description": description,
|
||
|
|
"icon": _get_weather_icon(weather_code),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_forecast_day(day: Dict[str, Any]) -> Dict[str, Any]:
|
||
|
|
"""Parse a single forecast day from the wttr.in weather array."""
|
||
|
|
date = day.get("date", "")
|
||
|
|
max_temp = int(day.get("maxtempC", 0))
|
||
|
|
min_temp = int(day.get("mintempC", 0))
|
||
|
|
|
||
|
|
astronomy = day.get("astronomy", [])
|
||
|
|
sunrise = astronomy[0].get("sunrise", "") if astronomy else ""
|
||
|
|
sunset = astronomy[0].get("sunset", "") if astronomy else ""
|
||
|
|
|
||
|
|
hourly = day.get("hourly", [])
|
||
|
|
if hourly:
|
||
|
|
midday = hourly[len(hourly) // 2]
|
||
|
|
weather_code = int(midday.get("weatherCode", 113))
|
||
|
|
descs = midday.get("weatherDesc", [])
|
||
|
|
description = descs[0].get("value", "Unknown") if descs else "Unknown"
|
||
|
|
else:
|
||
|
|
weather_code = 113
|
||
|
|
description = "Unknown"
|
||
|
|
|
||
|
|
return {
|
||
|
|
"date": date,
|
||
|
|
"max_temp": max_temp,
|
||
|
|
"min_temp": min_temp,
|
||
|
|
"icon": _get_weather_icon(weather_code),
|
||
|
|
"description": description,
|
||
|
|
"sunrise": sunrise,
|
||
|
|
"sunset": sunset,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def fetch_weather(location: str) -> Dict[str, Any]:
|
||
|
|
"""Fetch current weather and 3-day forecast from wttr.in.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
location: City name or coordinates (e.g. "Berlin" or "52.52,13.405").
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Dictionary with current conditions and 3-day forecast.
|
||
|
|
"""
|
||
|
|
fallback: Dict[str, Any] = {
|
||
|
|
"location": location,
|
||
|
|
"temp": 0,
|
||
|
|
"feels_like": 0,
|
||
|
|
"humidity": 0,
|
||
|
|
"wind_kmh": 0,
|
||
|
|
"description": "Unavailable",
|
||
|
|
"icon": "\u2753",
|
||
|
|
"forecast_3day": [],
|
||
|
|
"error": None,
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
||
|
|
resp = await client.get(
|
||
|
|
f"https://wttr.in/{location}",
|
||
|
|
params={"format": "j1"},
|
||
|
|
headers={"Accept": "application/json"},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
except httpx.HTTPStatusError as exc:
|
||
|
|
fallback["error"] = f"HTTP {exc.response.status_code}"
|
||
|
|
return fallback
|
||
|
|
except httpx.RequestError as exc:
|
||
|
|
fallback["error"] = f"Request failed: {exc}"
|
||
|
|
return fallback
|
||
|
|
except Exception as exc:
|
||
|
|
fallback["error"] = str(exc)
|
||
|
|
return fallback
|
||
|
|
|
||
|
|
current_conditions = data.get("current_condition", [])
|
||
|
|
if not current_conditions:
|
||
|
|
fallback["error"] = "No current condition data"
|
||
|
|
return fallback
|
||
|
|
|
||
|
|
result = _parse_current_condition(current_conditions[0], location)
|
||
|
|
|
||
|
|
weather_days = data.get("weather", [])
|
||
|
|
forecast_3day: List[Dict[str, Any]] = []
|
||
|
|
for day in weather_days[:3]:
|
||
|
|
forecast_3day.append(_parse_forecast_day(day))
|
||
|
|
|
||
|
|
result["forecast_3day"] = forecast_3day
|
||
|
|
result["error"] = None
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
async def fetch_hourly_forecast(location: str) -> List[Dict[str, Any]]:
|
||
|
|
"""Fetch hourly forecast for the current day from wttr.in.
|
||
|
|
|
||
|
|
Returns the next 8 hourly slots from the current day's forecast.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
location: City name or coordinates.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
List of hourly forecast dicts with time, temp, icon, and precip_chance.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
||
|
|
resp = await client.get(
|
||
|
|
f"https://wttr.in/{location}",
|
||
|
|
params={"format": "j1"},
|
||
|
|
headers={"Accept": "application/json"},
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
data = resp.json()
|
||
|
|
except Exception:
|
||
|
|
return []
|
||
|
|
|
||
|
|
weather_days = data.get("weather", [])
|
||
|
|
if not weather_days:
|
||
|
|
return []
|
||
|
|
|
||
|
|
all_hourly: List[Dict[str, Any]] = []
|
||
|
|
for day in weather_days[:2]:
|
||
|
|
hourly_entries = day.get("hourly", [])
|
||
|
|
for entry in hourly_entries:
|
||
|
|
time_raw = entry.get("time", "0")
|
||
|
|
time_value = int(time_raw)
|
||
|
|
hours = time_value // 100
|
||
|
|
minutes = time_value % 100
|
||
|
|
time_str = f"{hours:02d}:{minutes:02d}"
|
||
|
|
|
||
|
|
weather_code = int(entry.get("weatherCode", 113))
|
||
|
|
descs = entry.get("weatherDesc", [])
|
||
|
|
description = descs[0].get("value", "Unknown") if descs else "Unknown"
|
||
|
|
|
||
|
|
all_hourly.append({
|
||
|
|
"time": time_str,
|
||
|
|
"temp": int(entry.get("tempC", 0)),
|
||
|
|
"icon": _get_weather_icon(weather_code),
|
||
|
|
"description": description,
|
||
|
|
"precip_chance": int(entry.get("chanceofrain", 0)),
|
||
|
|
"wind_kmh": int(entry.get("windspeedKmph", 0)),
|
||
|
|
})
|
||
|
|
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
now_hour = datetime.now().hour
|
||
|
|
upcoming: List[Dict[str, Any]] = []
|
||
|
|
found_start = False
|
||
|
|
for slot in all_hourly:
|
||
|
|
slot_hour = int(slot["time"].split(":")[0])
|
||
|
|
if not found_start:
|
||
|
|
if slot_hour >= now_hour:
|
||
|
|
found_start = True
|
||
|
|
else:
|
||
|
|
continue
|
||
|
|
upcoming.append(slot)
|
||
|
|
if len(upcoming) >= 8:
|
||
|
|
break
|
||
|
|
|
||
|
|
return upcoming
|