Files
Modus/src/services/weather.py
T
sankalp f4689b8076 refactor(config): reorganize config.toml into sections
- Move all settings into [general], [dock], [panel], [switcher], [notification]
- Remove [desktop_widgets] section, move items to panel/general
- Add dot notation support to config service for nested keys
- Update all config references across codebase
2026-07-28 13:34:23 +05:45

595 lines
19 KiB
Python

import datetime
import json
import threading
import time
import urllib.request
from pathlib import Path
from typing import Optional, Tuple
from fabric.core.service import Property, Service, Signal
from fabric.utils import GLib, logger
from shared.data import CACHE_DIR
CACHE_DURATION = 600
STALE_CACHE_MAX = 1800
UPDATE_INTERVAL = 600
IP_LOCATION_API = "http://ip-api.com/json/"
WEATHER_API_BASE = "https://api.open-meteo.com/v1/forecast"
_NIGHT_VARIANT_ICONS = frozenset(
(
"weather-clear",
"weather-clouds",
"weather-few-clouds",
"weather-overcast",
"weather-showers",
"weather-showers-scattered",
"weather-snow",
"weather-snow-scattered",
"weather-storm",
)
)
WEATHER_EMOJI_MAP = {
0: "☀️",
1: "🌤️",
2: "⛅",
3: "☁️",
45: "🌫️",
48: "🌫️",
51: "🌦️",
53: "🌧️",
55: "🌧️",
56: "🌨️",
57: "🌨️",
61: "🌦️",
63: "🌧️",
65: "🌧️",
66: "🌨️",
67: "🌨️",
71: "🌨️",
73: "❄️",
75: "❄️",
77: "🌨️",
80: "🌦️",
81: "🌧️",
82: "⛈️",
85: "🌨️",
86: "❄️",
95: "⛈️",
96: "⛈️",
99: "⛈️",
}
WEATHER_ICON_MAP = {
0: "weather-clear",
1: "weather-few-clouds",
2: "weather-clouds",
3: "weather-overcast",
45: "weather-fog",
48: "weather-fog",
51: "weather-showers-scattered",
53: "weather-showers",
55: "weather-showers",
61: "weather-showers-scattered",
80: "weather-showers-scattered",
63: "weather-showers",
65: "weather-showers",
81: "weather-showers",
82: "weather-storm",
56: "weather-freezing-rain",
57: "weather-freezing-rain",
66: "weather-freezing-rain",
67: "weather-freezing-rain",
71: "weather-snow",
73: "weather-snow",
75: "weather-snow",
77: "weather-hail",
85: "weather-snow-scattered",
86: "weather-snow-scattered",
95: "weather-storm",
96: "weather-storm",
99: "weather-storm",
}
WEATHER_DESC_MAP = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
56: "Light freezing drizzle",
57: "Dense freezing drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
66: "Light freezing rain",
67: "Heavy freezing rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Light rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with hail",
99: "Thunderstorm with heavy hail",
}
WEATHER_GRADIENT_MAP = {
0: "weather-clear",
1: "weather-mostly-clear",
2: "weather-partly-cloudy",
3: "weather-overcast",
45: "weather-fog",
48: "weather-fog",
51: "weather-light-rain",
53: "weather-rain",
55: "weather-rain",
61: "weather-light-rain",
80: "weather-light-rain",
63: "weather-heavy-rain",
65: "weather-heavy-rain",
81: "weather-heavy-rain",
82: "weather-storm",
56: "weather-snow",
57: "weather-snow",
66: "weather-snow",
67: "weather-snow",
71: "weather-snow",
73: "weather-heavy-snow",
75: "weather-heavy-snow",
77: "weather-snow",
85: "weather-snow",
86: "weather-heavy-snow",
95: "weather-storm",
96: "weather-storm",
99: "weather-storm",
}
class Cache:
def __init__(self, cache_file: Path, max_age: int):
self._cache_file = cache_file
self._max_age = max_age
self._data = None
self._time = 0
self._lock = threading.Lock()
self._load_cache()
def _load_cache(self):
try:
if self._cache_file.exists():
with self._lock:
self._time = self._cache_file.stat().st_mtime
self._data = json.loads(self._cache_file.read_text())
except Exception:
pass
def is_fresh(self) -> bool:
with self._lock:
return bool(self._data and time.time() - self._time < CACHE_DURATION)
def is_usable(self) -> bool:
with self._lock:
return bool(self._data and time.time() - self._time < self._max_age)
def get(self, allow_stale=False) -> Optional[dict]:
if self.is_fresh() or (allow_stale and self.is_usable()):
with self._lock:
return self._data.copy() if self._data else None
return None
def set(self, data: dict):
try:
tmp = self._cache_file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, separators=(",", ":")))
tmp.rename(self._cache_file)
with self._lock:
self._data = data
self._time = time.time()
except Exception:
pass
def fetch_api(url: str, timeout: int = 10) -> Optional[dict]:
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=timeout) as response:
return (
json.loads(response.read().decode()) if response.status == 200 else None
)
except Exception:
return None
def get_weather_info(code: int, is_day: int = 1) -> Tuple[str, str, str, str]:
emoji = WEATHER_EMOJI_MAP.get(code, "🌡️")
icon = WEATHER_ICON_MAP.get(code, "weather-none-available")
if not is_day and icon in _NIGHT_VARIANT_ICONS:
icon = f"{icon}-night"
description = WEATHER_DESC_MAP.get(code, "Unknown")
gradient = WEATHER_GRADIENT_MAP.get(code, "weather-clear")
return emoji, icon, description, gradient
def get_wind_direction(degrees: float) -> str:
dirs = [
"N",
"NNE",
"NE",
"ENE",
"E",
"ESE",
"SE",
"SSE",
"S",
"SSW",
"SW",
"WSW",
"W",
"WNW",
"NW",
"NNW",
]
return dirs[round(degrees / 22.5) % 16]
class Weather(Service):
_instance = None
@classmethod
def get_default(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
@Signal
def ready(self): ...
@Property(float, "readable", default_value=0.0)
def temperature(self) -> float:
return self._property_helper_temperature
@Property(float, "readable", default_value=0.0)
def feels_like(self) -> float:
return self._property_helper_feels_like
@Property(int, "readable", default_value=0)
def humidity(self) -> int:
return self._property_helper_humidity
@Property(int, "readable", default_value=0)
def pressure(self) -> int:
return self._property_helper_pressure
@Property(float, "readable", default_value=0.0)
def wind_speed(self) -> float:
return self._property_helper_wind_speed
@Property(str, "readable", default_value="")
def wind_direction(self) -> str:
return self._property_helper_wind_direction
@Property(float, "readable", default_value=0.0)
def precipitation(self) -> float:
return self._property_helper_precipitation
@Property(int, "readable", default_value=0)
def weather_code(self) -> int:
return self._property_helper_weather_code
@Property(str, "readable", default_value="🌡️")
def weather_emoji(self) -> str:
return self._property_helper_weather_emoji
@Property(str, "readable", default_value="cloud-sun-duotone")
def weather_icon(self) -> str:
return self._property_helper_weather_icon
@Property(str, "readable", default_value="weather-clear")
def weather_gradient(self) -> str:
return self._property_helper_weather_gradient
@Property(str, "readable", default_value="Unknown")
def weather_description(self) -> str:
return self._property_helper_weather_description
@Property(str, "readable", default_value="Loading...")
def location(self) -> str:
return self._property_helper_location
@Property(bool, "readable", default_value=True)
def is_loading(self) -> bool:
return self._property_helper_is_loading
@Property(bool, "readable", default_value=False)
def has_error(self) -> bool:
return self._property_helper_has_error
@Property(str, "readable", default_value="")
def error_message(self) -> str:
return self._property_helper_error_message
@Property(object, "readable", default_value=None)
def hourly_forecast(self) -> list:
return self._property_helper_hourly_forecast
@Property(object, "readable", default_value=None)
def daily_forecast(self) -> list:
return self._property_helper_daily_forecast
def __init__(self, **kwargs):
self._property_helper_temperature = 0.0
self._property_helper_feels_like = 0.0
self._property_helper_humidity = 0
self._property_helper_pressure = 0
self._property_helper_wind_speed = 0.0
self._property_helper_wind_direction = ""
self._property_helper_precipitation = 0.0
self._property_helper_weather_code = 0
self._property_helper_weather_emoji = "🌡️"
self._property_helper_weather_icon = "cloud-sun-duotone"
self._property_helper_weather_description = "Unknown"
self._property_helper_weather_gradient = "weather-clear"
self._property_helper_location = "Loading..."
self._property_helper_is_loading = True
self._property_helper_has_error = False
self._property_helper_error_message = ""
self._property_helper_hourly_forecast = []
self._property_helper_daily_forecast = []
self._weather_cache = Cache(
Path(CACHE_DIR) / "weather_cache.json", STALE_CACHE_MAX
)
self._location_cache = Cache(Path(CACHE_DIR) / "location_cache.json", 24 * 3600)
super().__init__(**kwargs)
cached_weather = self._weather_cache.get(allow_stale=True)
cached_location = self._location_cache.get(allow_stale=True)
if cached_weather and cached_location:
self._update_properties(cached_weather, cached_location)
self._start_fetch_thread()
GLib.timeout_add_seconds(UPDATE_INTERVAL, self._on_periodic_update)
from services.config import on_config_change
on_config_change(self._on_config_changed)
self.emit("ready")
def _on_config_changed(self, new_config: dict, old_config: dict) -> None:
new_general = new_config.get("general", {})
old_general = old_config.get("general", {})
if new_general.get("weather_location") != old_general.get("weather_location"):
self.refresh()
def _start_fetch_thread(self) -> None:
thread = threading.Thread(target=self._fetch_weather_data, daemon=True)
thread.start()
def _fetch_weather_data(self) -> None:
try:
GLib.idle_add(self._set_loading, True)
location_data = self._location_cache.get()
from services.config import config
target_loc = config().get("general.weather_location", "").strip()
# Invalidate location cache if the config target changed
if location_data and location_data.get("config_location", "") != target_loc:
location_data = None
with self._weather_cache._lock:
self._weather_cache._data = None # Invalidate weather cache
if not location_data:
if target_loc:
import urllib.parse
encoded_loc = urllib.parse.quote(target_loc)
geocode_url = f"https://geocoding-api.open-meteo.com/v1/search?name={encoded_loc}&count=1"
raw = fetch_api(geocode_url, 10)
if not raw or not raw.get("results"):
GLib.idle_add(self._set_error, "Location not found")
return
result = raw["results"][0]
location_data = {
"lat": result["latitude"],
"lon": result["longitude"],
"city": result.get("name", "Unknown"),
"country": result.get("country", ""),
"config_location": target_loc,
}
else:
raw = fetch_api(IP_LOCATION_API, 10)
if not raw or raw.get("status") != "success":
GLib.idle_add(self._set_error, "Location unavailable")
return
location_data = {
"lat": raw["lat"],
"lon": raw["lon"],
"city": raw.get("city", "Unknown"),
"country": raw.get("country", ""),
"config_location": "",
}
self._location_cache.set(location_data)
weather_data = self._weather_cache.get()
if not weather_data:
lat, lon = location_data["lat"], location_data["lon"]
params = "&".join(
[
f"latitude={lat}",
f"longitude={lon}",
"current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,surface_pressure,wind_speed_10m,wind_direction_10m,is_day",
"hourly=temperature_2m,weather_code,precipitation_probability",
"daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum",
"timezone=auto",
"forecast_days=7",
]
)
weather_data = fetch_api(f"{WEATHER_API_BASE}?{params}", 15)
if not weather_data:
weather_data = self._weather_cache.get(allow_stale=True)
if not weather_data:
GLib.idle_add(self._set_error, "Weather unavailable")
return
else:
self._weather_cache.set(weather_data)
GLib.idle_add(self._update_properties, weather_data, location_data)
except Exception as e:
logger.error(f"[WeatherService] Fetch error: {e}")
GLib.idle_add(self._set_error, str(e))
def _update_properties(self, weather_data: dict, location_data: dict) -> None:
try:
current = weather_data["current"]
self._property_helper_temperature = current["temperature_2m"]
self._property_helper_feels_like = current.get(
"apparent_temperature", current["temperature_2m"]
)
self._property_helper_humidity = round(current["relative_humidity_2m"])
self._property_helper_pressure = round(
current.get("surface_pressure", 1013)
)
self._property_helper_wind_speed = current.get("wind_speed_10m", 0)
self._property_helper_wind_direction = get_wind_direction(
current.get("wind_direction_10m", 0)
)
self._property_helper_precipitation = current.get("precipitation", 0)
self._property_helper_weather_code = current["weather_code"]
is_day = current.get("is_day", 1)
emoji, icon, description, gradient = get_weather_info(
self._property_helper_weather_code, is_day
)
self._property_helper_weather_emoji = emoji
self._property_helper_weather_icon = icon
self._property_helper_weather_description = description
self._property_helper_weather_gradient = gradient
city = location_data.get("city", "Unknown")
country = location_data.get("country", "")
self._property_helper_location = f"{city}, {country}" if country else city
self._property_helper_hourly_forecast = self._process_hourly_forecast(
weather_data.get("hourly", {})
)
self._property_helper_daily_forecast = self._process_daily_forecast(
weather_data.get("daily", {})
)
self._property_helper_has_error = False
self._property_helper_error_message = ""
self._property_helper_is_loading = False
for prop in [
"temperature",
"feels-like",
"humidity",
"pressure",
"wind-speed",
"wind-direction",
"precipitation",
"weather-code",
"weather-emoji",
"weather-icon",
"weather-description",
"weather-gradient",
"location",
"is-loading",
"has-error",
"error-message",
"hourly-forecast",
"daily-forecast",
]:
self.notify(prop)
except Exception as e:
logger.error(f"[WeatherService] Update error: {e}")
self._set_error(str(e))
def _process_hourly_forecast(self, hourly: dict) -> list:
if not hourly or not hourly.get("time"):
return []
forecast = []
current_hour = datetime.datetime.now().hour
for i in range(current_hour, min(current_hour + 24, len(hourly["time"]))):
try:
forecast.append(
{
"time": hourly["time"][i],
"temperature": hourly["temperature_2m"][i],
"weather_code": hourly["weather_code"][i],
"precipitation_probability": hourly.get(
"precipitation_probability", [0] * len(hourly["time"])
)[i],
}
)
except (IndexError, KeyError):
continue
return forecast
def _process_daily_forecast(self, daily: dict) -> list:
if not daily or not daily.get("time"):
return []
forecast = []
for i in range(min(7, len(daily["time"]))):
try:
forecast.append(
{
"date": daily["time"][i],
"temperature_max": daily["temperature_2m_max"][i],
"temperature_min": daily["temperature_2m_min"][i],
"weather_code": daily["weather_code"][i],
"precipitation": daily.get(
"precipitation_sum", [0] * len(daily["time"])
)[i],
}
)
except (IndexError, KeyError):
continue
return forecast
def _set_loading(self, value: bool) -> None:
self._property_helper_is_loading = value
self.notify("is-loading")
def _set_error(self, message: str) -> None:
self._property_helper_has_error = True
self._property_helper_error_message = message
self._property_helper_is_loading = False
self.notify("has-error")
self.notify("error-message")
self.notify("is-loading")
def _on_periodic_update(self) -> bool:
self._start_fetch_thread()
return True
def refresh(self) -> None:
self._start_fetch_thread()
def get_weather_info_for_code(
self, code: int, is_day: int = 1
) -> Tuple[str, str, str, str]:
return get_weather_info(code, is_day)