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 <erik@montnemery.com>
This commit is contained in:
G Johansson 2023-09-27 10:11:57 +02:00 committed by GitHub
parent fd53e116bb
commit 43954d660b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 768 additions and 36 deletions

View File

@ -9,6 +9,7 @@ from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN
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 @@ from . import (
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 @@ CONFIG_SECTION_SCHEMA = vol.Schema(
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]
),
}
)

View File

@ -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 @@ from homeassistant.components.weather import (
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 @@ from homeassistant.util.unit_conversion import (
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_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template"
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 @@ class WeatherTemplate(TemplateEntity, WeatherEntity):
)
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())

View File

@ -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 @@ from homeassistant.components.weather import (
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