From b969afb1cfe4fe98986916052f0fdaf1b20e9d84 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 23 Nov 2023 01:06:38 +0100 Subject: [PATCH] Added support for weather forecast events and entities. --- .../components/panels/Entities/Weather.vue | 36 +++--- .../panels/Entities/WeatherForecast.vue | 108 ++++++++++++++++++ .../panels/Entities/WeatherIcon.vue | 38 ++++++ .../src/components/panels/Entities/meta.json | 8 ++ platypush/entities/managers/weather.py | 59 +++++----- platypush/entities/weather.py | 19 ++- platypush/message/event/weather.py | 28 ++++- platypush/plugins/weather/__init__.py | 102 +++++++++++++---- .../weather/openweathermap/__init__.py | 96 ++++++++++++---- platypush/schemas/weather/openweathermap.py | 18 +++ 10 files changed, 416 insertions(+), 96 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/WeatherForecast.vue create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/WeatherIcon.vue diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/Weather.vue b/platypush/backend/http/webapp/src/components/panels/Entities/Weather.vue index c4609bbce0..6107b02fef 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/Weather.vue +++ b/platypush/backend/http/webapp/src/components/panels/Entities/Weather.vue @@ -2,28 +2,16 @@
- +
-
+
+
- - - - @@ -184,11 +172,20 @@ + + diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/WeatherIcon.vue b/platypush/backend/http/webapp/src/components/panels/Entities/WeatherIcon.vue new file mode 100644 index 0000000000..0c0e2ead3e --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/WeatherIcon.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json index b4d89cd52b..0c986e2865 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json +++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json @@ -23,6 +23,14 @@ } }, + "weather_forecast": { + "name": "Weather Forecast", + "name_plural": "Weather Forecast", + "icon": { + "class": "fas fa-cloud-sun-rain" + } + }, + "button": { "name": "Button", "name_plural": "Buttons", diff --git a/platypush/entities/managers/weather.py b/platypush/entities/managers/weather.py index 3bab2f4765..39300f49e2 100644 --- a/platypush/entities/managers/weather.py +++ b/platypush/entities/managers/weather.py @@ -11,40 +11,41 @@ class WeatherEntityManager(EntityManager, ABC): Base class for integrations that support weather reports. """ - def transform_entities(self, entities: List[dict]): - from platypush.entities.weather import Weather + def transform_entities(self, entities: List[dict], *, type: str): + from platypush.entities.weather import Weather, WeatherForecast if not entities: return [] - weather = entities[0] plugin = get_plugin_name_by_class(self.__class__) - return super().transform_entities( - [ - Weather( - id=plugin, - name='Weather', - summary=weather.get('summary'), - icon=weather.get('icon'), - image=weather.get('image'), - precip_intensity=weather.get('precip_intensity'), - precip_type=weather.get('precip_type'), - temperature=weather.get('temperature'), - apparent_temperature=weather.get('apparent_temperature'), - humidity=weather.get('humidity'), - pressure=weather.get('pressure'), - rain_chance=weather.get('rain_chance'), - wind_speed=weather.get('wind_speed'), - wind_direction=weather.get('wind_direction'), - wind_gust=weather.get('wind_gust'), - cloud_cover=weather.get('cloud_cover'), - visibility=weather.get('visibility'), - sunrise=weather.get('sunrise'), - sunset=weather.get('sunset'), - units=weather.get('units'), - ) - ] - ) + + if type == 'weather': + # Current weather response + weather = entities[0] + weather.pop('time', None) + return super().transform_entities( + [ + Weather( + id=f'{plugin}:weather', + name='Weather', + **weather, + ) + ] + ) + + # Weather forecast response + if type == 'forecast': + return super().transform_entities( + [ + WeatherForecast( + id=f'{plugin}:forecast', + name='Forecast', + forecast=entities, + ) + ] + ) + + raise AssertionError(f'Unexpected weather entity type: {type}') @abstractmethod def status(self, *_, **__): diff --git a/platypush/entities/weather.py b/platypush/entities/weather.py index 03b3ad798f..988bf72541 100644 --- a/platypush/entities/weather.py +++ b/platypush/entities/weather.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, DateTime, Float, Integer, ForeignKey, String +from sqlalchemy import JSON, Column, DateTime, Float, Integer, ForeignKey, String from . import Entity @@ -35,3 +35,20 @@ class Weather(Entity): __mapper_args__ = { 'polymorphic_identity': __tablename__, } + + +class WeatherForecast(Entity): + """ + Weather forecast entity. + """ + + __tablename__ = 'weather_forecast' + + id = Column(Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True) + # forecast contains a list of serialized Weather entities + forecast = Column(JSON) + + __table_args__ = {'extend_existing': True} + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } diff --git a/platypush/message/event/weather.py b/platypush/message/event/weather.py index 5aefdb4c99..e4a27f8000 100644 --- a/platypush/message/event/weather.py +++ b/platypush/message/event/weather.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import List, Optional from platypush.message.event import Event @@ -104,4 +104,30 @@ class NewPrecipitationForecastEvent(Event): ) +class NewWeatherForecastEvent(Event): + """ + Event triggered when a new weather forecast is received. + """ + + def __init__( + self, + *args, + plugin_name: str, + forecast: List[dict], + **kwargs, + ): + """ + :param forecast: List of weather forecast items. Format: + + .. schema:: weather.openweathermap.WeatherSchema(many=True) + + """ + super().__init__( + *args, + plugin_name=plugin_name, + forecast=forecast, + **kwargs, + ) + + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/weather/__init__.py b/platypush/plugins/weather/__init__.py index c3bd3064a3..e4fb85f73d 100644 --- a/platypush/plugins/weather/__init__.py +++ b/platypush/plugins/weather/__init__.py @@ -1,9 +1,13 @@ from abc import ABC, abstractmethod -from typing import Optional +from typing import List, Optional from platypush.entities.managers.weather import WeatherEntityManager -from platypush.message.event.weather import NewWeatherConditionEvent +from platypush.message.event.weather import ( + NewWeatherConditionEvent, + NewWeatherForecastEvent, +) from platypush.plugins import RunnablePlugin, action +from platypush.schemas.weather.openweathermap import WeatherReportSchema from platypush.utils import get_plugin_name_by_class @@ -15,6 +19,7 @@ class WeatherPlugin(RunnablePlugin, WeatherEntityManager, ABC): def __init__(self, poll_interval: Optional[float] = 120, **kwargs): super().__init__(poll_interval=poll_interval, **kwargs) self._latest_weather = None + self._latest_forecast = None def _on_weather_data(self, weather: dict, always_publish: bool = False): if weather != self._latest_weather or always_publish: @@ -24,42 +29,99 @@ class WeatherPlugin(RunnablePlugin, WeatherEntityManager, ABC): ) ) - self.publish_entities([weather]) + self.publish_entities([weather], type='weather') self._latest_weather = weather + def _on_weather_forecast(self, forecast: List[dict], always_publish: bool = False): + if forecast != self._latest_forecast or always_publish: + self._bus.post( + NewWeatherForecastEvent( + plugin_name=get_plugin_name_by_class(self.__class__), + forecast=forecast, + ) + ) + + self.publish_entities(forecast, type='forecast') + + self._latest_forecast = forecast + @action - def get_current_weather(self, *args, **kwargs) -> dict: - weather = self._get_current_weather(*args, **kwargs) + def get_current_weather( + self, + *args, + lat: Optional[float] = None, + long: Optional[float] = None, + units: Optional[str] = None, + **kwargs + ) -> dict: + """ + Returns the current weather. + + :param lat: Override the ``lat`` configuration value. + :param long: Override the ``long`` configuration value. + :param units: Override the ``units`` configuration value. + :return: .. schema:: weather.openweathermap.WeatherSchema + """ + weather = self._get_current_weather( + *args, lat=lat, long=long, units=units, **kwargs + ) self._on_weather_data(weather, always_publish=True) return weather @action - def status(self, *args, **kwargs): + def get_forecast( + self, + *args, + lat: Optional[float] = None, + long: Optional[float] = None, + units: Optional[str] = None, + **kwargs + ) -> List[dict]: """ - Alias for :meth:`get_current_weather`. + Returns the weather forecast for the upcoming hours/days. + + :param lat: Override the ``lat`` configuration value. + :param long: Override the ``long`` configuration value. + :param units: Override the ``units`` configuration value. + :return: .. schema:: weather.openweathermap.WeatherSchema(many=True) """ - return self.get_current_weather(*args, **kwargs) + forecast = self._get_forecast(*args, lat=lat, long=long, units=units, **kwargs) + + if forecast: + self._on_weather_forecast(forecast, always_publish=True) + + return forecast + + @action + def status(self, *args, **kwargs) -> dict: + """ + :return: .. schema:: weather.openweathermap.WeatherReportSchema + """ + return self._status(*args, **kwargs) + + def _status(self, *args, **kwargs) -> dict: + return dict( + WeatherReportSchema().dump( + { + 'current': self.get_current_weather(*args, **kwargs).output, + 'forecast': self.get_forecast(*args, **kwargs).output, + } + ) + ) @abstractmethod def _get_current_weather(self, *args, **kwargs) -> dict: raise NotImplementedError("_get_current_weather not implemented") + @abstractmethod + def _get_forecast(self, *args, **kwargs) -> List[dict]: + raise NotImplementedError("_get_forecast not implemented") + def main(self): while not self.should_stop(): try: - current_weather = self._get_current_weather() or {} - 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 + self._status() except Exception as e: self.logger.exception(e) finally: diff --git a/platypush/plugins/weather/openweathermap/__init__.py b/platypush/plugins/weather/openweathermap/__init__.py index 64319d8e3e..f0455b7880 100644 --- a/platypush/plugins/weather/openweathermap/__init__.py +++ b/platypush/plugins/weather/openweathermap/__init__.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional import requests @@ -14,7 +14,7 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an `_ in order to use this API. """ - base_url = 'https://api.openweathermap.org/data/2.5/weather' + base_url = 'https://api.openweathermap.org/data/2.5' def __init__( self, @@ -25,7 +25,8 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an long: Optional[float] = None, zip_code: Optional[str] = None, units: str = 'metric', - **kwargs + lang: Optional[str] = None, + **kwargs, ): """ :param token: OpenWeatherMap API token. @@ -44,8 +45,7 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an ``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. + :param lang: Language code for the weather description (default: en). """ super().__init__(**kwargs) self._token = token @@ -54,6 +54,7 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an location=location, city_id=city_id, lat=lat, long=long, zip_code=zip_code ) self.units = units + self.lang = lang def _get_location_query( self, @@ -75,8 +76,9 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an assert self._location_query, 'Specify either location, city_id or lat/long' return self._location_query - def _get_current_weather( + def _weather_request( self, + path: str, *_, location: Optional[str] = None, city_id: Optional[int] = None, @@ -84,23 +86,13 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an long: Optional[float] = None, zip_code: Optional[str] = None, units: Optional[str] = None, - **__ + **__, ) -> dict: - """ - Returns the current weather. - - :param location: Override the ``location`` configuration value. - :param city_id: Override the ``city_id`` configuration value. - :param lat: Override the ``lat`` configuration value. - :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 - """ units = units or self.units params = { 'appid': self._token, 'units': units, + 'lang': self.lang, **self._get_location_query( location=location, city_id=city_id, @@ -110,8 +102,68 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an ), } - rs = requests.get(self.base_url, params=params, timeout=10) + rs = requests.get(f'{self.base_url}/{path}', params=params, timeout=10) rs.raise_for_status() - state = rs.json() - state['units'] = units - return dict(WeatherSchema().dump(state)) + return rs.json() + + 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: + units = units or self.units + return dict( + WeatherSchema().dump( + { + 'units': units, + **self._weather_request( + 'weather', + location=location, + city_id=city_id, + lat=lat, + long=long, + zip_code=zip_code, + units=units, + ), + } + ) + ) + + def _get_forecast( + 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, + **__, + ) -> List[dict]: + units = units or self.units + return list( + WeatherSchema().dump( + [ + { + 'units': units, + **data, + } + for data in self._weather_request( + 'forecast', + location=location, + city_id=city_id, + lat=lat, + long=long, + zip_code=zip_code, + units=units, + ).get('list', []) + ], + many=True, + ) + ) diff --git a/platypush/schemas/weather/openweathermap.py b/platypush/schemas/weather/openweathermap.py index ce88a5ba94..10a3675e08 100644 --- a/platypush/schemas/weather/openweathermap.py +++ b/platypush/schemas/weather/openweathermap.py @@ -22,6 +22,15 @@ class WeatherSchema(Schema): Schema for weather data. """ + time = DateTime( + required=True, + attribute='dt', + metadata={ + 'description': 'Time of the weather condition', + 'example': '2020-01-01T12:00:00+00:00', + }, + ) + summary = fields.Function( lambda obj: obj.get('weather', [{'main': 'Unknown'}])[0].get('main', 'Unknown'), metadata={ @@ -164,3 +173,12 @@ class WeatherSchema(Schema): data['sunset'] = self._timestamp_to_dt(sunset) return data + + +class WeatherReportSchema(Schema): + """ + Schema for full weather reports. + """ + + current = fields.Nested(WeatherSchema, required=True) + forecast = fields.List(fields.Nested(WeatherSchema), required=True)