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="head">
<div class="col-1 icon">
<EntityIcon
:entity="value"
:loading="loading"
:error="error" />
<WeatherIcon :value="value" />
</div>
<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 class="col-5 current-weather" @click.stop="isCollapsed = !isCollapsed">
<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"
v-text="normTemperature"
v-if="normTemperature != null" />
@ -184,11 +172,20 @@
<script>
import EntityIcon from "./EntityIcon"
import EntityMixin from "./EntityMixin"
import WeatherIcon from "./WeatherIcon"
export default {
components: {EntityIcon},
components: {EntityIcon, WeatherIcon},
mixins: [EntityMixin],
props: {
value: Object,
isForecast: {
type: Boolean,
default: false,
},
},
data() {
return {
isCollapsed: true,
@ -276,13 +273,6 @@ export default {
margin-right: 0.5em;
}
.weather-icon {
max-width: 100%;
max-height: 100%;
width: 1.5em;
height: 1.5em;
}
.temperature {
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": {
"name": "Button",
"name_plural": "Buttons",

View File

@ -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, *_, **__):

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
@ -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__,
}

View File

@ -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:

View File

@ -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:

View File

@ -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
<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__(
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,
)
)

View File

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