Implemented support for weather entities.

This commit is contained in:
Fabio Manganiello 2023-11-20 01:46:01 +01:00
parent bf8f31545a
commit b8a4b9e4c5
Signed by: blacklight
GPG key ID: D90FBA7F76362774
9 changed files with 430 additions and 12 deletions

View file

@ -134,6 +134,9 @@
"variable": { "variable": {
"class": "fas fa-square-root-variable" "class": "fas fa-square-root-variable"
}, },
"weather.openweathermap": {
"class": "fas fa-cloud-sun-rain"
},
"zigbee.mqtt": { "zigbee.mqtt": {
"imgUrl": "/icons/zigbee.svg" "imgUrl": "/icons/zigbee.svg"
}, },

View file

@ -0,0 +1,277 @@
<template>
<div class="entity weather-container">
<div class="head">
<div class="col-1 icon">
<EntityIcon
:entity="value"
:loading="loading"
:error="error" />
</div>
<div class="col-5 name">
<div class="name" v-text="value.name" />
</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" />
<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-if="value.summary">
<div class="col-s-12 col-m-6 label">
<div class="name">Summary</div>
</div>
<div class="value">
<div class="name" v-text="value.summary" />
</div>
</div>
<div class="child" v-if="value.temperature">
<div class="col-s-12 col-m-6 label">
<div class="name">Temperature</div>
</div>
<div class="value">
<div class="name" v-text="normTemperature" />
</div>
</div>
<div class="child" v-if="normApparentTemperature">
<div class="col-s-12 col-m-6 label">
<div class="name">Feels Like</div>
</div>
<div class="value">
<div class="name" v-text="normApparentTemperature" />
</div>
</div>
<div class="child" v-if="value.humidity">
<div class="col-s-12 col-m-6 label">
<div class="name">Humidity</div>
</div>
<div class="value">
<div class="name" v-text="normPercentage(value.humidity)" />
</div>
</div>
<div class="child" v-if="normPrecipIntensity && precipIconClass">
<div class="col-s-12 col-m-6 label">
<div class="name">Precipitation</div>
</div>
<div class="value">
<div class="name">
<i :class="precipIconClass" /> &nbsp;
<span v-text="normPrecipIntensity" />
</div>
</div>
</div>
<div class="child" v-if="value.cloud_cover">
<div class="col-s-12 col-m-6 label">
<div class="name">Cloud Cover</div>
</div>
<div class="value">
<div class="name" v-text="normPercentage(value.cloud_cover)" />
</div>
</div>
<div class="child" v-if="normPressure">
<div class="col-s-12 col-m-6 label">
<div class="name">Pressure</div>
</div>
<div class="value">
<div class="name" v-text="normPressure" />
</div>
</div>
<div class="child" v-if="value.wind_speed != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Wind</div>
</div>
<div class="value">
<div class="name">
<span v-text="value.wind_speed" />
<span v-if="isMetric">m/s</span>
<span v-else>mph</span>
</div>
</div>
</div>
<div class="child" v-if="value.wind_gust != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Wind</div>
</div>
<div class="value">
<div class="name">
<span v-text="value.wind_gust" />
<span v-if="isMetric">m/s</span>
<span v-else>mph</span>
</div>
</div>
</div>
<div class="child" v-if="value.wind_direction != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Wind Direction</div>
</div>
<div class="value">
<span class="name" v-text="value.wind_direction" />&deg;
</div>
</div>
<div class="child" v-if="value.visibility != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Visibility</div>
</div>
<div class="value">
<div class="name">
<span v-text="value.visibility" />
<span v-if="isMetric">m</span>
<span v-else>mi</span>
</div>
</div>
</div>
<div class="child" v-if="value.sunrise != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Sunrise</div>
</div>
<div class="value">
<div class="name" v-text="formatDateTime(value.sunrise)" />
</div>
</div>
<div class="child" v-if="value.sunset != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Sunset</div>
</div>
<div class="value">
<div class="name" v-text="formatDateTime(value.sunset)" />
</div>
</div>
</div>
</div>
</template>
<script>
import EntityIcon from "./EntityIcon"
import EntityMixin from "./EntityMixin"
export default {
components: {EntityIcon},
mixins: [EntityMixin],
data() {
return {
isCollapsed: true,
}
},
computed: {
normTemperature() {
if (this.value.temperature == null)
return null
return Math.round(this.value.temperature).toFixed(1) + "°"
},
normApparentTemperature() {
if (this.value.apparent_temperature == null)
return null
return Math.round(this.value.apparent_temperature).toFixed(1) + "°"
},
normPrecipIntensity() {
if (this.value.precip_intensity == null)
return null
return (
Math.round(this.value.precip_intensity).toFixed(1) +
(this.isMetric ? "mm" : "in") + "/h"
)
},
normPressure() {
if (this.value.pressure == null)
return null
return Math.round(this.value.pressure) + "hPa"
},
precipIconClass() {
if (this.value.precip_type == null)
return null
switch (this.value.precip_type.toLowerCase()) {
case "rain":
return "fas fa-cloud-rain"
case "snow":
return "fas fa-snowflake"
case "sleet":
return "fa-cloud-meatball"
default:
return null
}
},
isMetric() {
return this.value.units === "metric"
},
},
methods: {
normPercentage(value) {
if (value == null)
return null
return Math.round(value) + "%"
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.weather-container {
.current-weather {
display: flex;
align-items: center;
font-size: 1.25em;
.weather-summary {
display: flex;
align-items: center;
justify-content: flex-end;
flex-grow: 1;
margin-right: 0.5em;
}
.weather-icon {
max-width: 100%;
max-height: 100%;
width: 1.5em;
height: 1.5em;
}
.temperature {
margin-left: 0.5em;
}
}
}
</style>

View file

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

View file

@ -0,0 +1,49 @@
from abc import ABC, abstractmethod
from typing import List
from platypush.utils import get_plugin_name_by_class
from . import EntityManager
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
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'),
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'),
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'),
)
]
)
@abstractmethod
def status(self, *_, **__):
raise NotImplementedError

