From 39f711e3db3b44e3b6751ebd105b54af4a4e21df Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 8 Aug 2023 18:56:55 +0000 Subject: [PATCH 01/10] Modernize template weather --- homeassistant/components/template/weather.py | 16 ++++- tests/components/template/test_weather.py | 75 +++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 81a6badfc34aca..092883b65af5da 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -22,9 +22,10 @@ ENTITY_ID_FORMAT, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id @@ -130,6 +131,7 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY def __init__( self, @@ -246,6 +248,10 @@ def forecast(self) -> list[Forecast]: """Return the forecast.""" return self._forecast + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast + @property def attribution(self) -> str | None: """Return the attribution.""" @@ -253,6 +259,14 @@ def attribution(self) -> str | None: return "Powered by Home Assistant" return self._attribution + @callback + def _update_state(self, result): + super()._update_state(result) + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners("daily") + ) + async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 38cf439987d7e3..8c781a68def560 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components.weather import ( + ATTR_FORECAST, ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, @@ -13,13 +14,15 @@ ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, - DOMAIN, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + Forecast, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", [ @@ -74,3 +77,71 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: assert state is not None assert state.state == "sunny" assert state.attributes.get(v_attr) == value + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + {"weather": {"platform": "demo"}}, + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + "pressure_template": "{{ states('sensor.pressure') }}", + "wind_speed_template": "{{ states('sensor.windspeed') }}", + "wind_bearing_template": "{{ states('sensor.windbearing') }}", + "ozone_template": "{{ states('sensor.ozone') }}", + "visibility_template": "{{ states('sensor.visibility') }}", + "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", + "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", + "dew_point_template": "{{ states('sensor.dew_point') }}", + "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", + }, + ] + }, + ], +) +async def test_forecast_service(hass: HomeAssistant, start_ha) -> None: + """Test forecast service.""" + for attr, value in [ + ( + "weather.forecast", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + ]: + hass.states.async_set(attr, "sunny", value) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + } + ] + } From 7673da94078a38820ad9bbab1df096188aecfdda Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 8 Aug 2023 20:56:34 +0000 Subject: [PATCH 02/10] mods --- homeassistant/components/template/weather.py | 28 ++++--- tests/components/template/test_weather.py | 84 +++++++++++++------- 2 files changed, 76 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 092883b65af5da..dbea74c4125797 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -30,7 +30,11 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.unit_conversion import ( DistanceConverter, PressureConverter, @@ -259,14 +263,6 @@ def attribution(self) -> str | None: return "Powered by Home Assistant" return self._attribution - @callback - def _update_state(self, result): - super()._update_state(result) - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners("daily") - ) - async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -336,9 +332,23 @@ async def async_added_to_hass(self) -> None: "_apparent_temperature", self._apparent_temperature_template, ) + if self._forecast_template: self.add_template_attribute( "_forecast", self._forecast_template, ) + self.async_on_remove( + async_track_state_change_event( + self.hass, self.entity_id, self.async_state_changed_listener + ) + ) await super().async_added_to_hass() + + @callback + async def async_state_changed_listener( + self, + event: EventType[EventStateChangedData], + ) -> None: + """Call to update forecast listener.""" + await self.async_update_listeners(["daily"]) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 8c781a68def560..de5e5b9bdf77f7 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -85,7 +85,6 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: [ { "weather": [ - {"weather": {"platform": "demo"}}, { "platform": "template", "name": "forecast", @@ -93,15 +92,6 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", "temperature_template": "{{ states('sensor.temperature') | float }}", "humidity_template": "{{ states('sensor.humidity') | int }}", - "pressure_template": "{{ states('sensor.pressure') }}", - "wind_speed_template": "{{ states('sensor.windspeed') }}", - "wind_bearing_template": "{{ states('sensor.windbearing') }}", - "ozone_template": "{{ states('sensor.ozone') }}", - "visibility_template": "{{ states('sensor.visibility') }}", - "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", - "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", - "dew_point_template": "{{ states('sensor.dew_point') }}", - "apparent_temperature_template": "{{ states('sensor.apparent_temperature') }}", }, ] }, @@ -109,25 +99,30 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: ) async def test_forecast_service(hass: HomeAssistant, start_ha) -> None: """Test forecast service.""" - for attr, value in [ - ( - "weather.forecast", - { - ATTR_FORECAST: [ - Forecast( - condition="cloudy", - datetime="2023-02-17T14:00:00+00:00", - temperature=14.2, - ) - ] - }, - ) + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), ]: - hass.states.async_set(attr, "sunny", value) + hass.states.async_set(attr, value) await hass.async_block_till_done() - state = hass.states.get("weather.forecast") - assert state is not None - assert state.state == "sunny" + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" response = await hass.services.async_call( WEATHER_DOMAIN, @@ -145,3 +140,38 @@ async def test_forecast_service(hass: HomeAssistant, start_ha) -> None: } ] } + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=16.9, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 16.9, + } + ] + } From d887ea794ac914b4e58cca16a551094650f5b658 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 9 Aug 2023 17:10:55 +0000 Subject: [PATCH 03/10] adds templates --- homeassistant/components/template/weather.py | 115 ++++++++++++++----- tests/components/template/test_weather.py | 55 ++++++++- 2 files changed, 141 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index dbea74c4125797..43fe891efd2cde 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,6 +1,8 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from typing import Literal + import voluptuous as vol from homeassistant.components.weather import ( @@ -73,6 +75,9 @@ CONF_OZONE_TEMPLATE = "ozone_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_TEMPLATE = "forecast_template" +CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" +CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" +CONF_FORECAST_TWICE_DAILY_TEMPLATE = "forecast_twice_daily_template" CONF_PRESSURE_UNIT = "pressure_unit" CONF_WIND_SPEED_UNIT = "wind_speed_unit" CONF_VISIBILITY_UNIT = "visibility_unit" @@ -82,30 +87,40 @@ CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, - vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_FORECAST_TEMPLATE), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( + TemperatureConverter.VALID_UNITS + ), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In( + DistanceConverter.VALID_UNITS + ), + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + } + ), ) @@ -135,7 +150,11 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False - _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY + | WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_TWICE_DAILY + ) def __init__( self, @@ -157,6 +176,11 @@ def __init__( self._ozone_template = config.get(CONF_OZONE_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_template = config.get(CONF_FORECAST_TEMPLATE) + self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) + self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) + self._forecast_twice_daily_template = config.get( + CONF_FORECAST_TWICE_DAILY_TEMPLATE + ) self._wind_gust_speed_template = config.get(CONF_WIND_GUST_SPEED_TEMPLATE) self._cloud_coverage_template = config.get(CONF_CLOUD_COVERAGE_TEMPLATE) self._dew_point_template = config.get(CONF_DEW_POINT_TEMPLATE) @@ -186,6 +210,9 @@ def __init__( self._dew_point = None self._apparent_temperature = None self._forecast: list[Forecast] = [] + self._forecast_daily: list[Forecast] = [] + self._forecast_hourly: list[Forecast] = [] + self._forecast_twice_daily: list[Forecast] = [] @property def condition(self) -> str | None: @@ -254,7 +281,15 @@ def forecast(self) -> list[Forecast]: async def async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" - return self._forecast + return self._forecast_daily + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_hourly + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_twice_daily @property def attribution(self) -> str | None: @@ -332,17 +367,34 @@ async def async_added_to_hass(self) -> None: "_apparent_temperature", self._apparent_temperature_template, ) - if self._forecast_template: self.add_template_attribute( "_forecast", self._forecast_template, ) + + if fc_daily := self._forecast_daily_template: + self.add_template_attribute( + "_forecast_daily", + self._forecast_daily_template, + ) + if fc_hourly := self._forecast_hourly_template: + self.add_template_attribute( + "_forecast_hourly", + self._forecast_hourly_template, + ) + if fc_twice_daily := self._forecast_twice_daily_template: + self.add_template_attribute( + "_forecast_twice_daily", + self._forecast_twice_daily_template, + ) + if fc_daily or fc_hourly or fc_twice_daily: self.async_on_remove( async_track_state_change_event( self.hass, self.entity_id, self.async_state_changed_listener ) ) + await super().async_added_to_hass() @callback @@ -351,4 +403,11 @@ async def async_state_changed_listener( event: EventType[EventStateChangedData], ) -> None: """Call to update forecast listener.""" - await self.async_update_listeners(["daily"]) + types: list[Literal["daily", "hourly", "twice_daily"]] = [] + if self._forecast_daily_template: + types.append("daily") + if self._forecast_hourly_template: + types.append("hourly") + if self._forecast_twice_daily_template: + types.append("twice_daily") + await self.async_update_listeners(types) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index de5e5b9bdf77f7..8fca161cb747c9 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -90,6 +90,9 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: "name": "forecast", "condition_template": "sunny", "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", "temperature_template": "{{ states('sensor.temperature') | float }}", "humidity_template": "{{ states('sensor.humidity') | int }}", }, @@ -97,7 +100,7 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_forecast_service(hass: HomeAssistant, start_ha) -> None: +async def test_forecasts(hass: HomeAssistant, start_ha) -> None: """Test forecast service.""" for attr, _v_attr, value in [ ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), @@ -119,10 +122,27 @@ async def test_forecast_service(hass: HomeAssistant, start_ha) -> None: ] }, ) + hass.states.async_set( + "weather.forecast_twice_daily", + "fog", + { + ATTR_FORECAST: [ + Forecast( + condition="fog", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + is_daytime=True, + ) + ] + }, + ) await hass.async_block_till_done() state = hass.states.get("weather.forecast") assert state is not None assert state.state == "sunny" + state2 = hass.states.get("weather.forecast_twice_daily") + assert state2 is not None + assert state2.state == "fog" response = await hass.services.async_call( WEATHER_DOMAIN, @@ -140,6 +160,39 @@ async def test_forecast_service(hass: HomeAssistant, start_ha) -> None: } ] } + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + } + ] + } + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "fog", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + "is_daytime": True, + } + ] + } hass.states.async_set( "weather.forecast", From 4162bd4bd006a233afeb8bd5f6fe7974dfbde218 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 9 Aug 2023 18:23:16 +0000 Subject: [PATCH 04/10] Fixes --- homeassistant/components/template/weather.py | 77 ++++++++++++-------- tests/components/template/test_weather.py | 74 +++++++++++++++++++ 2 files changed, 122 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 43fe891efd2cde..3780516247847e 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,8 +1,6 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations -from typing import Literal - import voluptuous as vol from homeassistant.components.weather import ( @@ -28,15 +26,12 @@ ) from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import ( DistanceConverter, PressureConverter, @@ -373,41 +368,65 @@ async def async_added_to_hass(self) -> None: self._forecast_template, ) - if fc_daily := self._forecast_daily_template: + if self._forecast_daily_template: self.add_template_attribute( "_forecast_daily", self._forecast_daily_template, + on_update=self._update_daily, + validator=self._validate_forecast, ) - if fc_hourly := self._forecast_hourly_template: + if self._forecast_hourly_template: self.add_template_attribute( "_forecast_hourly", self._forecast_hourly_template, + on_update=self._update_hourly, + validator=self._validate_forecast, ) - if fc_twice_daily := self._forecast_twice_daily_template: + if self._forecast_twice_daily_template: self.add_template_attribute( "_forecast_twice_daily", self._forecast_twice_daily_template, - ) - if fc_daily or fc_hourly or fc_twice_daily: - self.async_on_remove( - async_track_state_change_event( - self.hass, self.entity_id, self.async_state_changed_listener - ) + on_update=self._update_twice_daily, + validator=self._validate_forecast, ) await super().async_added_to_hass() @callback - async def async_state_changed_listener( - self, - event: EventType[EventStateChangedData], - ) -> None: - """Call to update forecast listener.""" - types: list[Literal["daily", "hourly", "twice_daily"]] = [] - if self._forecast_daily_template: - types.append("daily") - if self._forecast_hourly_template: - types.append("hourly") - if self._forecast_twice_daily_template: - types.append("twice_daily") - await self.async_update_listeners(types) + def _update_daily(self, result: list[Forecast] | TemplateError) -> None: + """Save template result and trigger forecast listener.""" + attr_result = None if isinstance(result, TemplateError) else result + setattr(self, "_forecast_daily", attr_result) + self.hass.create_task(self.async_update_listeners(["daily"])) + + @callback + def _update_hourly(self, result: list[Forecast] | TemplateError) -> None: + """Save template result and trigger forecast listener.""" + attr_result = None if isinstance(result, TemplateError) else result + setattr(self, "_forecast_hourly", attr_result) + self.hass.create_task(self.async_update_listeners(["hourly"])) + + @callback + def _update_twice_daily(self, result: list[Forecast] | TemplateError) -> None: + """Save template result and trigger forecast listener.""" + attr_result = None if isinstance(result, TemplateError) else result + setattr(self, "_forecast_twice_daily", attr_result) + self.hass.create_task(self.async_update_listeners(["twice_daily"])) + + @callback + def _validate_forecast( + self, result: list[Forecast] | TemplateError + ) -> list[Forecast] | None: + """Validate the forecasts.""" + if result is None or isinstance(result, TemplateError): + return None + check_keys = Forecast.__annotations__.keys() + if isinstance(result, list): + for forecast in result: + for key, _ in forecast.items(): + if key not in check_keys: + raise vol.Invalid( + "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + continue + return result diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 8fca161cb747c9..30fd2b3be0a605 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -228,3 +228,77 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: } ] } + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast_hourly.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + not_correct=1, + ) + ] + }, + ) + hass.states.async_set( + "weather.forecast_hourly", + "sunny", + {ATTR_FORECAST: None}, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_hourly") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "Only valid keys in Forecast are allowed" in caplog.text From 06d76a68d3b03b58e8f976f66fa9dcad309b3067 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 10 Aug 2023 09:08:42 +0000 Subject: [PATCH 05/10] review comments --- homeassistant/components/template/weather.py | 52 ++++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 3780516247847e..98ad5c3f847dad 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,6 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from functools import partial +from typing import Literal + import voluptuous as vol from homeassistant.components.weather import ( @@ -41,6 +44,8 @@ from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +CHECK_FORECAST_KEYS = Forecast.__annotations__.keys() + CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -145,11 +150,6 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False - _attr_supported_features = ( - WeatherEntityFeature.FORECAST_DAILY - | WeatherEntityFeature.FORECAST_HOURLY - | WeatherEntityFeature.FORECAST_TWICE_DAILY - ) def __init__( self, @@ -209,6 +209,14 @@ def __init__( self._forecast_hourly: list[Forecast] = [] self._forecast_twice_daily: list[Forecast] = [] + self._attr_supported_features = 0 + if self._forecast_daily_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + @property def condition(self) -> str | None: """Return the current condition.""" @@ -372,46 +380,36 @@ async def async_added_to_hass(self) -> None: self.add_template_attribute( "_forecast_daily", self._forecast_daily_template, - on_update=self._update_daily, + on_update=partial(self._update_forecast, "daily"), validator=self._validate_forecast, ) if self._forecast_hourly_template: self.add_template_attribute( "_forecast_hourly", self._forecast_hourly_template, - on_update=self._update_hourly, + on_update=partial(self._update_forecast, "hourly"), validator=self._validate_forecast, ) if self._forecast_twice_daily_template: self.add_template_attribute( "_forecast_twice_daily", self._forecast_twice_daily_template, - on_update=self._update_twice_daily, + on_update=partial(self._update_forecast, "twice_daily"), validator=self._validate_forecast, ) await super().async_added_to_hass() @callback - def _update_daily(self, result: list[Forecast] | TemplateError) -> None: - """Save template result and trigger forecast listener.""" - attr_result = None if isinstance(result, TemplateError) else result - setattr(self, "_forecast_daily", attr_result) - self.hass.create_task(self.async_update_listeners(["daily"])) - - @callback - def _update_hourly(self, result: list[Forecast] | TemplateError) -> None: - """Save template result and trigger forecast listener.""" - attr_result = None if isinstance(result, TemplateError) else result - setattr(self, "_forecast_hourly", attr_result) - self.hass.create_task(self.async_update_listeners(["hourly"])) - - @callback - def _update_twice_daily(self, result: list[Forecast] | TemplateError) -> None: + def _update_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: list[Forecast] | TemplateError, + ) -> None: """Save template result and trigger forecast listener.""" attr_result = None if isinstance(result, TemplateError) else result - setattr(self, "_forecast_twice_daily", attr_result) - self.hass.create_task(self.async_update_listeners(["twice_daily"])) + setattr(self, f"_forecast_{forecast_type}", attr_result) + self.hass.create_task(self.async_update_listeners([forecast_type])) @callback def _validate_forecast( @@ -420,11 +418,11 @@ def _validate_forecast( """Validate the forecasts.""" if result is None or isinstance(result, TemplateError): return None - check_keys = Forecast.__annotations__.keys() + if isinstance(result, list): for forecast in result: for key, _ in forecast.items(): - if key not in check_keys: + if key not in CHECK_FORECAST_KEYS: raise vol.Invalid( "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" ) From 41eb5af42a47e7d37e4f25420ec620afc6ca5ba3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 10 Aug 2023 09:19:14 +0000 Subject: [PATCH 06/10] more comments --- homeassistant/components/template/weather.py | 38 ++++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 98ad5c3f847dad..8421f9733b9516 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -44,7 +44,7 @@ from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf -CHECK_FORECAST_KEYS = Forecast.__annotations__.keys() +CHECK_FORECAST_KEYS = set().union(Forecast.__annotations__.keys()) CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT, @@ -381,21 +381,21 @@ async def async_added_to_hass(self) -> None: "_forecast_daily", self._forecast_daily_template, on_update=partial(self._update_forecast, "daily"), - validator=self._validate_forecast, + validator=partial(self._validate_forecast, "daily"), ) if self._forecast_hourly_template: self.add_template_attribute( "_forecast_hourly", self._forecast_hourly_template, on_update=partial(self._update_forecast, "hourly"), - validator=self._validate_forecast, + validator=partial(self._validate_forecast, "hourly"), ) if self._forecast_twice_daily_template: self.add_template_attribute( "_forecast_twice_daily", self._forecast_twice_daily_template, on_update=partial(self._update_forecast, "twice_daily"), - validator=self._validate_forecast, + validator=partial(self._validate_forecast, "twice_daily"), ) await super().async_added_to_hass() @@ -413,18 +413,34 @@ def _update_forecast( @callback def _validate_forecast( - self, result: list[Forecast] | TemplateError + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: list[Forecast] | TemplateError, ) -> list[Forecast] | None: """Validate the forecasts.""" if result is None or isinstance(result, TemplateError): return None + set().union() if isinstance(result, list): for forecast in result: - for key, _ in forecast.items(): - if key not in CHECK_FORECAST_KEYS: - raise vol.Invalid( - "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" - ) - continue + diff_result = ( + set().union(forecast.items()).difference(CHECK_FORECAST_KEYS) + ) + if diff_result: + raise vol.Invalid( + "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if ( + forecast_type == "twice_daily" + and "is_daytime" not in forecast.keys() + ): + raise vol.Invalid( + "`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if "datetime" not in forecast.keys(): + raise vol.Invalid( + "`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + continue return result From 38061fa1d69e265baec871893f50a7abe3d20606 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 10 Aug 2023 10:07:59 +0000 Subject: [PATCH 07/10] Fix validator --- homeassistant/components/template/weather.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 8421f9733b9516..740e1df6784a48 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -421,11 +421,10 @@ def _validate_forecast( if result is None or isinstance(result, TemplateError): return None - set().union() if isinstance(result, list): for forecast in result: diff_result = ( - set().union(forecast.items()).difference(CHECK_FORECAST_KEYS) + set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS) ) if diff_result: raise vol.Invalid( From 49490a8f813325f74703c627405a29b4ca97b763 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 10 Aug 2023 12:44:33 +0000 Subject: [PATCH 08/10] Tests --- tests/components/template/test_weather.py | 120 +++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 30fd2b3be0a605..0c51f8578264eb 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -253,7 +253,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: async def test_forecast_invalid( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture ) -> None: - """Test forecast service.""" + """Test invalid forecasts.""" for attr, _v_attr, value in [ ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), @@ -302,3 +302,121 @@ async def test_forecast_invalid( ) assert response == {"forecast": []} assert "Only valid keys in Forecast are allowed" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid_is_daytime_missing_in_twice_daily( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_twice_daily", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_twice_daily") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "`is_daytime` is missing in twice_daily forecast" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid_datetime_missing( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid when datetime missing.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_twice_daily", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + temperature=14.2, + is_daytime=True, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_twice_daily") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "`datetime` is required in forecasts" in caplog.text From 427b1a06ccea6707271035ad753ede40b0c73ea1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 15 Aug 2023 19:42:17 +0000 Subject: [PATCH 09/10] Mods --- homeassistant/components/template/weather.py | 48 +++++++------ tests/components/template/test_weather.py | 73 ++++++++++++++++++++ 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 740e1df6784a48..891f2e5a9b9b32 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations from functools import partial -from typing import Literal +from typing import Any, Literal import voluptuous as vol @@ -415,31 +415,33 @@ def _update_forecast( def _validate_forecast( self, forecast_type: Literal["daily", "hourly", "twice_daily"], - result: list[Forecast] | TemplateError, + result: Any, ) -> list[Forecast] | None: """Validate the forecasts.""" - if result is None or isinstance(result, TemplateError): + if result is None: return None - if isinstance(result, list): - for forecast in result: - diff_result = ( - set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS) + if not isinstance(result, list): + raise vol.Invalid( + "Forecasts is not a list, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + for forecast in result: + if not isinstance(forecast, dict): + raise vol.Invalid( + "Forecast in list is not a dict, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + diff_result = set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS) + if diff_result: + raise vol.Invalid( + "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if forecast_type == "twice_daily" and "is_daytime" not in forecast.keys(): + raise vol.Invalid( + "`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if "datetime" not in forecast.keys(): + raise vol.Invalid( + "`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/" ) - if diff_result: - raise vol.Invalid( - "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" - ) - if ( - forecast_type == "twice_daily" - and "is_daytime" not in forecast.keys() - ): - raise vol.Invalid( - "`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/" - ) - if "datetime" not in forecast.keys(): - raise vol.Invalid( - "`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/" - ) - continue + continue return result diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 0c51f8578264eb..97965a5643ee3b 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -420,3 +420,76 @@ async def test_forecast_invalid_datetime_missing( ) assert response == {"forecast": []} assert "`datetime` is required in forecasts" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast_daily.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast_hourly.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_format_error( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid on incorrect format.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_daily", + "sunny", + { + ATTR_FORECAST: [ + "cloudy", + "2023-02-17T14:00:00+00:00", + 14.2, + 1, + ] + }, + ) + hass.states.async_set( + "weather.forecast_hourly", + "sunny", + { + ATTR_FORECAST: { + "condition": "cloudy", + "temperature": 14.2, + "is_daytime": True, + } + }, + ) + + await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert "Forecasts is not a list, see Weather documentation" in caplog.text + await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert "Forecast in list is not a dict, see Weather documentation" in caplog.text From 5c75839c6091bb56f71c59abb1a3d86313d911c9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Aug 2023 09:57:29 +0000 Subject: [PATCH 10/10] Fix ruff --- homeassistant/components/template/weather.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 891f2e5a9b9b32..85f2f82c213068 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -435,11 +435,11 @@ def _validate_forecast( raise vol.Invalid( "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" ) - if forecast_type == "twice_daily" and "is_daytime" not in forecast.keys(): + if forecast_type == "twice_daily" and "is_daytime" not in forecast: raise vol.Invalid( "`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/" ) - if "datetime" not in forecast.keys(): + if "datetime" not in forecast: raise vol.Invalid( "`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/" )