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")