Added support for weather forecast events and entities.

This commit is contained in:
Fabio Manganiello 2023-11-23 01:06:38 +01:00
parent 841a28066b
commit b969afb1cf
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
10 changed files with 416 additions and 96 deletions

View File

@ -2,28 +2,16 @@
<div class="entity weather-container"> <div class="entity weather-container">
<div class="head"> <div class="head">
<div class="col-1 icon"> <div class="col-1 icon">
<EntityIcon <WeatherIcon :value="value" />
:entity="value"
:loading="loading"
:error="error" />
</div> </div>
<div class="col-5 name"> <div class="col-5 name">
<div class="name" v-text="value.name" /> <div class="name" v-text="formatDateTime(value.time, year=false, seconds=false)" v-if="isForecast" />
<div class="name" v-text="value.name" v-else />
</div> </div>
<div class="col-5 current-weather" @click.stop="isCollapsed = !isCollapsed"> <div class="col-5 current-weather" @click.stop="isCollapsed = !isCollapsed">
<div class="weather-summary"> <div class="weather-summary">
<img :src="`/icons/openweathermap/dark/${value.icon}.png`"
:alt="value?.summary"
class="weather-icon"
v-if="value.icon" />
<img :src="value.image"
:alt="value?.summary"
class="weather-icon"
v-else-if="value.image" />
<span class="temperature" <span class="temperature"
v-text="normTemperature" v-text="normTemperature"
v-if="normTemperature != null" /> v-if="normTemperature != null" />
@ -184,11 +172,20 @@
<script> <script>
import EntityIcon from "./EntityIcon" import EntityIcon from "./EntityIcon"
import EntityMixin from "./EntityMixin" import EntityMixin from "./EntityMixin"
import WeatherIcon from "./WeatherIcon"
export default { export default {
components: {EntityIcon}, components: {EntityIcon, WeatherIcon},
mixins: [EntityMixin], mixins: [EntityMixin],
props: {
value: Object,
isForecast: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
isCollapsed: true, isCollapsed: true,
@ -276,13 +273,6 @@ export default {
margin-right: 0.5em; margin-right: 0.5em;
} }
.weather-icon {
max-width: 100%;
max-height: 100%;
width: 1.5em;
height: 1.5em;
}
.temperature { .temperature {
margin-left: 0.5em; margin-left: 0.5em;
} }

View File

@ -0,0 +1,108 @@
<template>
<div class="entity weather-forecast-container">
<div class="head">
<div class="col-1 icon">
<WeatherIcon :value="firstForecast" v-if="firstForecast" />
<EntityIcon
:entity="value"
:loading="loading"
:error="error"
v-else />
</div>
<div class="col-5 name" @click.stop="isCollapsed = !isCollapsed">
<div class="name" v-text="value.name" />
</div>
<div class="col-5 summary-container" @click.stop="isCollapsed = !isCollapsed">
<div class="summary">
<span class="temperature"
v-text="normTemperature"
v-if="normTemperature != null" />
</div>
</div>
<div class="col-1 collapse-toggler" @click.stop="isCollapsed = !isCollapsed">
<i class="fas"
:class="{'fa-chevron-down': isCollapsed, 'fa-chevron-up': !isCollapsed}" />
</div>
</div>
<div class="body children attributes fade-in" v-if="!isCollapsed">
<div class="child" v-for="weather in value.forecast" :key="weather.time">
<Weather :value="weather" :is-forecast="true" />
</div>
</div>
</div>
</template>
<script>
import EntityIcon from "./EntityIcon"
import EntityMixin from "./EntityMixin"
import Weather from "./Weather"
import WeatherIcon from "./WeatherIcon"
export default {
components: {EntityIcon, Weather, WeatherIcon},
mixins: [EntityMixin],
data() {
return {
isCollapsed: true,
}
},
computed: {
firstForecast() {
return this.value?.forecast?.[0]
},
normTemperature() {
if (this.firstForecast?.temperature == null)
return null
return Math.round(this.firstForecast.temperature).toFixed(1) + "°"
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.weather-forecast-container {
.body {
padding-top: 0;
max-height: 35em;
overflow-y: auto;
.child {
padding: 0;
margin: 0 -0.5em;
&:hover {
background: $hover-bg !important;
cursor: pointer;
}
}
}
.summary-container {
display: flex;
align-items: center;
font-size: 1.25em;
.summary {
display: flex;
align-items: center;
justify-content: flex-end;
flex-grow: 1;
margin-right: 0.5em;
}
.temperature {
margin-left: 0.5em;
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<span class="entity weather-icon-container">
<img :src="`/icons/openweathermap/dark/${value.icon}.png`"
:alt="value?.summary"
class="weather-icon"
v-if="value.icon" />
<img :src="value.image"
:alt="value?.summary"
class="weather-icon"
v-else-if="value.image" />
</span>
</template>
<script>
export default {
props: {
value: Object,
},
}
</script>
<style lang="scss" scoped>
@import "common";
.weather-icon-container {
display: flex;
align-items: center;
justify-content: center;
height: 2.5em;
.weather-icon {
max-width: 100%;
height: 100%;
margin: 0.5em 0.5em 0.5em 1em;
}
}
</style>

View File

@ -23,6 +23,14 @@
} }
}, },
"weather_forecast": {
"name": "Weather Forecast",
"name_plural": "Weather Forecast",
"icon": {
"class": "fas fa-cloud-sun-rain"
}
},
"button": { "button": {
"name": "Button", "name": "Button",
"name_plural": "Buttons", "name_plural": "Buttons",

View File

@ -11,40 +11,41 @@ class WeatherEntityManager(EntityManager, ABC):
Base class for integrations that support weather reports. Base class for integrations that support weather reports.
""" """
def transform_entities(self, entities: List[dict]): def transform_entities(self, entities: List[dict], *, type: str):
from platypush.entities.weather import Weather from platypush.entities.weather import Weather, WeatherForecast
if not entities: if not entities:
return [] return []
weather = entities[0]
plugin = get_plugin_name_by_class(self.__class__) plugin = get_plugin_name_by_class(self.__class__)
return super().transform_entities(
[ if type == 'weather':
Weather( # Current weather response
id=plugin, weather = entities[0]
name='Weather', weather.pop('time', None)
summary=weather.get('summary'), return super().transform_entities(
icon=weather.get('icon'), [
image=weather.get('image'), Weather(
precip_intensity=weather.get('precip_intensity'), id=f'{plugin}:weather',
precip_type=weather.get('precip_type'), name='Weather',
temperature=weather.get('temperature'), **weather,
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'), # Weather forecast response
wind_direction=weather.get('wind_direction'), if type == 'forecast':
wind_gust=weather.get('wind_gust'), return super().transform_entities(
cloud_cover=weather.get('cloud_cover'), [
visibility=weather.get('visibility'), WeatherForecast(
sunrise=weather.get('sunrise'), id=f'{plugin}:forecast',
sunset=weather.get('sunset'), name='Forecast',
units=weather.get('units'), forecast=entities,
) )
] ]
) )
raise AssertionError(f'Unexpected weather entity type: {type}')
@abstractmethod @abstractmethod
def status(self, *_, **__): def status(self, *_, **__):

View File

@ -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 from . import Entity
@ -35,3 +35,20 @@ class Weather(Entity):
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': __tablename__, '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__,
}

View File

@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import List, Optional
from platypush.message.event import Event 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: # vim:sw=4:ts=4:et:

View File

@ -1,9 +1,13 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional from typing import List, Optional
from platypush.entities.managers.weather import WeatherEntityManager 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.plugins import RunnablePlugin, action
from platypush.schemas.weather.openweathermap import WeatherReportSchema
from platypush.utils import get_plugin_name_by_class 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): def __init__(self, poll_interval: Optional[float] = 120, **kwargs):
super().__init__(poll_interval=poll_interval, **kwargs) super().__init__(poll_interval=poll_interval, **kwargs)
self._latest_weather = None self._latest_weather = None
self._latest_forecast = None
def _on_weather_data(self, weather: dict, always_publish: bool = False): def _on_weather_data(self, weather: dict, always_publish: bool = False):
if weather != self._latest_weather or always_publish: 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 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 @action
def get_current_weather(self, *args, **kwargs) -> dict: def get_current_weather(
weather = self._get_current_weather(*args, **kwargs) 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) self._on_weather_data(weather, always_publish=True)
return weather return weather
@action @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 @abstractmethod
def _get_current_weather(self, *args, **kwargs) -> dict: def _get_current_weather(self, *args, **kwargs) -> dict:
raise NotImplementedError("_get_current_weather not implemented") 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): def main(self):
while not self.should_stop(): while not self.should_stop():
try: try:
current_weather = self._get_current_weather() or {} self._status()
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: except Exception as e:
self.logger.exception(e) self.logger.exception(e)
finally: finally:

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import List, Optional
import requests import requests
@ -14,7 +14,7 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an
<https://openweathermap.org/api>`_ in order to use this API. <https://openweathermap.org/api>`_ 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__( def __init__(
self, self,
@ -25,7 +25,8 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an
long: Optional[float] = None, long: Optional[float] = None,
zip_code: Optional[str] = None, zip_code: Optional[str] = None,
units: str = 'metric', units: str = 'metric',
**kwargs lang: Optional[str] = None,
**kwargs,
): ):
""" """
:param token: OpenWeatherMap API token. :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. ``zip,country_code``) will be used by default for weather lookup.
:param units: Supported: ``metric`` (default), ``standard`` and :param units: Supported: ``metric`` (default), ``standard`` and
``imperial``. ``imperial``.
:param poll_interval: How often the weather should be refreshed, in :param lang: Language code for the weather description (default: en).
seconds.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self._token = token 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 location=location, city_id=city_id, lat=lat, long=long, zip_code=zip_code
) )
self.units = units self.units = units
self.lang = lang
def _get_location_query( def _get_location_query(
self, 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' assert self._location_query, 'Specify either location, city_id or lat/long'
return self._location_query return self._location_query
def _get_current_weather( def _weather_request(
self, self,
path: str,
*_, *_,
location: Optional[str] = None, location: Optional[str] = None,
city_id: Optional[int] = None, city_id: Optional[int] = None,
@ -84,23 +86,13 @@ class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-an
long: Optional[float] = None, long: Optional[float] = None,
zip_code: Optional[str] = None, zip_code: Optional[str] = None,
units: Optional[str] = None, units: Optional[str] = None,
**__ **__,
) -> dict: ) -> 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 units = units or self.units
params = { params = {
'appid': self._token, 'appid': self._token,
'units': units, 'units': units,
'lang': self.lang,
**self._get_location_query( **self._get_location_query(
location=location, location=location,
city_id=city_id, 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() rs.raise_for_status()
state = rs.json() return rs.json()
state['units'] = units
return dict(WeatherSchema().dump(state)) 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,
)
)

View File

@ -22,6 +22,15 @@ class WeatherSchema(Schema):
Schema for weather data. 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( summary = fields.Function(
lambda obj: obj.get('weather', [{'main': 'Unknown'}])[0].get('main', 'Unknown'), lambda obj: obj.get('weather', [{'main': 'Unknown'}])[0].get('main', 'Unknown'),
metadata={ metadata={
@ -164,3 +173,12 @@ class WeatherSchema(Schema):
data['sunset'] = self._timestamp_to_dt(sunset) data['sunset'] = self._timestamp_to_dt(sunset)
return data return data
class WeatherReportSchema(Schema):
"""
Schema for full weather reports.
"""
current = fields.Nested(WeatherSchema, required=True)
forecast = fields.List(fields.Nested(WeatherSchema), required=True)