diff --git a/src/meson.build b/src/meson.build index dd86840b..bb2d1881 100644 --- a/src/meson.build +++ b/src/meson.build @@ -32,6 +32,7 @@ app_py = [ 'status.py', 'unlock.py', 'volumeControl.py', + 'weather.py' ] app_css = [ diff --git a/src/stage.py b/src/stage.py index c73440c9..f3c2af57 100644 --- a/src/stage.py +++ b/src/stage.py @@ -20,6 +20,7 @@ from util import utils, trackers, settings from util.eventHandler import EventHandler from util.utils import DEBUG +from weather import WeatherWidget class Stage(Gtk.Window): """ @@ -69,6 +70,7 @@ def __init__(self, manager, away_message): self.overlay = None self.clock_widget = None self.albumart_widget = None + self.weather_widget = None self.unlock_dialog = None self.audio_panel = None self.info_panel = None @@ -291,6 +293,11 @@ def setup_delayed_components(self, data=None): except Exception as e: print("Problem setting up albumart widget: %s" % str(e)) self.albumart_widget = None + try: + self.setup_weather() + except Exception as e: + print("Problem setting up weather widget: %s" % str(e)) + self.weather_widget = None try: self.setup_status_bars() except Exception as e: @@ -324,6 +331,12 @@ def destroy_children(self): except Exception as e: print(e) + try: + if self.weather_widget is not None: + self.weather_widget.destroy() + except Exception as e: + print(e) + try: if self.info_panel is not None: self.info_panel.destroy() @@ -345,6 +358,7 @@ def destroy_children(self): self.unlock_dialog = None self.clock_widget = None self.albumart_widget = None + self.weather_widget = None self.info_panel = None self.audio_panel = None self.osk = None @@ -504,6 +518,23 @@ def setup_albumart(self): if settings.get_show_albumart(): self.albumart_widget.start_positioning() + def setup_weather(self): + """ + Construct the Weather widget and add it to the overlay, but only actually + show it if we're a) Not running a plug-in, and b) The user wants it via + preferences. + + Initially invisible, regardless - its visibility is controlled via its + own positioning timer. + """ + self.weather_widget = WeatherWidget(status.screen.get_mouse_monitor()) + self.add_child_widget(self.weather_widget) + + self.floaters.append(self.weather_widget) + + if settings.get_show_weather(): + self.weather_widget.start_positioning() + def setup_osk(self): self.osk = OnScreenKeyboard() diff --git a/src/util/geojs.py b/src/util/geojs.py new file mode 100644 index 00000000..e5458916 --- /dev/null +++ b/src/util/geojs.py @@ -0,0 +1,24 @@ +import json +from types import SimpleNamespace + +import requests + +from util.location import LocationProvider, LocationData + +URL = "https://get.geojs.io/v1/ip/geo.json" + +class GeoJSLocationProvider(LocationProvider): + """ + LocationProvider implementation for geojs.io + """ + + def __init__(self): + pass + + @staticmethod + def GetLocation() -> LocationData: + response = requests.get(URL) + + data = json.loads(response.text, object_hook=lambda d: SimpleNamespace(**d)) + + return LocationData(float(data.latitude), float(data.longitude), data.city, data.country, data.timezone, data.city) \ No newline at end of file diff --git a/src/util/location.py b/src/util/location.py new file mode 100644 index 00000000..b5204b9b --- /dev/null +++ b/src/util/location.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class LocationData: + lat: float + lon: float + city: Optional[str] = None + country: Optional[str] = None + timeZone: Optional[str] = None + entryText: Optional[str] = None + + +class LocationProvider(ABC): + @abstractmethod + def GetLocation(self) -> LocationData: + pass diff --git a/src/util/meson.build b/src/util/meson.build index 393cacab..d2cdeb41 100644 --- a/src/util/meson.build +++ b/src/util/meson.build @@ -3,10 +3,15 @@ app_py = [ 'eventHandler.py', 'fader.py', 'focusNavigator.py', + 'geojs.py', 'keybindings.py', + 'location.py', + 'openweathermap.py', 'settings.py', 'trackers.py', - 'utils.py' + 'utils.py', + 'weather.py', + 'weather_types.py' ] install_data(app_py, install_dir: join_paths(pkgdatadir, 'util')) diff --git a/src/util/openweathermap.py b/src/util/openweathermap.py new file mode 100644 index 00000000..243c1bc9 --- /dev/null +++ b/src/util/openweathermap.py @@ -0,0 +1,271 @@ +import json +from types import SimpleNamespace + +import requests +from gi.repository import Pango + +from util.weather_types import ( + APIUniqueField, + BuiltinIcons, + Condition, + CustomIcons, + Location, + LocationData, + WeatherData, + WeatherProvider, + Wind, +) + +OWM_URL = "https://api.openweathermap.org/data/2.5/weather" +# this is the OpenWeatherMap API key used by linux-mint/cinnamon-spices-applets/weather@mockturtl +# presumably belongs to the org? +OWM_API_KEY = "1c73f8259a86c6fd43c7163b543c8640" +OWM_SUPPORTED_LANGS = [ + "af", + "al", + "ar", + "az", + "bg", + "ca", + "cz", + "da", + "de", + "el", + "en", + "eu", + "fa", + "fi", + "fr", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "kr", + "la", + "lt", + "mk", + "no", + "nl", + "pl", + "pt", + "pt_br", + "ro", + "ru", + "se", + "sk", + "sl", + "sp", + "es", + "sr", + "th", + "tr", + "ua", + "uk", + "vi", + "zh_cn", + "zh_tw", + "zu", +] + + +class OWMWeatherProvider(WeatherProvider): + """ + WeatherProvider implementation for OpenWeatherMap.org + """ + + def __init__(self): + self.needsApiKey = False + self.prettyName = _("OpenWeatherMap") + self.name = "OpenWeatherMap_Open" + self.maxForecastSupport = 7 + self.maxHourlyForecastSupport = 0 + self.website = "https://openweathermap.org/" + self.remainingCalls = None + self.supportHourlyPrecipChance = False + self.supportHourlyPrecipVolume = False + + def GetWeather(self, loc: LocationData): + lang = self.locale_to_owm_lang(Pango.language_get_default().to_string()) + pref = list( + map( + lambda p: self.locale_to_owm_lang(p.to_string()), + Pango.language_get_preferred(), + ) + ) + if lang not in OWM_SUPPORTED_LANGS: + for locale in pref: + if self.locale_to_owm_lang(locale) in OWM_SUPPORTED_LANGS: + lang = self.locale_to_owm_lang(locale) + break + # if we still have not found a supported language... + if lang not in OWM_SUPPORTED_LANGS: + lang = "en" + + response = requests.get( + OWM_URL, + { + "lat": loc.lat, + "lon": loc.lon, + "units": "standard", + "appid": OWM_API_KEY, + "lang": lang, + }, + ) + + # actual object structure: https://github.com/linuxmint/cinnamon-spices-applets/weather@mockturtl/src/3_8/providers/openweathermap/payload/weather.ts + data = json.loads(response.text, object_hook=lambda d: SimpleNamespace(**d)) + return self.owm_data_to_weather_data(data) + + @staticmethod + def locale_to_owm_lang(locale_string): + if locale_string is None: + return "en" + + # Dialect? support by OWM + if ( + locale_string == "zh-cn" + or locale_string == "zh-cn" + or locale_string == "pt-br" + ): + return locale_string + + lang = locale_string.split("-")[0] + # OWM uses different language code for Swedish, Czech, Korean, Latvian, Norwegian + if lang == "sv": + return "se" + elif lang == "cs": + return "cz" + elif lang == "ko": + return "kr" + elif lang == "lv": + return "la" + elif lang == "nn" or lang == "nb": + return "no" + return lang + + def owm_data_to_weather_data(self, owm_data) -> WeatherData: + """ + Returns as much of a complete WeatherData object as we can + """ + return WeatherData( + **dict( + date=owm_data.dt, + sunrise=owm_data.sys.sunrise, + sunset=owm_data.sys.sunset, + coord=owm_data.coord, + location=Location( + **dict( + city=owm_data.name, + country=owm_data.sys.country, + url="https://openweathermap.org/city/%s" % owm_data.id, + ) + ), + condition=Condition( + **dict( + main=owm_data.weather[0].main, + description=owm_data.weather[0].description, + icons=self.owm_icon_to_builtin_icons(owm_data.weather[0].icon), + customIcon=self.owm_icon_to_custom_icon( + owm_data.weather[0].icon + ), + ) + ), + wind=Wind(**dict(speed=owm_data.wind.speed, degree=owm_data.wind.deg)), + temperature=owm_data.main.temp, + pressure=owm_data.main.pressure, + humidity=owm_data.main.humidity, + dewPoint=None, + extra_field=APIUniqueField( + **dict( + type="temperature", + name=_("Feels Like"), + value=owm_data.main.feels_like, + ) + ), + ) + ) + + @staticmethod + def owm_icon_to_builtin_icons(icon) -> list[BuiltinIcons]: + # https://openweathermap.org/weather-conditions + # fallback icons are: weather-clear-night + # weather-clear weather-few-clouds-night weather-few-clouds + # weather-fog weather-overcast weather-severe-alert weather-showers + # weather-showers-scattered weather-snow weather-storm + match icon: + case "10d": + # rain day */ + return [ + "weather-rain", + "weather-showers-scattered", + "weather-freezing-rain", + ] + case "10n": + # rain night */ + return [ + "weather-rain", + "weather-showers-scattered", + "weather-freezing-rain", + ] + case "09n": + # showers night*/ + return ["weather-showers"] + case "09d": + # showers day */ + return ["weather-showers"] + case "13d": + # snow day*/ + return ["weather-snow"] + case "13n": + # snow night */ + return ["weather-snow"] + case "50d": + # mist day */ + return ["weather-fog"] + case "50n": + # mist night */ + return ["weather-fog"] + case "04d": + # broken clouds day */ + return ["weather-overcast", "weather-clouds", "weather-few-clouds"] + case "04n": + # broken clouds night */ + return [ + "weather-overcast", + "weather-clouds-night", + "weather-few-clouds-night", + ] + case "03n": + # mostly cloudy (night) */ + return ["weather-clouds-night", "weather-few-clouds-night"] + case "03d": + # mostly cloudy (day) */ + return ["weather-clouds", "weather-few-clouds", "weather-overcast"] + case "02n": + # partly cloudy (night) */ + return ["weather-few-clouds-night"] + case "02d": + # partly cloudy (day) */ + return ["weather-few-clouds"] + case "01n": + # clear (night) */ + return ["weather-clear-night"] + case "01d": + # sunny */ + return ["weather-clear"] + case "11d": + # storm day */ + return ["weather-storm"] + case "11n": + # storm night */ + return ["weather-storm"] + case _: + return ["weather-severe-alert"] + + @staticmethod + def owm_icon_to_custom_icon(icon) -> CustomIcons: + return None # TODO diff --git a/src/util/settings.py b/src/util/settings.py index 58a7c4fe..fdc3354f 100644 --- a/src/util/settings.py +++ b/src/util/settings.py @@ -30,6 +30,9 @@ SHOW_CLOCK_KEY = "show-clock" SHOW_ALBUMART = "show-album-art" +SHOW_WEATHER = "show-weather" +WEATHER_LOCATION = "weather-location" +WEATHER_UNITS = "weather-units" ALLOW_SHORTCUTS = "allow-keyboard-shortcuts" ALLOW_MEDIA_CONTROL = "allow-media-control" SHOW_INFO_PANEL = "show-info-panel" @@ -56,6 +59,7 @@ # "settings.ss_settings.get_string(settings.DEFAULT_MESSAGE_KEY)" or keeping # instances of GioSettings wherever we need them. + def _check_string(string): if string and string != "": return string @@ -133,6 +137,24 @@ def get_show_clock(): def get_show_albumart(): return ss_settings.get_boolean(SHOW_ALBUMART) +def get_show_weather(): + return ss_settings.get_boolean(SHOW_WEATHER) + +def get_weather_location(): + location_string = ss_settings.get_string(WEATHER_LOCATION) # string LAT,LON + return _check_string(location_string) + +def get_weather_units(): + units = ["metric", "imperial"] + units_string = _check_string(ss_settings.get_string(WEATHER_UNITS)) + return units_string if units_string in units else "metric" + +def get_weather_font(): + # reusing the Clock widget Time font for now (it's big) + time_font = ss_settings.get_string(FONT_TIME_KEY) + + return _check_string(time_font) + def get_allow_shortcuts(): return ss_settings.get_boolean(ALLOW_SHORTCUTS) diff --git a/src/util/weather.py b/src/util/weather.py new file mode 100644 index 00000000..e7ffd4e7 --- /dev/null +++ b/src/util/weather.py @@ -0,0 +1,6 @@ +def k_to_c(k: float) -> float: + return round(k - 273.15, 1) + + +def k_to_f(k: float) -> int: + return round((9 / 5 * (k - 273.15) + 32)) diff --git a/src/util/weather_types.py b/src/util/weather_types.py new file mode 100644 index 00000000..b3e257be --- /dev/null +++ b/src/util/weather_types.py @@ -0,0 +1,194 @@ +# Mostly derived from/compatible with: +# https://github.com/linuxmint/cinnamon-spices-applets/weather@mockturtl/src/3_8/types.ts +# we don't use much of the data, but intending for easy porting of sources + customizations going forward +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Literal, Optional + +from util.location import LocationData +from util.weather import k_to_c, k_to_f + +type PrecipitationTypes = Literal[ + "rain", "snow", "none", "ice pellets", "freezing rain" +] +type BuiltinIcons = Literal[ + "weather-clear", + "weather-clear-night", + "weather-few-clouds", + "weather-few-clouds-night", + "weather-clouds", + "weather-many-clouds", + "weather-overcast", + "weather-showers-scattered", + "weather-showers-scattered-day", + "weather-showers-scattered-night", + "weather-showers-day", + "weather-showers-night", + "weather-showers", + "weather-rain", + "weather-freezing-rain", + "weather-snow", + "weather-snow-day", + "weather-snow-night", + "weather-snow-rain", + "weather-snow-scattered", + "weather-snow-scattered-day", + "weather-snow-scattered-night", + "weather-storm", + "weather-hail", + "weather-fog", + "weather-tornado", + "weather-windy", + "weather-breeze", + "weather-clouds-night", + "weather-severe-alert", +] +type CustomIcons = Literal[None] # TODO + + +@dataclass +class Coord: + lat: float + lon: float + + +@dataclass +class Location: + city: Optional[str] = None + country: Optional[str] = None + timeZone: Optional[str] = None + url: Optional[str] = None + tzOffset: Optional[float] = None + + +@dataclass +class StationInfo: + distanceFrom: float + name: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + area: Optional[str] = None + + +@dataclass +class Wind: + # m/s + speed: float + # meteorological degrees + degree: float + + +@dataclass +class Condition: + main: str + description: str + icons: Optional[list[BuiltinIcons]] = None + customIcon: Optional[CustomIcons] = None # TODO + + +class ForecastData: + date: int + temp_min: float # kelvin + temp_max: float # kelvin + condition: Condition + + +@dataclass +class Precipitation: + type: PrecipitationTypes + # /** in mm */ + volume: Optional[float] = None + # /** % */ + chance: Optional[float] = None + + +@dataclass +class HourlyForecastData: + date: int + # /** Kelvin */ + temp: float + condition: Condition + precipitation: Optional[Precipitation] = None + + +type APIUniqueFieldTypes = Literal["temperature", "percent", "string"] + + +@dataclass +class APIUniqueField: + name: str + value: str | float + type: APIUniqueFieldTypes + + +@dataclass +class ImmediatePrecipitation: + start: int + end: int + + +type AlertSeverity = Literal["minor", "moderate", "severe", "extreme", "unknown"] + + +@dataclass +class AlertData: + sender_name: str + level: AlertSeverity + title: str + description: str + icon: Optional[BuiltinIcons | CustomIcons] = None + + +@dataclass +class WeatherData: + date: int + coord: Coord + location: Location + condition: Condition + wind: Wind + stationInfo: Optional[StationInfo] = None + # /** in UTC with tz info */ + sunrise: Optional[float] = None + # /** in UTC with tz info */ + sunset: Optional[float] = None + # /** In Kelvin */ + temperature: Optional[float] = None + # /** In hPa */ + pressure: Optional[float] = None + # /** In percent */ + humidity: Optional[float] = None + # /** In kelvin */ + dewPoint: Optional[float] = None + forecasts: Optional[list[ForecastData]] = None + hourlyForecasts: Optional[list[HourlyForecastData]] = None + extra_field: Optional[APIUniqueField] = None + immediatePrecipitation: Optional[ImmediatePrecipitation] = None + alerts: Optional[list[AlertData]] = None + + def temp_f(self): + return k_to_f(self.temperature) if self.temperature else None + + def temp_c(self): + return k_to_c(self.temperature) if self.temperature else None + + +class WeatherProvider(ABC): + """ + WeatherProvider tries to emulate the interface specified in cinnamon-spices-applets/weather@mockturtl + such that other providers could be easily ported here in the future + """ + + needsApiKey: bool + prettyName: str + name: Literal["OpenWeatherMap_Open"] # expand in the future + maxForecastSupport: int + maxHourlyForecastSupport: int + website: str + remainingCalls: Optional[int] = None + supportHourlyPrecipChance: bool + supportHourlyPrecipVolume: bool + locationType: Literal["coordinates", "postcode"] + + @abstractmethod + def GetWeather(self, loc: LocationData) -> WeatherData: + pass diff --git a/src/weather.py b/src/weather.py new file mode 100644 index 00000000..fd36f41f --- /dev/null +++ b/src/weather.py @@ -0,0 +1,128 @@ +#!/usr/bin/python3 +from gi.repository import Gtk, Pango, Gio + +from baseWindow import BaseWindow +from floating import Floating +from util import settings, trackers +from util.geojs import GeoJSLocationProvider +from util.location import LocationData +from util.openweathermap import OWMWeatherProvider + +ICON_SIZE = 128 # probably works OK on most screens + + +class WeatherWidget(Floating, BaseWindow): + """ + WeatherWidget displays current weather on screen + + It is a child of the Stage's GtkOverlay, and its placement is + controlled by the overlay's child positioning function. + + When not Awake, it positions itself around all monitors + using a timer which randomizes its halign and valign properties + as well as its current monitor. + """ + + def __init__(self, initial_monitor=0, low_res=False): + super(WeatherWidget, self).__init__(initial_monitor, Gtk.Align.CENTER, Gtk.Align.START) + self.get_style_context().add_class("weather") + self.set_property("margin", 6) + + self.low_res = low_res + + if not settings.get_show_weather(): + return + + # overall container + big_box = Gtk.Box(Gtk.Orientation.HORIZONTAL) + self.add(big_box) + big_box.show() + + # icon + self.icon_size = ICON_SIZE + self.condition_icon = Gtk.Image() + self.condition_icon.set_size_request(self.icon_size, self.icon_size) + big_box.pack_start(self.condition_icon, False, False, 6) + self.condition_icon.show() + + # temp + condition + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + big_box.pack_start(box, True, False, 6) + box.show() + + self.temp_label = Gtk.Label() + self.temp_label.show() + self.temp_label.set_line_wrap(True) + self.temp_label.set_alignment(0.5, 0.5) + + box.pack_start(self.temp_label, True, False, 6) + + self.desc_label = Gtk.Label() + self.desc_label.show() + self.desc_label.set_line_wrap(True) + self.desc_label.set_alignment(0.5, 0.5) + + if self.low_res: + self.desc_label.set_max_width_chars(50) + else: + self.desc_label.set_max_width_chars(80) + + box.pack_start(self.desc_label, True, True, 6) + + # TODO: get from settings once other providers are available + self.location_provider = GeoJSLocationProvider() + self.weather_provider = OWMWeatherProvider() + + self.location = self.get_location() + self.update_weather() + + trackers.timer_tracker_get().start_seconds("weather", 600, self.update_weather) + + def get_location(self): + loc_string = settings.get_weather_location() + if loc_string == "" or "," not in loc_string: + return self.location_provider.GetLocation() + lat = float(loc_string.split(",")[0]) + lon = float(loc_string.split(",")[1]) + return LocationData(lat, lon) + + def update_weather(self): + desc_font = Pango.FontDescription.from_string(settings.get_message_font()) + weather_font = Pango.FontDescription.from_string(settings.get_weather_font()) + + if self.low_res: + desc_size = desc_font.get_size() * 0.66 + desc_font.set_size(int(desc_size)) + + weather_data = self.weather_provider.GetWeather(self.location) + + in_str = " " + _("in") + " " + + temp = ( + weather_data.temp_f() + if settings.get_weather_units() == "imperial" + else weather_data.temp_c() + ) + temp_string = str(round(temp)) + desc_message = ( + weather_data.condition.description.title() + + in_str + + weather_data.location.city.capitalize() + ) + + markup = '%s\n ' % ( + desc_font.to_string(), + desc_message, + ) + + self.temp_label.set_markup( + '%s°' % (weather_font.to_string(), temp_string) + ) + self.desc_label.set_markup(markup) + gicon = Gio.ThemedIcon.new_from_names(weather_data.condition.icons) + self.condition_icon.set_from_gicon(gicon, Gtk.IconSize.DIALOG) + self.condition_icon.set_pixel_size(self.icon_size) + + @staticmethod + def on_destroy(data=None): + trackers.timer_tracker_get().cancel("weather")