From 43954d660b16c12c2f405f1baf352ef1ec9d743f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Sep 2023 10:11:57 +0200 Subject: [PATCH] Add trigger weather template (#100824) * Add trigger weather template * Add tests * mod dataclass * Remove legacy forecast * Fix test failure * sorting * add hourly test * Add tests * Add and fix tests * Improve tests --------- Co-authored-by: Erik --- homeassistant/components/template/config.py | 5 + homeassistant/components/template/weather.py | 329 +++++++++++-- tests/components/template/test_weather.py | 470 ++++++++++++++++++- 3 files changed, 768 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 54c82d88c74bc..3329f185f0847 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -9,6 +9,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv @@ -21,6 +22,7 @@ number as number_platform, select as select_platform, sensor as sensor_platform, + weather as weather_platform, ) from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN @@ -55,6 +57,9 @@ vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), + vol.Optional(WEATHER_DOMAIN): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index d815655d77524..4e9149ebd07ef 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,8 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from dataclasses import asdict, dataclass from functools import partial -from typing import Any, Literal +from typing import Any, Literal, Self import voluptuous as vol @@ -22,18 +23,27 @@ ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, + DOMAIN as WEATHER_DOMAIN, ENTITY_ID_FORMAT, Forecast, WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template 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.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -42,7 +52,9 @@ TemperatureConverter, ) +from .coordinator import TriggerUpdateCoordinator from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( set().union(Forecast.__annotations__.keys()) @@ -92,40 +104,38 @@ CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +WEATHER_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + 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, + } +) + 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, - } - ), + PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema), ) @@ -136,6 +146,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" + if discovery_info and "coordinator" in discovery_info: + async_add_entities( + TriggerWeatherEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return config = rewrite_common_legacy_to_modern_conf(config) unique_id = config.get(CONF_UNIQUE_ID) @@ -452,3 +468,248 @@ def _validate_forecast( ) continue return result + + +@dataclass(kw_only=True) +class WeatherExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_apparent_temperature: float | None + last_cloud_coverage: int | None + last_dew_point: float | None + last_humidity: float | None + last_ozone: float | None + last_pressure: float | None + last_temperature: float | None + last_visibility: float | None + last_wind_bearing: float | str | None + last_wind_gust_speed: float | None + last_wind_speed: float | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored event state from a dict.""" + try: + return cls( + last_apparent_temperature=restored["last_apparent_temperature"], + last_cloud_coverage=restored["last_cloud_coverage"], + last_dew_point=restored["last_dew_point"], + last_humidity=restored["last_humidity"], + last_ozone=restored["last_ozone"], + last_pressure=restored["last_pressure"], + last_temperature=restored["last_temperature"], + last_visibility=restored["last_visibility"], + last_wind_bearing=restored["last_wind_bearing"], + last_wind_gust_speed=restored["last_wind_gust_speed"], + last_wind_speed=restored["last_wind_speed"], + ) + except KeyError: + return None + + +class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): + """Sensor entity based on trigger data.""" + + domain = WEATHER_DOMAIN + extra_template_keys = ( + CONF_CONDITION_TEMPLATE, + CONF_TEMPERATURE_TEMPLATE, + CONF_HUMIDITY_TEMPLATE, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) + self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) + self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) + self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) + self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) + + self._attr_supported_features = 0 + if config.get(CONF_FORECAST_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if config.get(CONF_FORECAST_HOURLY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if config.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + for key in ( + CONF_APPARENT_TEMPERATURE_TEMPLATE, + CONF_CLOUD_COVERAGE_TEMPLATE, + CONF_DEW_POINT_TEMPLATE, + CONF_FORECAST_DAILY_TEMPLATE, + CONF_FORECAST_HOURLY_TEMPLATE, + CONF_FORECAST_TWICE_DAILY_TEMPLATE, + CONF_OZONE_TEMPLATE, + CONF_PRESSURE_TEMPLATE, + CONF_VISIBILITY_TEMPLATE, + CONF_WIND_BEARING_TEMPLATE, + CONF_WIND_GUST_SPEED_TEMPLATE, + CONF_WIND_SPEED_TEMPLATE, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (state := await self.async_get_last_state()) + and state.state is not None + and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and (weather_data := await self.async_get_last_weather_data()) + ): + self._rendered[ + CONF_APPARENT_TEMPERATURE_TEMPLATE + ] = weather_data.last_apparent_temperature + self._rendered[ + CONF_CLOUD_COVERAGE_TEMPLATE + ] = weather_data.last_cloud_coverage + self._rendered[CONF_CONDITION_TEMPLATE] = state.state + self._rendered[CONF_DEW_POINT_TEMPLATE] = weather_data.last_dew_point + self._rendered[CONF_HUMIDITY_TEMPLATE] = weather_data.last_humidity + self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone + self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure + self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility + self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing + self._rendered[ + CONF_WIND_GUST_SPEED_TEMPLATE + ] = weather_data.last_wind_gust_speed + self._rendered[CONF_WIND_SPEED_TEMPLATE] = weather_data.last_wind_speed + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._rendered.get(CONF_CONDITION_TEMPLATE) + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_TEMPERATURE_TEMPLATE) + ) + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_HUMIDITY_TEMPLATE) + ) + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_SPEED_TEMPLATE) + ) + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return vol.Any(vol.Coerce(float), vol.Coerce(str), None)( + self._rendered.get(CONF_WIND_BEARING_TEMPLATE) + ) + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_OZONE_TEMPLATE), + ) + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_VISIBILITY_TEMPLATE) + ) + + @property + def native_pressure(self) -> float | None: + """Return the air pressure.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_PRESSURE_TEMPLATE) + ) + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE) + ) + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE) + ) + + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_DEW_POINT_TEMPLATE) + ) + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_APPARENT_TEMPERATURE_TEMPLATE) + ) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_DAILY_TEMPLATE) + ) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_HOURLY_TEMPLATE) + ) + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE) + ) + + @property + def extra_restore_state_data(self) -> WeatherExtraStoredData: + """Return weather specific state data to be restored.""" + return WeatherExtraStoredData( + last_apparent_temperature=self._rendered.get( + CONF_APPARENT_TEMPERATURE_TEMPLATE + ), + last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE), + last_dew_point=self._rendered.get(CONF_DEW_POINT_TEMPLATE), + last_humidity=self._rendered.get(CONF_HUMIDITY_TEMPLATE), + last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), + last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), + last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), + last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), + last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), + last_wind_speed=self._rendered.get(CONF_WIND_SPEED_TEMPLATE), + ) + + async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None: + """Restore weather specific state data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return WeatherExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 97965a5643ee3..7ca3d11b09905 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -1,4 +1,6 @@ """The tests for the Template Weather platform.""" +from typing import Any + import pytest from homeassistant.components.weather import ( @@ -18,8 +20,18 @@ SERVICE_GET_FORECAST, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + assert_setup_component, + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -493,3 +505,457 @@ async def test_forecast_format_error( return_response=True, ) assert "Forecast in list is not a dict, see Weather documentation" in caplog.text + + +SAVED_EXTRA_DATA = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + +SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + "some_key_added_in_the_future": 123, +} + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ], +) +@pytest.mark.parametrize( + ("saved_state", "saved_extra_data", "initial_state"), + [ + ("sunny", SAVED_EXTRA_DATA, "sunny"), + ("sunny", SAVED_EXTRA_DATA_WITH_FUTURE_KEY, "sunny"), + (STATE_UNAVAILABLE, SAVED_EXTRA_DATA, STATE_UNKNOWN), + (STATE_UNKNOWN, SAVED_EXTRA_DATA, STATE_UNKNOWN), + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "temperature": -10, + "humidity": 50, + } + + fake_state = State( + "weather.test", + saved_state, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, saved_extra_data),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == initial_state + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + + state = hass.states.get("weather.test") + assert state.state == "cloudy" + assert state.attributes["temperature"] == 15.0 + assert state.attributes["humidity"] == 25.0 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.temperature + 1 }}" + }, + }, + ], + "weather": [ + { + "name": "Hello Name", + "condition_template": "sunny", + "temperature_unit": "°C", + "humidity_template": "{{ 20 }}", + "temperature_template": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("weather.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"temperature": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("weather.hello_name") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.context is context + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.information + 1 }}", + "var_forecast_daily": "{{ trigger.event.data.forecast_daily }}", + "var_forecast_hourly": "{{ trigger.event.data.forecast_hourly }}", + "var_forecast_twice_daily": "{{ trigger.event.data.forecast_twice_daily }}", + }, + }, + ], + "weather": [ + { + "name": "Test", + "condition_template": "sunny", + "precipitation_unit": "mm", + "pressure_unit": "hPa", + "visibility_unit": "km", + "wind_speed_unit": "km/h", + "temperature_unit": "°C", + "temperature_template": "{{ my_variable + 1 }}", + "humidity_template": "{{ my_variable + 1 }}", + "wind_speed_template": "{{ my_variable + 1 }}", + "wind_bearing_template": "{{ my_variable + 1 }}", + "ozone_template": "{{ my_variable + 1 }}", + "visibility_template": "{{ my_variable + 1 }}", + "pressure_template": "{{ my_variable + 1 }}", + "wind_gust_speed_template": "{{ my_variable + 1 }}", + "cloud_coverage_template": "{{ my_variable + 1 }}", + "dew_point_template": "{{ my_variable + 1 }}", + "apparent_temperature_template": "{{ my_variable + 1 }}", + "forecast_template": "{{ var_forecast_daily }}", + "forecast_daily_template": "{{ var_forecast_daily }}", + "forecast_hourly_template": "{{ var_forecast_hourly }}", + "forecast_twice_daily_template": "{{ var_forecast_twice_daily }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_weather_services( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger weather entity with services.""" + state = hass.states.get("weather.test") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + now = dt_util.now().isoformat() + hass.bus.async_fire( + "test_event", + { + "information": 1, + "forecast_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_hourly": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_twice_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + "is_daytime": True, + } + ], + }, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.attributes["humidity"] == 3.0 + assert state.attributes["wind_speed"] == 3.0 + assert state.attributes["wind_bearing"] == 3.0 + assert state.attributes["ozone"] == 3.0 + assert state.attributes["visibility"] == 3.0 + assert state.attributes["pressure"] == 3.0 + assert state.attributes["wind_gust_speed"] == 3.0 + assert state.attributes["cloud_coverage"] == 3.0 + assert state.attributes["dew_point"] == 3.0 + assert state.attributes["apparent_temperature"] == 3.0 + assert state.context is context + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "twice_daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + "is_daytime": True, + } + ], + } + + +async def test_restore_weather_save_state( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test Restore saved state for Weather trigger template.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + entity = hass.states.get("weather.test") + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": "25.0", + "last_ozone": None, + "last_pressure": None, + "last_temperature": "15.0", + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + } + + +SAVED_ATTRIBUTES_1 = { + "humidity": 20, + "temperature": 10, +} + +SAVED_EXTRA_DATA_MISSING_KEY = { + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": 20, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + + +@pytest.mark.parametrize( + ("saved_attributes", "saved_extra_data"), + [ + (SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_MISSING_KEY), + (SAVED_ATTRIBUTES_1, None), + ], +) +async def test_trigger_entity_restore_state_fail( + hass: HomeAssistant, + saved_attributes: dict, + saved_extra_data: dict | None, +) -> None: + """Test restoring trigger template weather fails due to missing attribute.""" + + saved_state = State( + "weather.test", + None, + saved_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((saved_state, saved_extra_data),)) + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("temperature") is None