Files
Inkycal/inkycal/custom/openweathermap_wrapper.py

209 lines
8.1 KiB
Python
Raw Normal View History

"""
2025-06-19 15:19:58 +02:00
Inkycal OpenWeatherMap API abstraction module
- Retrieves free weather data from OWM 2.5/3.0 API endpoints (with provided API key)
- Handles temperature and wind unit conversions
- Converts data to a standardized timezone and language
- Returns ready-to-use weather structures for current, hourly, and daily forecasts
"""
2025-06-19 15:19:58 +02:00
import json
import logging
2025-06-19 15:19:58 +02:00
from datetime import datetime, timedelta
from typing import Dict, List, Literal
import requests
from dateutil import tz
2025-06-19 15:19:58 +02:00
# Type annotations for strict typing
TEMP_UNITS = Literal["celsius", "fahrenheit"]
WIND_UNITS = Literal["meters_sec", "km_hour", "miles_hour", "knots", "beaufort"]
WEATHER_TYPE = Literal["current", "forecast"]
API_VERSIONS = Literal["2.5", "3.0"]
API_BASE_URL = "https://api.openweathermap.org/data"
logger = logging.getLogger(__name__)
logger.setLevel(level=logging.INFO)
def is_timestamp_within_range(timestamp: datetime, start_time: datetime, end_time: datetime) -> bool:
2025-06-19 15:19:58 +02:00
"""Check if the given timestamp lies between start_time and end_time."""
return start_time <= timestamp <= end_time
def get_json_from_url(request_url):
2025-06-19 15:19:58 +02:00
"""Performs an HTTP GET request and returns the parsed JSON response."""
response = requests.get(request_url)
if not response.ok:
raise AssertionError(
2025-06-19 15:19:58 +02:00
f"Failure getting weather: code {response.status_code}. Reason: {response.text}"
)
return json.loads(response.text)
class OpenWeatherMap:
def __init__(self, api_key: str, city_id: int = None, lat: float = None, lon: float = None,
api_version: API_VERSIONS = "2.5", temp_unit: TEMP_UNITS = "celsius",
wind_unit: WIND_UNITS = "meters_sec", language: str = "en", tz_name: str = "UTC") -> None:
2025-06-19 15:19:58 +02:00
"""
Initializes the OWM wrapper with localization settings.
Chooses API version, units, location and timezone preferences.
"""
self.api_key = api_key
self.temp_unit = temp_unit
self.wind_unit = wind_unit
self.language = language
self._api_version = api_version
2025-06-19 15:19:58 +02:00
if self._api_version == "3.0":
2025-06-19 15:19:58 +02:00
assert isinstance(lat, float) and isinstance(lon, float)
self.location_substring = (
2025-06-19 15:19:58 +02:00
f"lat={lat}&lon={lon}" if (lat and lon) else f"id={city_id}"
)
self.tz_zone = tz.gettz(tz_name)
2025-06-19 15:19:58 +02:00
logger.info(f"OWM wrapper initialized with API v{self._api_version}, lang={language}, tz={tz_name}.")
def get_weather_data_from_owm(self, weather: WEATHER_TYPE):
2025-06-19 15:19:58 +02:00
"""Gets either current or forecast weather data."""
if weather == "current":
2025-06-19 15:19:58 +02:00
url = f"{API_BASE_URL}/2.5/weather?{self.location_substring}&appid={self.api_key}&units=Metric&lang={self.language}"
data = get_json_from_url(url)
if self._api_version == "3.0":
2025-06-19 15:19:58 +02:00
uvi_url = f"{API_BASE_URL}/3.0/onecall?{self.location_substring}&appid={self.api_key}&exclude=minutely,hourly,daily&units=Metric&lang={self.language}"
data["uvi"] = get_json_from_url(uvi_url)["current"].get("uvi")
elif weather == "forecast":
2025-06-19 15:19:58 +02:00
url = f"{API_BASE_URL}/2.5/forecast?{self.location_substring}&appid={self.api_key}&units=Metric&lang={self.language}"
data = get_json_from_url(url)["list"]
return data
def get_current_weather(self) -> Dict:
"""
2025-06-19 15:19:58 +02:00
Fetches and processes current weather data.
Includes gust fallback and unit conversions.
"""
2025-06-19 15:19:58 +02:00
data = self.get_weather_data_from_owm("current")
wind_data = data.get("wind", {})
base_speed = wind_data.get("speed", 0.0)
gust_speed = wind_data.get("gust", base_speed)
converted_gust = self.get_converted_windspeed(gust_speed)
weather = {
"detailed_status": data["weather"][0]["description"],
"weather_icon_name": data["weather"][0]["icon"],
"temp": self.get_converted_temperature(data["main"]["temp"]),
"temp_feels_like": self.get_converted_temperature(data["main"]["feels_like"]),
"min_temp": self.get_converted_temperature(data["main"]["temp_min"]),
"max_temp": self.get_converted_temperature(data["main"]["temp_max"]),
"humidity": data["main"]["humidity"],
"wind": converted_gust,
"wind_gust": converted_gust,
"uvi": data.get("uvi"),
"sunrise": datetime.fromtimestamp(data["sys"]["sunrise"], tz=self.tz_zone),
"sunset": datetime.fromtimestamp(data["sys"]["sunset"], tz=self.tz_zone),
}
2025-06-19 15:19:58 +02:00
return weather
def get_weather_forecast(self) -> List[Dict]:
"""
2025-06-19 15:19:58 +02:00
Parses OWM 5-day / 3-hour forecast into a list of hourly dictionaries.
"""
2025-06-19 15:19:58 +02:00
forecasts = self.get_weather_data_from_owm("forecast")
hourly = []
for f in forecasts:
rain = f.get("rain", {}).get("3h", 0.0)
snow = f.get("snow", {}).get("3h", 0.0)
precip_mm = rain + snow
hourly.append({
"temp": self.get_converted_temperature(f["main"]["temp"]),
"min_temp": self.get_converted_temperature(f["main"]["temp_min"]),
"max_temp": self.get_converted_temperature(f["main"]["temp_max"]),
"precip_3h_mm": precip_mm,
"wind": self.get_converted_windspeed(f["wind"]["speed"]),
"wind_gust": self.get_converted_windspeed(f["wind"].get("gust", f["wind"]["speed"])),
"pressure": f["main"]["pressure"],
"humidity": f["main"]["humidity"],
"precip_probability": f.get("pop", 0.0) * 100.0,
"icon": f["weather"][0]["icon"],
"datetime": datetime.fromtimestamp(f["dt"], tz=self.tz_zone),
})
return hourly
def get_forecast_for_day(self, days_from_today: int) -> Dict:
"""
2025-06-19 15:19:58 +02:00
Aggregates hourly data into daily summary with min/max temp, precip and icon.
"""
2025-06-19 15:19:58 +02:00
forecasts = self.get_weather_forecast()
now = datetime.now(tz=self.tz_zone)
start = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=days_from_today)
end = start + timedelta(days=1)
daily = [f for f in forecasts if start <= f["datetime"] < end]
if not daily:
daily.append(forecasts[0]) # fallback to first forecast
temps = [f["temp"] for f in daily]
rain = sum(f["precip_3h_mm"] for f in daily)
icons = [f["icon"] for f in daily if f["icon"]]
icon = max(set(icons), key=icons.count)
return {
"datetime": start,
"icon": icon,
"temp_min": min(temps),
"temp_max": max(temps),
2025-06-19 15:19:58 +02:00
"precip_mm": rain
}
def get_converted_temperature(self, value: float) -> float:
2025-06-19 15:19:58 +02:00
return self.celsius_to_fahrenheit(value) if self.temp_unit == "fahrenheit" else value
def get_converted_windspeed(self, value: float) -> float:
if self.wind_unit == "km_hour":
2025-06-19 15:19:58 +02:00
return self.mps_to_kph(value)
if self.wind_unit == "miles_hour":
return self.mps_to_mph(value)
if self.wind_unit == "knots":
return self.mps_to_knots(value)
if self.wind_unit == "beaufort":
return self.mps_to_beaufort(value)
return value # default is meters/sec
@staticmethod
2025-06-19 15:19:58 +02:00
def mps_to_beaufort(mps: float) -> int:
thresholds = [0.3, 1.6, 3.4, 5.5, 8.0, 10.8, 13.9, 17.2, 20.8, 24.5, 28.5, 32.7]
2025-06-19 15:19:58 +02:00
return next((i for i, t in enumerate(thresholds) if mps < t), 12)
@staticmethod
2025-06-19 15:19:58 +02:00
def mps_to_mph(mps: float) -> float:
return mps * 2.23694
@staticmethod
2025-06-19 15:19:58 +02:00
def mps_to_kph(mps: float) -> float:
return mps * 3.6
@staticmethod
2025-06-19 15:19:58 +02:00
def mps_to_knots(mps: float) -> float:
return mps * 1.94384
@staticmethod
2025-06-19 15:19:58 +02:00
def celsius_to_fahrenheit(c: float) -> float:
return c * 9.0 / 5.0 + 32.0
def main():
2025-06-19 15:19:58 +02:00
# Simple test entry point
key = ""
2025-06-19 15:19:58 +02:00
city = 2643743 # London
owm = OpenWeatherMap(api_key=key, city_id=city, language="de", tz_name="Europe/Berlin")
print(owm.get_current_weather())
print(owm.get_forecast_for_day(days_from_today=2))
if __name__ == "__main__":
main()