From b80089985980470b01a418eb017c6d256400f215 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Sun, 19 Nov 2023 00:10:10 +0100 Subject: [PATCH] [#308] Refactored `weather.openweathermap` plugin. --- platypush/message/event/weather.py | 84 +++++++++- platypush/plugins/todoist/__init__.py | 4 +- platypush/plugins/weather/__init__.py | 33 +++- .../weather/openweathermap/__init__.py | 150 +++++++++--------- .../weather/openweathermap/manifest.yaml | 3 +- platypush/schemas/weather/__init__.py | 0 platypush/schemas/weather/openweathermap.py | 149 +++++++++++++++++ 7 files changed, 333 insertions(+), 90 deletions(-) create mode 100644 platypush/schemas/weather/__init__.py create mode 100644 platypush/schemas/weather/openweathermap.py diff --git a/platypush/message/event/weather.py b/platypush/message/event/weather.py index b35e07d623..47dfd39fe8 100644 --- a/platypush/message/event/weather.py +++ b/platypush/message/event/weather.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from platypush.message.event import Event @@ -8,17 +9,90 @@ class NewWeatherConditionEvent(Event): Event triggered when the weather condition changes """ - def __init__(self, *args, plugin_name: Optional[str] = None, **kwargs): - super().__init__(*args, plugin_name=plugin_name, **kwargs) + def __init__( + self, + *args, + plugin_name: str, + summary: Optional[str] = None, + icon: Optional[str] = None, + precip_intensity: Optional[float] = None, + precip_type: Optional[str] = None, + temperature: Optional[float] = None, + apparent_temperature: Optional[float] = None, + humidity: Optional[float] = None, + pressure: Optional[float] = None, + wind_speed: Optional[float] = None, + wind_gust: Optional[float] = None, + wind_direction: Optional[float] = None, + cloud_cover: Optional[float] = None, + visibility: Optional[float] = None, + sunrise: Optional[datetime] = None, + sunset: Optional[datetime] = None, + **kwargs, + ): + """ + :param plugin_name: Plugin that triggered the event. + :param summary: Summary of the weather condition. + :param icon: Icon representing the weather condition. + :param precip_intensity: Intensity of the precipitation. + :param precip_type: Type of precipitation. + :param temperature: Temperature, in the configured unit system. + :param apparent_temperature: Apparent temperature, in the configured + unit system. + :param humidity: Humidity percentage, between 0 and 100. + :param pressure: Pressure, in the configured unit system. + :param wind_speed: Wind speed, in the configured unit system. + :param wind_gust: Wind gust, in the configured unit system. + :param wind_direction: Wind direction, in degrees. + :param cloud_cover: Cloud cover percentage, between 0 and 100. + :param visibility: Visibility, in meters. + :param sunrise: Sunrise time. + :param sunset: Sunset time. + """ + super().__init__( + *args, + plugin_name=plugin_name, + summary=summary, + icon=icon, + precip_intensity=precip_intensity, + precip_type=precip_type, + temperature=temperature, + apparent_temperature=apparent_temperature, + humidity=humidity, + pressure=pressure, + wind_speed=wind_speed, + wind_gust=wind_gust, + wind_direction=wind_direction, + cloud_cover=cloud_cover, + visibility=visibility, + sunrise=sunrise, + sunset=sunset, + **kwargs, + ) class NewPrecipitationForecastEvent(Event): """ Event triggered when the precipitation forecast changes """ - def __init__(self, *args, plugin_name: Optional[str] = None, average: float, total: float, - time_frame: int, **kwargs): - super().__init__(*args, plugin_name=plugin_name, average=average, total=total, time_frame=time_frame, **kwargs) + + def __init__( + self, + *args, + plugin_name: Optional[str] = None, + average: float, + total: float, + time_frame: int, + **kwargs, + ): + super().__init__( + *args, + plugin_name=plugin_name, + average=average, + total=total, + time_frame=time_frame, + **kwargs, + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/todoist/__init__.py b/platypush/plugins/todoist/__init__.py index 0f19234c67..ddd5c5c3b4 100644 --- a/platypush/plugins/todoist/__init__.py +++ b/platypush/plugins/todoist/__init__.py @@ -23,7 +23,7 @@ class TodoistPlugin(Plugin): Todoist integration. You'll also need a Todoist token. You can get it `here - `_. + `_. """ _sync_timeout = 60.0 @@ -31,7 +31,7 @@ class TodoistPlugin(Plugin): def __init__(self, api_token: str, **kwargs): """ :param api_token: Todoist API token. You can get it `here - `_. + `_. """ super().__init__(**kwargs) diff --git a/platypush/plugins/weather/__init__.py b/platypush/plugins/weather/__init__.py index 95a0ce5962..dca429182b 100644 --- a/platypush/plugins/weather/__init__.py +++ b/platypush/plugins/weather/__init__.py @@ -1,14 +1,41 @@ from abc import ABC, abstractmethod +from typing import Optional -from platypush.plugins import Plugin, action +from platypush.message.event.weather import NewWeatherConditionEvent +from platypush.plugins import RunnablePlugin, action +from platypush.utils import get_plugin_name_by_class -class WeatherPlugin(Plugin, ABC): +class WeatherPlugin(RunnablePlugin, ABC): """ Base class for weather plugins. """ + def __init__(self, poll_interval: Optional[float] = 120, **kwargs): + super().__init__(poll_interval=poll_interval, **kwargs) + self._latest_weather = None + @action @abstractmethod def get_current_weather(self, *args, **kwargs): - raise NotImplementedError + raise NotImplementedError("get_current_weather not implemented") + + def main(self): + while not self.should_stop(): + try: + current_weather = dict(self.get_current_weather().output or {}) # type: ignore + current_weather.pop("time", None) + + if current_weather != self._latest_weather: + self._bus.post( + NewWeatherConditionEvent( + plugin_name=get_plugin_name_by_class(self.__class__), + **current_weather + ) + ) + + self._latest_weather = current_weather + except Exception as e: + self.logger.exception(e) + finally: + self.wait_stop(self.poll_interval) diff --git a/platypush/plugins/weather/openweathermap/__init__.py b/platypush/plugins/weather/openweathermap/__init__.py index 5046ad2ca0..86438ba7eb 100644 --- a/platypush/plugins/weather/openweathermap/__init__.py +++ b/platypush/plugins/weather/openweathermap/__init__.py @@ -1,44 +1,69 @@ from typing import Optional +import requests + from platypush.plugins import action -from platypush.plugins.http.request import HttpRequestPlugin from platypush.plugins.weather import WeatherPlugin +from platypush.schemas.weather.openweathermap import WeatherSchema -class WeatherOpenweathermapPlugin(HttpRequestPlugin, WeatherPlugin): +class WeatherOpenweathermapPlugin(WeatherPlugin): """ - OpenWeatherMap plugin. This is the advised plugin to use for weather forecasts since Darksky has officially - shut down their API. + OpenWeatherMap plugin. - You'll need an API token from `OpenWeatherMap `_ in order to use this API. + You'll need an API token from `OpenWeatherMap + `_ in order to use this API. """ + base_url = 'https://api.openweathermap.org/data/2.5/weather' - def __init__(self, token: str, location: Optional[str] = None, city_id: Optional[int] = None, - lat: Optional[float] = None, long: Optional[float] = None, - zip_code: Optional[str] = None, units: str = 'metric', **kwargs): + def __init__( + self, + token: str, + location: Optional[str] = None, + city_id: Optional[int] = None, + lat: Optional[float] = None, + long: Optional[float] = None, + zip_code: Optional[str] = None, + units: str = 'metric', + **kwargs + ): """ :param token: OpenWeatherMap API token. - :param location: If set, then this location will be used by default for weather lookup. If multiple locations - share the same name you can disambiguate by specifying the country code as well - e.g. ``London,GB``. - :param city_id: If set, then this city ID will be used by default for weather lookup. The full list of city IDs - is available `here `_. - :param lat: If lat/long are set, then the weather by default will be retrieved for the specified geo location. - :param long: If lat/long are set, then the weather by default will be retrieved for the specified geo location. - :param zip_code: If set, then this ZIP code (should be in the form ``zip,country_code``) will be used by default - for weather lookup. - :param units: Supported: ``metric`` (default), ``standard`` and ``imperial``. + :param location: If set, then this location will be used by default for + weather lookup. If multiple locations share the same name you can + disambiguate by specifying the country code as well - e.g. + ``London,GB``. + :param city_id: If set, then this city ID will be used by default for + weather lookup. The full list of city IDs is available `here + `_. + :param lat: If lat/long are set, then the weather by default will be + retrieved for the specified geo location. + :param long: If lat/long are set, then the weather by default will be + retrieved for the specified geo location. + :param zip_code: If set, then this ZIP code (should be in the form + ``zip,country_code``) will be used by default for weather lookup. + :param units: Supported: ``metric`` (default), ``standard`` and + ``imperial``. + :param poll_interval: How often the weather should be refreshed, in + seconds. """ - super().__init__(method='get', output='json', **kwargs) + super().__init__(**kwargs) self._token = token self._location_query = None - self._location_query = self._get_location_query(location=location, city_id=city_id, lat=lat, long=long, - zip_code=zip_code) + self._location_query = self._get_location_query( + location=location, city_id=city_id, lat=lat, long=long, zip_code=zip_code + ) self.units = units - def _get_location_query(self, location: Optional[str] = None, city_id: Optional[int] = None, - lat: Optional[float] = None, long: Optional[float] = None, - zip_code: Optional[str] = None) -> dict: + def _get_location_query( + self, + location: Optional[str] = None, + city_id: Optional[int] = None, + lat: Optional[float] = None, + long: Optional[float] = None, + zip_code: Optional[str] = None, + ) -> dict: if city_id: return {'id': city_id} if lat and long: @@ -52,59 +77,17 @@ class WeatherOpenweathermapPlugin(HttpRequestPlugin, WeatherPlugin): return self._location_query @action - def get(self, url, **kwargs): - kwargs['params'] = { - 'appid': self._token, - **kwargs.get('params', {}), - } - - return super().get(url, **kwargs) - - @staticmethod - def _convert_percentage(perc: Optional[float]) -> Optional[float]: - if perc is None: - return - return perc / 100. - - @staticmethod - def _m_to_km(m: Optional[float]) -> Optional[float]: - if m is None: - return - return m / 1000. - - @staticmethod - def _get_precip_type(response: dict) -> Optional[str]: - if response.get('snow', {}).get('1h', 0) > 0: - return 'snow' - if response.get('rain', {}).get('1h', 0) > 0: - return 'rain' - return - - @classmethod - def _convert_weather_response(cls, response: dict) -> dict: - return { - 'time': response.get('dt'), - 'summary': response.get('weather', [{'main': 'Unknown'}])[0].get('main', 'Unknown'), - 'icon': response.get('weather', [{'icon': 'unknown'}])[0].get('icon', 'unknown'), - 'precipIntensity': response.get('rain', response.get('snow', {})).get('1h', 0), - 'precipType': cls._get_precip_type(response), - 'temperature': response.get('main', {}).get('temp'), - 'apparentTemperature': response.get('main', {}).get('feels_like'), - 'humidity': cls._convert_percentage(response.get('main', {}).get('humidity')), - 'pressure': response.get('main', {}).get('pressure'), - 'windSpeed': response.get('wind', {}).get('speed'), - 'windDirection': response.get('wind', {}).get('deg'), - 'windGust': response.get('wind', {}).get('gust'), - 'cloudCover': cls._convert_percentage(response.get('clouds', {}).get('all')), - 'visibility': cls._m_to_km(response.get('visibility')), - 'sunrise': response.get('sys', {}).get('sunrise'), - 'sunset': response.get('sys', {}).get('sunset'), - } - - @action - def get_current_weather(self, *, location: Optional[str] = None, city_id: Optional[int] = None, - lat: Optional[float] = None, long: Optional[float] = None, zip_code: Optional[str] = None, - units: Optional[str] = None, **kwargs) -> dict: + def get_current_weather( + self, + *_, + location: Optional[str] = None, + city_id: Optional[int] = None, + lat: Optional[float] = None, + long: Optional[float] = None, + zip_code: Optional[str] = None, + units: Optional[str] = None, + **__ + ) -> dict: """ Returns the current weather. @@ -114,11 +97,20 @@ class WeatherOpenweathermapPlugin(HttpRequestPlugin, WeatherPlugin): :param long: Override the ``long`` configuration value. :param zip_code: Override the ``zip_code`` configuration value. :param units: Override the ``units`` configuration value. + :return: .. schema:: weather.openweathermap.WeatherSchema """ params = { + 'appid': self._token, 'units': units or self.units, - **self._get_location_query(location=location, city_id=city_id, lat=lat, long=long) + **self._get_location_query( + location=location, + city_id=city_id, + lat=lat, + long=long, + zip_code=zip_code, + ), } - response = self.get(self.base_url, params=params).output - return self._convert_weather_response(response) + rs = requests.get(self.base_url, params=params, timeout=10) + rs.raise_for_status() + return dict(WeatherSchema().dump(rs.json())) diff --git a/platypush/plugins/weather/openweathermap/manifest.yaml b/platypush/plugins/weather/openweathermap/manifest.yaml index 9de6ca3f6a..3da560b963 100644 --- a/platypush/plugins/weather/openweathermap/manifest.yaml +++ b/platypush/plugins/weather/openweathermap/manifest.yaml @@ -1,5 +1,6 @@ manifest: - events: {} + events: + - platypush.message.event.weather.NewWeatherConditionEvent install: pip: [] package: platypush.plugins.weather.openweathermap diff --git a/platypush/schemas/weather/__init__.py b/platypush/schemas/weather/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/platypush/schemas/weather/openweathermap.py b/platypush/schemas/weather/openweathermap.py new file mode 100644 index 0000000000..fab56e8afa --- /dev/null +++ b/platypush/schemas/weather/openweathermap.py @@ -0,0 +1,149 @@ +from typing import Optional +from marshmallow import fields, pre_dump +from marshmallow.schema import Schema + +from platypush.schemas import DateTime + + +def _get_precip_type(response: dict) -> Optional[str]: + if response.get('snow', {}).get('1h', 0) > 0: + return 'snow' + if response.get('rain', {}).get('1h', 0) > 0: + return 'rain' + return None + + +class WeatherSchema(Schema): + """ + Schema for weather data. + """ + + summary = fields.Function( + lambda obj: obj.get('weather', [{'main': 'Unknown'}])[0].get('main', 'Unknown'), + metadata={ + 'description': 'Summary of the weather condition', + 'example': 'Cloudy', + }, + ) + + icon = fields.Function( + lambda obj: obj.get('weather', [{'icon': 'unknown'}])[0].get('icon', 'unknown'), + metadata={ + 'description': 'Icon representing the weather condition', + 'example': 'cloudy', + }, + ) + + precip_intensity = fields.Function( + lambda obj: obj.get('rain', obj.get('snow', {})).get('1h', 0), + metadata={ + 'description': 'Intensity of the precipitation', + 'example': 0.0, + }, + ) + + precip_type = fields.Function( + _get_precip_type, + metadata={ + 'description': 'Type of precipitation', + 'example': 'rain', + }, + ) + + temperature = fields.Function( + lambda obj: obj.get('main', {}).get('temp'), + metadata={ + 'description': 'Temperature in the configured unit of measure', + 'example': 10.0, + }, + ) + + apparent_temperature = fields.Function( + lambda obj: obj.get('main', {}).get('feels_like'), + metadata={ + 'description': 'Apparent temperature in the configured unit of measure', + 'example': 9.0, + }, + ) + + humidity = fields.Function( + lambda obj: obj.get('main', {}).get('humidity'), + metadata={ + 'description': 'Humidity percentage, between 0 and 100', + 'example': 30, + }, + ) + + pressure = fields.Function( + lambda obj: obj.get('main', {}).get('pressure'), + metadata={ + 'description': 'Pressure in hPa', + 'example': 1000.0, + }, + ) + + wind_speed = fields.Function( + lambda obj: obj.get('wind', {}).get('speed'), + metadata={ + 'description': 'Wind speed in the configured unit of measure', + 'example': 10.0, + }, + ) + + wind_direction = fields.Function( + lambda obj: obj.get('wind', {}).get('deg'), + metadata={ + 'description': 'Wind direction in degrees', + 'example': 180.0, + }, + ) + + wind_gust = fields.Function( + lambda obj: obj.get('wind', {}).get('gust'), + metadata={ + 'description': 'Wind gust in the configured unit of measure', + 'example': 15.0, + }, + ) + + cloud_cover = fields.Function( + lambda obj: obj.get('clouds', {}).get('all'), + metadata={ + 'description': 'Cloud cover percentage between 0 and 100', + 'example': 0.5, + }, + ) + + visibility = fields.Float( + metadata={ + 'description': 'Visibility in meters', + 'example': 2000.0, + }, + ) + + sunrise = DateTime( + metadata={ + 'description': 'Sunrise time', + 'example': '2020-01-01T06:00:00+00:00', + }, + ) + + sunset = DateTime( + metadata={ + 'description': 'Sunset time', + 'example': '2020-01-01T18:00:00+00:00', + }, + ) + + @pre_dump + def _pre_dump(self, data: dict, **_) -> dict: + sun_data = data.pop('sys', {}) + sunrise = sun_data.get('sunrise') + sunset = sun_data.get('sunset') + + if sunrise is not None: + data['sunrise'] = sunrise + if sunset is not None: + data['sunset'] = sunset + + return data