View file

@ -0,0 +1,35 @@
from sqlalchemy import Column, DateTime, Float, Integer, ForeignKey, String
from . import Entity
class Weather(Entity):
"""
Weather entity.
"""
__tablename__ = 'weather'
id = Column(Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True)
summary = Column(String)
icon = Column(String)
precip_intensity = Column(Float)
precip_type = Column(String)
temperature = Column(Float)
apparent_temperature = Column(Float)
humidity = Column(Float)
pressure = Column(Float)
wind_speed = Column(Float)
wind_direction = Column(Float)
wind_gust = Column(Float)
cloud_cover = Column(Float)
visibility = Column(Float)
sunrise = Column(DateTime)
sunset = Column(DateTime)
units = Column(String)
__table_args__ = {'extend_existing': True}
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}

View file

@ -28,6 +28,7 @@ class NewWeatherConditionEvent(Event):
visibility: Optional[float] = None, visibility: Optional[float] = None,
sunrise: Optional[datetime] = None, sunrise: Optional[datetime] = None,
sunset: Optional[datetime] = None, sunset: Optional[datetime] = None,
units: str = 'metric',
**kwargs, **kwargs,
): ):
""" """
@ -48,6 +49,7 @@ class NewWeatherConditionEvent(Event):
:param visibility: Visibility, in meters. :param visibility: Visibility, in meters.
:param sunrise: Sunrise time. :param sunrise: Sunrise time.
:param sunset: Sunset time. :param sunset: Sunset time.
:param units: Unit system (default: metric).
""" """
super().__init__( super().__init__(
*args, *args,
@ -67,6 +69,7 @@ class NewWeatherConditionEvent(Event):
visibility=visibility, visibility=visibility,
sunrise=sunrise, sunrise=sunrise,
sunset=sunset, sunset=sunset,
units=units,
**kwargs, **kwargs,
) )

View file

