diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 81a6badfc34..85f2f82c213 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 Any, Literal + import voluptuous as vol from homeassistant.components.weather import ( @@ -22,9 +25,11 @@ from homeassistant.components.weather import ( 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 +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 @@ -39,6 +44,8 @@ from homeassistant.util.unit_conversion import ( from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +CHECK_FORECAST_KEYS = set().union(Forecast.__annotations__.keys()) + CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -68,6 +75,9 @@ CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" 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" @@ -77,30 +87,40 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" 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, + } + ), ) @@ -151,6 +171,11 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): 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) @@ -180,6 +205,17 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): 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] = [] + + 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: @@ -246,6 +282,18 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Return the forecast.""" return self._forecast + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + 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: """Return the attribution.""" @@ -327,4 +375,73 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): "_forecast", self._forecast_template, ) + + if self._forecast_daily_template: + self.add_template_attribute( + "_forecast_daily", + self._forecast_daily_template, + on_update=partial(self._update_forecast, "daily"), + 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=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=partial(self._validate_forecast, "twice_daily"), + ) + await super().async_added_to_hass() + + @callback + 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, f"_forecast_{forecast_type}", attr_result) + self.hass.create_task(self.async_update_listeners([forecast_type])) + + @callback + def _validate_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: Any, + ) -> list[Forecast] | None: + """Validate the forecasts.""" + if result is None: + return None + + 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: + 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: + raise vol.Invalid( + "`datetime` is required in forecasts, 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 38cf439987d..97965a5643e 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 @@ from homeassistant.components.weather import ( 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,419 @@ 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": [ + { + "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.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_forecasts(hass: HomeAssistant, start_ha) -> 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, + ) + ] + }, + ) + 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, + 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, + } + ] + } + 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", + "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, + } + ] + } + + +@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 invalid forecasts.""" + 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 + + +@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 + + +@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