@ -1,12 +1,13 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional from typing import Optional
from platypush.entities.managers.weather import WeatherEntityManager
from platypush.message.event.weather import NewWeatherConditionEvent from platypush.message.event.weather import NewWeatherConditionEvent
from platypush.plugins import RunnablePlugin, action from platypush.plugins import RunnablePlugin, action
from platypush.utils import get_plugin_name_by_class from platypush.utils import get_plugin_name_by_class
class WeatherPlugin(RunnablePlugin, ABC): class WeatherPlugin(RunnablePlugin, WeatherEntityManager, ABC):
""" """
Base class for weather plugins. Base class for weather plugins.
""" """
@ -15,15 +16,39 @@ class WeatherPlugin(RunnablePlugin, ABC):
super().__init__(poll_interval=poll_interval, **kwargs) super().__init__(poll_interval=poll_interval, **kwargs)
self._latest_weather = None self._latest_weather = None
def _on_weather_data(self, weather: dict, always_publish: bool = False):
if weather != self._latest_weather or always_publish:
self._bus.post(
NewWeatherConditionEvent(
plugin_name=get_plugin_name_by_class(self.__class__), **weather
)
)
self.publish_entities([weather])
self._latest_weather = weather
@action @action
def get_current_weather(self, *args, **kwargs) -> dict:
weather = self._get_current_weather(*args, **kwargs)
self._on_weather_data(weather, always_publish=True)
return weather
@action
def status(self, *args, **kwargs):
"""
Alias for :meth:`get_current_weather`.
"""
return self.get_current_weather(*args, **kwargs)
@abstractmethod @abstractmethod
def get_current_weather(self, *args, **kwargs): def _get_current_weather(self, *args, **kwargs) -> dict:
raise NotImplementedError("get_current_weather not implemented") raise NotImplementedError("_get_current_weather not implemented")
def main(self): def main(self):
while not self.should_stop(): while not self.should_stop():
try: try:
current_weather = dict(self.get_current_weather().output or {}) # type: ignore current_weather = self._get_current_weather() or {}
current_weather.pop("time", None) current_weather.pop("time", None)
if current_weather != self._latest_weather: if current_weather != self._latest_weather:

View file

@ -2,12 +2,11 @@ from typing import Optional
import requests import requests
from platypush.plugins import action
from platypush.plugins.weather import WeatherPlugin from platypush.plugins.weather import WeatherPlugin
from platypush.schemas.weather.openweathermap import WeatherSchema from platypush.schemas.weather.openweathermap import WeatherSchema
class WeatherOpenweathermapPlugin(WeatherPlugin): class WeatherOpenweathermapPlugin(WeatherPlugin): # pylint: disable=too-many-ancestors
""" """
OpenWeatherMap plugin. OpenWeatherMap plugin.
@ -76,8 +75,7 @@ class WeatherOpenweathermapPlugin(WeatherPlugin):
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
@action def _get_current_weather(
def get_current_weather(
self, self,
*_, *_,
location: Optional[str] = None, location: Optional[str] = None,
@ -99,9 +97,10 @@ class WeatherOpenweathermapPlugin(WeatherPlugin):
:param units: Override the ``units`` configuration value. :param units: Override the ``units`` configuration value.
:return: .. schema:: weather.openweathermap.WeatherSchema :return: .. schema:: weather.openweathermap.WeatherSchema
""" """
units = units or self.units
params = { params = {
'appid': self._token, 'appid': self._token,
'units': units or self.units, 'units': units,
**self._get_location_query( **self._get_location_query(
location=location, location=location,
city_id=city_id, city_id=city_id,
@ -113,4 +112,6 @@ class WeatherOpenweathermapPlugin(WeatherPlugin):
rs = requests.get(self.base_url, params=params, timeout=10) rs = requests.get(self.base_url, params=params, timeout=10)
rs.raise_for_status() rs.raise_for_status()
return dict(WeatherSchema().dump(rs.json())) state = rs.json()
state['units'] = units
return dict(WeatherSchema().dump(state))

View file

@ -1,6 +1,10 @@
from typing import Optional from typing import Optional
from datetime import datetime
from dateutil.tz import tzutc
from marshmallow import fields, pre_dump from marshmallow import fields, pre_dump
from marshmallow.schema import Schema from marshmallow.schema import Schema
from marshmallow.validate import OneOf
from platypush.schemas import DateTime from platypush.schemas import DateTime
@ -135,6 +139,19 @@ class WeatherSchema(Schema):
}, },
) )
units = fields.String(
missing='metric',
validate=OneOf(['metric', 'imperial']),
metadata={
'description': 'Unit of measure',
'example': 'metric',
},
)
@staticmethod
def _timestamp_to_dt(timestamp: float) -> datetime:
return datetime.fromtimestamp(timestamp, tz=tzutc())
@pre_dump @pre_dump
def _pre_dump(self, data: dict, **_) -> dict: def _pre_dump(self, data: dict, **_) -> dict:
sun_data = data.pop('sys', {}) sun_data = data.pop('sys', {})
@ -142,8 +159,8 @@ class WeatherSchema(Schema):
sunset = sun_data.get('sunset') sunset = sun_data.get('sunset')
if sunrise is not None: if sunrise is not None:
data['sunrise'] = sunrise data['sunrise'] = self._timestamp_to_dt(sunrise)
if sunset is not None: if sunset is not None:
data['sunset'] = sunset data['sunset'] = self._timestamp_to_dt(sunset)
return data return data