diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 1832e83e7dd..b74c79fd842 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -56,7 +56,10 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -91,7 +94,9 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional( @@ -108,7 +113,9 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -134,7 +141,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, @@ -150,7 +159,9 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index fab56ae6887..727bf5b86ca 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -18,7 +18,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -50,7 +53,7 @@ async def async_setup_platform( scan_interval: timedelta = binary_sensor_config.get( CONF_SCAN_INTERVAL, SCAN_INTERVAL ) - value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) data = CommandSensorData(hass, command, command_timeout) @@ -86,7 +89,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): config: ConfigType, payload_on: str, payload_off: str, - value_template: Template | None, + value_template: ValueTemplate | None, scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" @@ -133,9 +136,14 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): await self.data.async_update() value = self.data.value + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._attr_is_on = None if value == self._payload_on: @@ -143,7 +151,7 @@ class CommandBinarySensor(ManualTriggerEntity, BinarySensorEntity): elif value == self._payload_off: self._attr_is_on = False - self._process_manual_data(value) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 7f1bc12264c..066f6ae0388 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -79,7 +82,7 @@ class CommandCover(ManualTriggerEntity, CoverEntity): command_close: str, command_stop: str, command_state: str | None, - value_template: Template | None, + value_template: ValueTemplate | None, timeout: int, scan_interval: timedelta, ) -> None: @@ -164,14 +167,20 @@ class CommandCover(ManualTriggerEntity, CoverEntity): """Update device state.""" if self._command_state: payload = str(await self._async_query_state()) + + variables = self._template_variables_with_value(payload) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._value_template: - payload = self._value_template.async_render_with_possible_json_value( - payload, None + payload = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._state = None if payload: self._state = int(payload) - self._process_manual_data(payload) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index b7c36a005fa..5ce50edc4e7 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -23,7 +23,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerSensorEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerSensorEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -57,7 +60,7 @@ async def async_setup_platform( json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) json_attributes_path: str | None = sensor_config.get(CONF_JSON_ATTRIBUTES_PATH) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = sensor_config.get(CONF_VALUE_TEMPLATE) data = CommandSensorData(hass, command, command_timeout) trigger_entity_config = { @@ -88,7 +91,7 @@ class CommandSensor(ManualTriggerSensorEntity): self, data: CommandSensorData, config: ConfigType, - value_template: Template | None, + value_template: ValueTemplate | None, json_attributes: list[str] | None, json_attributes_path: str | None, scan_interval: timedelta, @@ -144,6 +147,11 @@ class CommandSensor(ManualTriggerSensorEntity): await self.data.async_update() value = self.data.value + variables = self._template_variables_with_value(self.data.value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._json_attributes: self._attr_extra_state_attributes = {} if value: @@ -168,16 +176,17 @@ class CommandSensor(ManualTriggerSensorEntity): LOGGER.warning("Unable to parse output as JSON: %s", value) else: LOGGER.warning("Empty reply found when expecting JSON data") + if self._value_template is None: self._attr_native_value = None - self._process_manual_data(value) + self._process_manual_data(variables) + self.async_write_ha_state() return self._attr_native_value = None if self._value_template is not None and value is not None: - value = self._value_template.async_render_with_possible_json_value( - value, - None, + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) if self.device_class not in { @@ -190,7 +199,7 @@ class CommandSensor(ManualTriggerSensorEntity): value, self.entity_id, self.device_class ) - self._process_manual_data(value) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 31400048ddc..9d6b84c105f 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -19,7 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + ManualTriggerEntity, + ValueTemplate, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify @@ -78,7 +81,7 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): command_on: str, command_off: str, command_state: str | None, - value_template: Template | None, + value_template: ValueTemplate | None, timeout: int, scan_interval: timedelta, ) -> None: @@ -166,15 +169,21 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Update device state.""" if self._command_state: payload = str(await self._async_query_state()) + + variables = self._template_variables_with_value(payload) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + value = None if self._value_template: - value = self._value_template.async_render_with_possible_json_value( - payload, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) self._attr_is_on = None if payload or value: self._attr_is_on = (value or payload).lower() == "true" - self._process_manual_data(payload) + self._process_manual_data(variables) self.async_write_ha_state() async def async_update(self) -> None: diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index fa5bd388009..2e73f1b1b82 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -32,6 +32,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -132,7 +133,7 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): config[CONF_FORCE_UPDATE], ) self._previous_data = None - self._value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + self._value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) @property def available(self) -> bool: @@ -156,11 +157,14 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): ) return - raw_value = response + variables = self._template_variables_with_value(response) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return if response is not None and self._value_template is not None: - response = self._value_template.async_render_with_possible_json_value( - response, False + response = self._value_template.async_render_as_value_template( + self.entity_id, variables, False ) try: @@ -173,5 +177,5 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): "yes": True, }.get(str(response).lower(), False) - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index 62ed2d5c5b2..bddad18586e 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -31,6 +31,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, + ValueTemplate, ) from homeassistant.util.ssl import SSLCipherList @@ -76,7 +77,9 @@ SENSOR_SCHEMA = { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_AVAILABILITY): cv.template, } @@ -84,7 +87,9 @@ SENSOR_SCHEMA = { BINARY_SENSOR_SCHEMA = { **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_AVAILABILITY): cv.template, } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index b95e6dd72b7..9df10197a1a 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -36,6 +36,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -138,7 +139,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): config.get(CONF_RESOURCE_TEMPLATE), config[CONF_FORCE_UPDATE], ) - self._value_template = config.get(CONF_VALUE_TEMPLATE) + self._value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) self._json_attrs = config.get(CONF_JSON_ATTRS) self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) self._attr_extra_state_attributes = {} @@ -165,16 +166,19 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): ) value = self.rest.data + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return + if self._json_attrs: self._attr_extra_state_attributes = parse_json_attributes( value, self._json_attrs, self._json_attrs_path ) - raw_value = value - if value is not None and self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, None + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, None ) if value is None or self.device_class not in ( @@ -182,7 +186,7 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): SensorDeviceClass.TIMESTAMP, ): self._attr_native_value = value - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() return @@ -190,5 +194,5 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): value, self.entity_id, self.device_class ) - self._process_manual_data(raw_value) + self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index e4bb1f797d9..4f16503a2ea 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -38,6 +38,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_PICTURE, TEMPLATE_ENTITY_BASE_SCHEMA, ManualTriggerEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -73,7 +74,9 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( vol.Optional(CONF_PARAMS): {cv.string: cv.template}, vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template, vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template, - vol.Optional(CONF_IS_ON_TEMPLATE): cv.template, + vol.Optional(CONF_IS_ON_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All( vol.Lower, vol.In(SUPPORT_REST_METHODS) ), @@ -107,7 +110,7 @@ async def async_setup_platform( try: switch = RestSwitch(hass, config, trigger_entity_config) - req = await switch.get_device_state(hass) + req = await switch.get_response(hass) if req.status_code >= HTTPStatus.BAD_REQUEST: _LOGGER.error("Got non-ok response from resource: %s", req.status_code) else: @@ -147,7 +150,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): self._auth = auth self._body_on: template.Template = config[CONF_BODY_ON] self._body_off: template.Template = config[CONF_BODY_OFF] - self._is_on_template: template.Template | None = config.get(CONF_IS_ON_TEMPLATE) + self._is_on_template: ValueTemplate | None = config.get(CONF_IS_ON_TEMPLATE) self._timeout: int = config[CONF_TIMEOUT] self._verify_ssl: bool = config[CONF_VERIFY_SSL] @@ -208,35 +211,41 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): """Get the current state, catching errors.""" req = None try: - req = await self.get_device_state(self.hass) + req = await self.get_response(self.hass) except (TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError: _LOGGER.exception("Error while fetching data") if req: - self._process_manual_data(req.text) - self.async_write_ha_state() + self._async_update(req.text) - async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: + async def get_response(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" websession = get_async_client(hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - req = await websession.get( + return await websession.get( self._state_resource, auth=self._auth, headers=rendered_headers, params=rendered_params, timeout=self._timeout, ) - text = req.text + + def _async_update(self, text: str) -> None: + """Get the latest data from REST API and update the state.""" + + variables = self._template_variables_with_value(text) + if not self._render_availability_template(variables): + self.async_write_ha_state() + return if self._is_on_template is not None: - text = self._is_on_template.async_render_with_possible_json_value( - text, "None" + text = self._is_on_template.async_render_as_value_template( + self.entity_id, variables, "None" ) text = text.lower() if text == "true": @@ -252,4 +261,5 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): else: self._attr_is_on = None - return req + self._process_manual_data(variables) + self.async_write_ha_state() diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 68a8cf62fe4..801140157c1 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType @@ -43,7 +44,9 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), } ) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index b8ad9cb8a56..80d53a2c8b1 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -25,13 +25,14 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.template import Template +from homeassistant.helpers.template import _SENTINEL, Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerEntity, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -110,8 +111,8 @@ async def async_setup_entry( name: str = sensor_config[CONF_NAME] value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) - value_template: Template | None = ( - Template(value_string, hass) if value_string is not None else None + value_template: ValueTemplate | None = ( + ValueTemplate(value_string, hass) if value_string is not None else None ) trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name} @@ -150,7 +151,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti select: str, attr: str | None, index: int, - value_template: Template | None, + value_template: ValueTemplate | None, yaml: bool, ) -> None: """Initialize a web scrape sensor.""" @@ -161,7 +162,6 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self._index = index self._value_template = value_template self._attr_native_value = None - self._available = True if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): self._attr_name = None self._attr_has_entity_name = True @@ -176,7 +176,6 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti """Parse the html extraction in the executor.""" raw_data = self.coordinator.data value: str | list[str] | None - self._available = True try: if self._attr is not None: value = raw_data.select(self._select)[self._index][self._attr] @@ -188,14 +187,12 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti value = tag.text except IndexError: _LOGGER.warning("Index '%s' not found in %s", self._index, self.entity_id) - value = None - self._available = False + return _SENTINEL except KeyError: _LOGGER.warning( "Attribute '%s' not found in %s", self._attr, self.entity_id ) - value = None - self._available = False + return _SENTINEL _LOGGER.debug("Parsed value: %s", value) return value @@ -207,26 +204,32 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti def _async_update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self._extract_value() - raw_value = value + self._attr_available = True + if (value := self._extract_value()) is _SENTINEL: + self._attr_available = False + return + + variables = self._template_variables_with_value(value) + if not self._render_availability_template(variables): + return if (template := self._value_template) is not None: - value = template.async_render_with_possible_json_value(value, None) + value = template.async_render_as_value_template( + self.entity_id, variables, None + ) if self.device_class not in { SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, }: self._attr_native_value = value - self._attr_available = self._available - self._process_manual_data(raw_value) + self._process_manual_data(variables) return self._attr_native_value = async_parse_date_datetime( value, self.entity_id, self.device_class ) - self._attr_available = self._available - self._process_manual_data(raw_value) + self._process_manual_data(variables) @property def available(self) -> bool: diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 0baecd68ec4..bd50e2050e0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -45,6 +45,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -94,7 +95,9 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEFAULT_VALUE): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_AUTH_KEY): cv.string, @@ -173,7 +176,7 @@ async def async_setup_platform( continue trigger_entity_config[key] = config[key] - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = config.get(CONF_VALUE_TEMPLATE) data = SnmpData(request_args, baseoid, accept_errors, default_value) async_add_entities([SnmpSensor(hass, data, trigger_entity_config, value_template)]) @@ -189,7 +192,7 @@ class SnmpSensor(ManualTriggerSensorEntity): hass: HomeAssistant, data: SnmpData, config: ConfigType, - value_template: Template | None, + value_template: ValueTemplate | None, ) -> None: """Initialize the sensor.""" super().__init__(hass, config) @@ -206,17 +209,16 @@ class SnmpSensor(ManualTriggerSensorEntity): """Get the latest data and updates the states.""" await self.data.async_update() - raw_value = self.data.value - + variables = self._template_variables_with_value(self.data.value) if (value := self.data.value) is None: value = STATE_UNKNOWN elif self._value_template is not None: - value = self._value_template.async_render_with_possible_json_value( - value, STATE_UNKNOWN + value = self._value_template.async_render_as_value_template( + self.entity_id, variables, STATE_UNKNOWN ) self._attr_native_value = value - self._process_manual_data(raw_value) + self._process_manual_data(variables) class SnmpData: diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 1b9e8502209..e3e6c699d03 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType @@ -55,7 +56,9 @@ QUERY_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.template, vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): vol.All( + cv.template, ValueTemplate.from_template + ), vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DB_URL): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index a7b488dd521..b86a33db7ab 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -45,6 +45,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerSensorEntity, + ValueTemplate, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -79,7 +80,7 @@ async def async_setup_platform( name: Template = conf[CONF_NAME] query_str: str = conf[CONF_QUERY] - value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) + value_template: ValueTemplate | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) @@ -116,10 +117,10 @@ async def async_setup_entry( template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] - value_template: Template | None = None + value_template: ValueTemplate | None = None if template is not None: try: - value_template = Template(template, hass) + value_template = ValueTemplate(template, hass) value_template.ensure_valid() except TemplateError: value_template = None @@ -179,7 +180,7 @@ async def async_setup_sensor( trigger_entity_config: ConfigType, query_str: str, column_name: str, - value_template: Template | None, + value_template: ValueTemplate | None, unique_id: str | None, db_url: str, yaml: bool, @@ -316,7 +317,7 @@ class SQLSensor(ManualTriggerSensorEntity): sessmaker: scoped_session, query: str, column: str, - value_template: Template | None, + value_template: ValueTemplate | None, yaml: bool, use_database_executor: bool, ) -> None: @@ -359,14 +360,14 @@ class SQLSensor(ManualTriggerSensorEntity): async def async_update(self) -> None: """Retrieve sensor data from the query using the right executor.""" if self._use_database_executor: - data = await get_instance(self.hass).async_add_executor_job(self._update) + await get_instance(self.hass).async_add_executor_job(self._update) else: - data = await self.hass.async_add_executor_job(self._update) - self._process_manual_data(data) + await self.hass.async_add_executor_job(self._update) - def _update(self) -> Any: + def _update(self) -> None: """Retrieve sensor data from the query.""" data = None + extra_state_attributes = {} self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: @@ -379,7 +380,7 @@ class SQLSensor(ManualTriggerSensorEntity): ) sess.rollback() sess.close() - return None + return for res in result.mappings(): _LOGGER.debug("Query %s result in %s", self._query, res.items()) @@ -391,15 +392,19 @@ class SQLSensor(ManualTriggerSensorEntity): value = value.isoformat() elif isinstance(value, (bytes, bytearray)): value = f"0x{value.hex()}" + extra_state_attributes[key] = value self._attr_extra_state_attributes[key] = value if data is not None and isinstance(data, (bytes, bytearray)): data = f"0x{data.hex()}" if data is not None and self._template is not None: - self._attr_native_value = ( - self._template.async_render_with_possible_json_value(data, None) - ) + variables = self._template_variables_with_value(data) + if self._render_availability_template(variables): + self._attr_native_value = self._template.async_render_as_value_template( + self.entity_id, variables, None + ) + self._process_manual_data(variables) else: self._attr_native_value = data @@ -407,4 +412,3 @@ class SQLSensor(ManualTriggerSensorEntity): _LOGGER.warning("%s returned no results", self._query) sess.close() - return data diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 87c93b6143b..d440d626606 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import Any + +from homeassistant.const import CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.template import TemplateStateFromEntityId +from homeassistant.helpers.template import _SENTINEL from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,6 +32,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module TriggerBaseEntity.__init__(self, hass, config) AbstractTemplateEntity.__init__(self, hass) + self._state_render_error = False + async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() @@ -47,22 +52,47 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Return referenced blueprint or None.""" return self.coordinator.referenced_blueprint + @property + def available(self) -> bool: + """Return availability of the entity.""" + if self._state_render_error: + return False + + return super().available + @callback def _render_script_variables(self) -> dict: """Render configured variables.""" return self.coordinator.data["run_variables"] + def _render_templates(self, variables: dict[str, Any]) -> None: + """Render templates.""" + self._state_render_error = False + rendered = dict(self._static_rendered) + + # If state fails to render, the entity should go unavailable. Render the + # state as a simple template because the result should always be a string or None. + if CONF_STATE in self._to_render_simple: + if ( + result := self._render_single_template(CONF_STATE, variables) + ) is _SENTINEL: + self._rendered = self._static_rendered + self._state_render_error = True + return + + rendered[CONF_STATE] = result + + self._render_single_templates(rendered, variables, [CONF_STATE]) + self._render_attributes(rendered, variables) + self._rendered = rendered + @callback def _process_data(self) -> None: """Process new data.""" - run_variables = self.coordinator.data["run_variables"] - variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **(run_variables or {}), - } - - self._render_templates(variables) + variables = self._template_variables(self.coordinator.data["run_variables"]) + if self._render_availability_template(variables): + self._render_templates(variables) self.async_set_context(self.coordinator.data["context"]) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 1486e33d6fa..bf7598eb024 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -2,10 +2,11 @@ from __future__ import annotations -import contextlib +import itertools import logging from typing import Any +import jinja2 import voluptuous as vol from homeassistant.components.sensor import ( @@ -30,7 +31,14 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import config_validation as cv from .entity import Entity -from .template import TemplateStateFromEntityId, render_complex +from .template import ( + _SENTINEL, + Template, + TemplateStateFromEntityId, + _render_with_context, + render_complex, + result_as_boolean, +) from .typing import ConfigType CONF_AVAILABILITY = "availability" @@ -65,6 +73,27 @@ def make_template_entity_base_schema(default_name: str) -> vol.Schema: ) +def log_triggered_template_error( + entity_id: str, + err: TemplateError, + key: str | None = None, + attribute: str | None = None, +) -> None: + """Log a trigger entity template error.""" + target = "" + if key: + target = f" {key}" + elif attribute: + target = f" {CONF_ATTRIBUTES}.{attribute}" + + logging.getLogger(f"{__package__}.{entity_id.split('.')[0]}").error( + "Error rendering%s template for %s: %s", + target, + entity_id, + err, + ) + + TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, @@ -74,6 +103,44 @@ TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( ).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) +class ValueTemplate(Template): + """Class to hold a value_template and manage caching and rendering it with 'value' in variables.""" + + @classmethod + def from_template(cls, template: Template) -> ValueTemplate: + """Create a ValueTemplate object from a Template object.""" + return cls(template.template, template.hass) + + @callback + def async_render_as_value_template( + self, entity_id: str, variables: dict[str, Any], error_value: Any + ) -> Any: + """Render template that requires 'value' and optionally 'value_json'. + + Template errors will be suppressed when an error_value is supplied. + + This method must be run in the event loop. + """ + self._renders += 1 + + if self.is_static: + return self.template + + compiled = self._compiled or self._ensure_compiled() + + try: + render_result = _render_with_context( + self.template, compiled, **variables + ).strip() + except jinja2.TemplateError as ex: + message = f"Error parsing value for {entity_id}: {ex} (value: {variables['value']}, template: {self.template})" + logger = logging.getLogger(f"{__package__}.{entity_id.split('.')[0]}") + logger.debug(message) + return error_value + + return render_result + + class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" @@ -122,6 +189,9 @@ class TriggerBaseEntity(Entity): self._parse_result = {CONF_AVAILABILITY} self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._availability_template = config.get(CONF_AVAILABILITY) + self._available = True + @property def name(self) -> str | None: """Name of the entity.""" @@ -145,12 +215,10 @@ class TriggerBaseEntity(Entity): @property def available(self) -> bool: """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) + if self._availability_template is None: + return True + + return self._available @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -176,35 +244,93 @@ class TriggerBaseEntity(Entity): extra_state_attributes[attr] = last_state.attributes[attr] self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + def _template_variables(self, run_variables: dict[str, Any] | None = None) -> dict: + """Render template variables.""" + return { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **(run_variables or {}), + } + + def _render_single_template( + self, + key: str, + variables: dict[str, Any], + strict: bool = False, + ) -> Any: + """Render a single template.""" + try: + if key in self._to_render_complex: + return render_complex(self._config[key], variables) + + return self._config[key].async_render( + variables, parse_result=key in self._parse_result, strict=strict + ) + except TemplateError as err: + log_triggered_template_error(self.entity_id, err, key=key) + + return _SENTINEL + + def _render_availability_template(self, variables: dict[str, Any]) -> bool: + """Render availability template.""" + if not self._availability_template: + return True + + try: + if ( + available := self._availability_template.async_render( + variables, parse_result=True, strict=True + ) + ) is False: + self._rendered = dict(self._static_rendered) + + self._available = result_as_boolean(available) + + except TemplateError as err: + # The entity will be available when an error is rendered. This + # ensures functionality is consistent between template and trigger template + # entities. + self._available = True + log_triggered_template_error(self.entity_id, err, key=CONF_AVAILABILITY) + + return self._available + + def _render_attributes(self, rendered: dict, variables: dict[str, Any]) -> None: + """Render template attributes.""" + if CONF_ATTRIBUTES in self._config: + attributes = {} + for attribute, attribute_template in self._config[CONF_ATTRIBUTES].items(): + try: + value = render_complex(attribute_template, variables) + attributes[attribute] = value + variables.update({attribute: value}) + except TemplateError as err: + log_triggered_template_error( + self.entity_id, err, attribute=attribute + ) + rendered[CONF_ATTRIBUTES] = attributes + + def _render_single_templates( + self, + rendered: dict, + variables: dict[str, Any], + filtered: list[str] | None = None, + ) -> None: + """Render all single templates.""" + for key in itertools.chain(self._to_render_simple, self._to_render_complex): + if filtered and key in filtered: + continue + + if ( + result := self._render_single_template(key, variables) + ) is not _SENTINEL: + rendered[key] = result + def _render_templates(self, variables: dict[str, Any]) -> None: """Render templates.""" - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered + rendered = dict(self._static_rendered) + self._render_single_templates(rendered, variables) + self._render_attributes(rendered, variables) + self._rendered = rendered class ManualTriggerEntity(TriggerBaseEntity): @@ -223,23 +349,31 @@ class ManualTriggerEntity(TriggerBaseEntity): parse_result=CONF_NAME in self._parse_result, ) + def _template_variables_with_value( + self, value: str | None = None + ) -> dict[str, Any]: + """Render template variables. + + Implementing class should call this first in update method to render variables for templates. + Ex: variables = self._render_template_variables_with_value(payload) + """ + run_variables: dict[str, Any] = {"value": value} + + # Silently try if variable is a json and store result in `value_json` if it is. + try: # noqa: SIM105 - suppress is much slower + run_variables["value_json"] = json_loads(value) # type: ignore[arg-type] + except JSON_DECODE_EXCEPTIONS: + pass + + return self._template_variables(run_variables) + @callback - def _process_manual_data(self, value: Any | None = None) -> None: + def _process_manual_data(self, variables: dict[str, Any]) -> None: """Process new data manually. Implementing class should call this last in update method to render templates. - Ex: self._process_manual_data(payload) + Ex: self._process_manual_data(variables) """ - - run_variables: dict[str, Any] = {"value": value} - # Silently try if variable is a json and store result in `value_json` if it is. - with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): - run_variables["value_json"] = json_loads(run_variables["value"]) - variables = { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **(run_variables or {}), - } - self._render_templates(variables) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index aa49410aacb..fb7a407cee5 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -331,9 +331,10 @@ async def test_updating_manually( "name": "Test", "command": "echo 10", "payload_on": "1.0", - "payload_off": "0", + "payload_off": "0.0", "value_template": "{{ value | multiply(0.1) }}", - "availability": '{{ states("sensor.input1")=="on" }}', + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', } } ] @@ -346,8 +347,7 @@ async def test_availability( freezer: FrozenDateTimeFactory, ) -> None: """Test availability.""" - - hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input1", STATE_ON) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) @@ -355,8 +355,9 @@ async def test_availability( entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes["icon"] == "mdi:on" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"0"): freezer.tick(timedelta(minutes=1)) @@ -366,3 +367,64 @@ async def test_availability( entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", STATE_OFF) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"0"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_OFF + assert entity_state.attributes["icon"] == "mdi:off" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0.0", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + } + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for binary_sensor.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index a6e384fdd6b..5010b85ae70 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -371,7 +371,9 @@ async def test_updating_manually( "cover": { "command_state": "echo 10", "name": "Test", - "availability": '{{ states("sensor.input1")=="on" }}', + "value_template": "{{ value }}", + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', }, } ] @@ -393,8 +395,9 @@ async def test_availability( entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == CoverState.OPEN + assert entity_state.attributes["icon"] == "mdi:on" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"50\n"): freezer.tick(timedelta(minutes=1)) @@ -404,6 +407,19 @@ async def test_availability( entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"25\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes["icon"] == "mdi:off" async def test_icon_template(hass: HomeAssistant) -> None: @@ -455,3 +471,49 @@ async def test_icon_template(hass: HomeAssistant) -> None: entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.attributes.get("icon") == "mdi:icon2" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo 10", + "name": "Test", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for cover.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index f7879b334cd..9c619537b94 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -772,17 +772,92 @@ async def test_template_not_error_when_data_is_none( { "sensor": { "name": "Test", - "command": "echo January 17, 2022", - "device_class": "date", - "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", - "availability": '{{ states("sensor.input1")=="on" }}', + "command": 'echo { \\"key\\": \\"value\\" }', + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', + "json_attributes": ["key"], } } ] } ], ) -async def test_availability( +async def test_availability_json_attributes_without_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability.""" + hass.states.async_set("sensor.input1", "on") + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "value" + assert entity_state.attributes["icon"] == "mdi:on" + + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"Not A Number"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unable to parse output as JSON" not in caplog.text + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + assert "key" not in entity_state.attributes + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"Not A Number"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Unable to parse output as JSON" in caplog.text + + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + with mock_asyncio_subprocess_run(b'{ "key": "value" }'): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "unknown" + assert entity_state.attributes["key"] == "value" + assert entity_state.attributes["icon"] == "mdi:on" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo January 17, 2022", + "device_class": "date", + "value_template": "{{ strptime(value, '%B %d, %Y').strftime('%Y-%m-%d') }}", + "availability": '{{ states("sensor.input1")=="on" }}', + "icon": "mdi:o{{ 'n' if states('sensor.input1')=='on' else 'ff' }}", + } + } + ] + } + ], +) +async def test_availability_with_value_template( hass: HomeAssistant, load_yaml_integration: None, freezer: FrozenDateTimeFactory, @@ -797,6 +872,7 @@ async def test_availability( entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "2022-01-17" + assert entity_state.attributes["icon"] == "mdi:on" hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() @@ -808,3 +884,141 @@ async def test_availability( entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + +async def test_template_render_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability template render with syntax errors.""" + assert await setup.async_setup_component( + hass, + "command_line", + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + "availability": "{{ what_the_heck == 2 }}", + } + } + ] + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("sensor.input_sensor", "1") + await hass.async_block_till_done() + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "1" + + assert ( + "Error rendering availability template for sensor.test: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + "availability": "{{ value|is_number}}", + "unit_of_measurement": " ", + "state_class": "measurement", + } + } + ] + } + ], +) +async def test_command_template_render_with_availability( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test command template is rendered properly with availability.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input_sensor", "1") + + # Give time for template to load + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "1" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 0", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 6b34cf0fa77..8a8835ceaa0 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -735,7 +735,9 @@ async def test_updating_manually( "command_on": "echo 2", "command_off": "echo 3", "name": "Test", - "availability": '{{ states("sensor.input1")=="on" }}', + "value_template": "{{ value_json == 0 }}", + "availability": '{{ "sensor.input1" | has_value }}', + "icon": 'mdi:{{ states("sensor.input1") }}', }, } ] @@ -749,16 +751,17 @@ async def test_availability( ) -> None: """Test availability.""" - hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input1", STATE_OFF) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) entity_state = hass.states.get("switch.test") assert entity_state - assert entity_state.state == STATE_ON + assert entity_state.state == STATE_OFF + assert entity_state.attributes["icon"] == "mdi:off" - hass.states.async_set("sensor.input1", "off") + hass.states.async_set("sensor.input1", STATE_UNAVAILABLE) await hass.async_block_till_done() with mock_asyncio_subprocess_run(b"50\n"): freezer.tick(timedelta(minutes=1)) @@ -768,3 +771,64 @@ async def test_availability( entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_UNAVAILABLE + assert "icon" not in entity_state.attributes + + hass.states.async_set("sensor.input1", STATE_ON) + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"0\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_ON + assert entity_state.attributes["icon"] == "mdi:on" + + +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + ] + } + ], +) +async def test_availability_blocks_value_template( + hass: HomeAssistant, + load_yaml_integration: None, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for switch.test: 'x' is undefined" + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"51\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error not in caplog.text + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + await hass.async_block_till_done() + with mock_asyncio_subprocess_run(b"50\n"): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 65ec6bf5c05..6992794d596 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -595,3 +595,53 @@ async def test_availability_in_config(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.rest_binary_sensor") assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for binary_sensor.block_template: 'x' is undefined" + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="51") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "binary_sensor": [ + { + "unique_id": "block_template", + "name": "block_template", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("binary_sensor.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["binary_sensor.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index d5fc5eca55c..81440125b12 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1035,22 +1035,211 @@ async def test_entity_config( @respx.mock async def test_availability_in_config(hass: HomeAssistant) -> None: """Test entity configuration.""" - - config = { - SENSOR_DOMAIN: { - # REST configuration - "platform": DOMAIN, - "method": "GET", - "resource": "http://localhost", - # Entity configuration - "availability": "{{value==1}}", - "name": "{{'REST' + ' ' + 'Sensor'}}", + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={ + "state": "okay", + "available": True, + "name": "rest_sensor", + "icon": "mdi:foo", + "picture": "foo.jpg", }, - } + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "somethingunique", + "availability": "{{ value_json.available }}", + "value_template": "{{ value_json.state }}", + "name": "{{ value_json.name if value_json is defined else 'rest_sensor' }}", + "icon": "{{ value_json.icon }}", + "picture": "{{ value_json.picture }}", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() - respx.get("http://localhost").respond(status_code=HTTPStatus.OK, text="123") - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + state = hass.states.get("sensor.rest_sensor") + assert state.state == "okay" + assert state.attributes["friendly_name"] == "rest_sensor" + assert state.attributes["icon"] == "mdi:foo" + assert state.attributes["entity_picture"] == "foo.jpg" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={ + "state": "okay", + "available": False, + "name": "unavailable", + "icon": "mdi:unavailable", + "picture": "unavailable.jpg", + }, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.rest_sensor"]}, + blocking=True, + ) await hass.async_block_till_done() state = hass.states.get("sensor.rest_sensor") assert state.state == STATE_UNAVAILABLE + assert "friendly_name" not in state.attributes + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + +@respx.mock +async def test_json_response_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability with syntax error.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}}, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "complex_json", + "name": "complex_json", + "value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}', + "availability": "{{ what_the_heck == 2 }}", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + + state = hass.states.get("sensor.complex_json") + assert state.state == "21.4" + + assert ( + "Error rendering availability template for sensor.complex_json: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +@respx.mock +async def test_json_response_with_availability(hass: HomeAssistant) -> None: + """Test availability with complex json.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 1, "ping": 21.4}]}}, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "complex_json", + "name": "complex_json", + "value_template": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.ping }}', + "availability": '{% set v = value_json.heartbeatList["1"][-1] %}{{ v.status == 1 and is_number(v.ping) }}', + "unit_of_measurement": "ms", + "state_class": "measurement", + } + ], + } + ] + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + + state = hass.states.get("sensor.complex_json") + assert state.state == "21.4" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"heartbeatList": {"1": [{"status": 0, "ping": None}]}}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.complex_json"]}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.complex_json") + assert state.state == STATE_UNAVAILABLE + + +@respx.mock +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.block_template: 'x' is undefined" + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="51") + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "sensor": [ + { + "unique_id": "block_template", + "name": "block_template", + "value_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + "unit_of_measurement": "ms", + "state_class": "measurement", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index e0fc36d053e..2a69f5a477a 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -37,6 +37,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -482,3 +483,122 @@ async def test_entity_config( ATTR_FRIENDLY_NAME: "REST Switch", ATTR_ICON: "mdi:one_two_three", } + + +@respx.mock +async def test_availability( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity configuration.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"beer": 1}, + ) + assert await async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: { + # REST configuration + CONF_PLATFORM: DOMAIN, + CONF_METHOD: "POST", + CONF_RESOURCE: "http://localhost", + # Entity configuration + CONF_NAME: "{{'REST' + ' ' + 'Switch'}}", + "is_on_template": "{{ value_json.beer == 1 }}", + "availability": "{{ value_json.beer is defined }}", + CONF_ICON: "mdi:{{ value_json.beer }}", + CONF_PICTURE: "{{ value_json.beer }}.png", + }, + }, + ) + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_ON + assert state.attributes["icon"] == "mdi:1" + assert state.attributes["entity_picture"] == "1.png" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"x": 1}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.rest_switch"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_UNAVAILABLE + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + json={"beer": 0}, + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.rest_switch"]}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.rest_switch") + assert state + assert state.state == STATE_OFF + assert state.attributes["icon"] == "mdi:0" + assert state.attributes["entity_picture"] == "0.png" + + +@respx.mock +async def test_availability_blocks_is_on_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks is_on_template from rendering.""" + error = "Error parsing value for switch.block_template: 'x' is undefined" + respx.get(RESOURCE).respond(status_code=HTTPStatus.OK, content="51") + config = { + SWITCH_DOMAIN: { + # REST configuration + CONF_PLATFORM: DOMAIN, + CONF_METHOD: "POST", + CONF_RESOURCE: "http://localhost", + # Entity configuration + CONF_NAME: "block_template", + "is_on_template": "{{ x - 1 }}", + "availability": "{{ value == '50' }}", + }, + } + + assert await async_setup_component(hass, SWITCH_DOMAIN, config) + await hass.async_block_till_done() + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("switch.block_template") + assert state + assert state.state == STATE_UNAVAILABLE + + respx.clear() + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, content="50") + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["switch.block_template"]}, + blocking=True, + ) + await hass.async_block_till_done() + + assert error in caplog.text diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index dc9bf912c2d..c97e2cd3716 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -594,6 +594,8 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: CONF_INDEX: 0, CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}', + CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}', + CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg', } ], } @@ -613,6 +615,8 @@ async def test_availability( state = hass.states.get("sensor.current_version") assert state.state == "2021.12.10" + assert state.attributes["icon"] == "mdi:on" + assert state.attributes["entity_picture"] == "on.jpg" hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() @@ -623,3 +627,93 @@ async def test_availability( state = hass.states.get("sensor.current_version") assert state.state == STATE_UNAVAILABLE + assert "icon" not in state.attributes + assert "entity_picture" not in state.attributes + + +async def test_template_render_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability template render with syntax errors.""" + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + "select": ".current-version h1", + "name": "Current version", + "unique_id": "ha_version_unique_id", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_AVAILABILITY: "{{ what_the_heck == 2 }}", + } + ] + ) + ] + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.current_version") + assert state.state == "2021.12.10" + + assert ( + "Error rendering availability template for sensor.current_version: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.current_version: 'x' is undefined" + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + "select": ".current-version h1", + "name": "Current version", + "unique_id": "ha_version_unique_id", + CONF_VALUE_TEMPLATE: "{{ x - 1 }}", + CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}', + } + ] + ) + ] + } + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.current_version") + assert state + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=10), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 6b4032323d0..354840c518e 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -317,6 +317,8 @@ async def test_templates_with_yaml( state = hass.states.get("sensor.get_values_with_template") assert state.state == STATE_UNAVAILABLE + assert CONF_ICON not in state.attributes + assert "entity_picture" not in state.attributes hass.states.async_set("sensor.input1", "on") hass.states.async_set("sensor.input2", "on") @@ -660,3 +662,37 @@ async def test_setup_without_recorder(hass: HomeAssistant) -> None: state = hass.states.get("sensor.get_value") assert state.state == "5" + + +async def test_availability_blocks_value_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test availability blocks value_template from rendering.""" + error = "Error parsing value for sensor.get_value: 'x' is undefined" + config = YAML_CONFIG + config["sql"]["value_template"] = "{{ x - 0 }}" + config["sql"]["availability"] = '{{ states("sensor.input1")=="on" }}' + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert error not in caplog.text + + state = hass.states.get("sensor.get_value") + assert state + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert error in caplog.text diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 6f0e6be8a2a..e7af5296d4e 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1527,6 +1527,217 @@ async def test_trigger_entity_available(hass: HomeAssistant) -> None: assert state.state == "unavailable" +async def test_trigger_entity_available_skips_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity availability works.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Never Available", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ noexist - 1 }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.never_available") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.never_available") + assert state.state == "unavailable" + + assert "'noexist' is undefined" not in caplog.text + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.never_available") + assert state.state == "unavailable" + + assert "'noexist' is undefined" in caplog.text + + +async def test_trigger_state_with_availability_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity is available when attributes have syntax errors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ what_the_heck == 2 }}", + "state": "{{ trigger.event.data.beer }}", + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert ( + "Error rendering availability template for sensor.test_sensor: UndefinedError: 'what_the_heck' is undefined" + in caplog.text + ) + + +async def test_trigger_available_with_attribute_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity is available when attributes have syntax errors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + "attributes": { + "beer": "{{ trigger.event.data.beer }}", + "no_beer": "{{ sad - 1 }}", + "more_beer": "{{ beer + 1 }}", + }, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert "no_beer" not in state.attributes + assert ( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + in caplog.text + ) + assert state.attributes["more_beer"] == 3 + + +async def test_trigger_attribute_order( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test trigger entity attributes order.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Test Sensor", + "availability": "{{ trigger and trigger.event.data.beer == 2 }}", + "state": "{{ trigger.event.data.beer }}", + "attributes": { + "beer": "{{ trigger.event.data.beer }}", + "no_beer": "{{ sad - 1 }}", + "more_beer": "{{ beer + 1 }}", + "all_the_beer": "{{ this.state | int + more_beer }}", + }, + }, + ], + }, + ], + }, + ) + + await hass.async_block_till_done() + + # Sensors are unknown if never triggered + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert "no_beer" not in state.attributes + assert ( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + in caplog.text + ) + assert state.attributes["more_beer"] == 3 + assert ( + "Error rendering attributes.all_the_beer template for sensor.test_sensor: ValueError: Template error: int got invalid input 'unknown' when rendering template '{{ this.state | int + more_beer }}' but no default was specified" + in caplog.text + ) + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state.state == "2" + + assert state.attributes["beer"] == 2 + assert state.attributes["more_beer"] == 3 + assert state.attributes["all_the_beer"] == 5 + + assert ( + caplog.text.count( + "Error rendering attributes.no_beer template for sensor.test_sensor: UndefinedError: 'sad' is undefined" + ) + == 2 + ) + + async def test_trigger_entity_device_class_parsing_works(hass: HomeAssistant) -> None: """Test trigger entity device class parsing works.""" assert await async_setup_component( diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 99aa2d65df9..2f7e974e727 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -1,8 +1,28 @@ """Test trigger template entity.""" +import pytest + from homeassistant.components.template import trigger_entity from homeassistant.components.template.coordinator import TriggerUpdateCoordinator +from homeassistant.const import CONF_ICON, CONF_NAME, CONF_STATE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import template +from homeassistant.helpers.trigger_template_entity import CONF_PICTURE + +_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' +_PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' + + +class TestEntity(trigger_entity.TriggerEntity): + """Test entity class.""" + + __test__ = False + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return self._rendered.get(CONF_STATE) async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: @@ -11,3 +31,90 @@ async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: entity = trigger_entity.TriggerEntity(hass, coordinator, {}) assert entity.referenced_blueprint is None + + +async def test_template_state(hass: HomeAssistant) -> None: + """Test manual trigger template entity with a state.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ value == 'on' }}", hass), + } + + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"value": STATE_ON}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.state == "True" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + coordinator._execute_update({"value": STATE_OFF}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.state == "False" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + +async def test_bad_template_state(hass: HomeAssistant) -> None: + """Test manual trigger template entity with a state.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ x - 1 }}", hass), + } + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"x": 1}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.available is True + assert entity.state == "0" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + coordinator._execute_update({"value": STATE_OFF}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert entity.available is False + assert entity.state is None + assert entity.icon is None + assert entity.entity_picture is None + + +async def test_template_state_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when state render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ incorrect ", hass), + } + + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, config) + entity.entity_id = "test.entity" + + coordinator._execute_update({"value": STATE_ON}) + entity._handle_coordinator_update() + await hass.async_block_till_done() + + assert f"Error rendering {CONF_STATE} template for test.entity" in caplog.text + + assert entity.state is None + assert entity.icon is None + assert entity.entity_picture is None diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index a18827ecb4c..8389218054d 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -1,8 +1,82 @@ """Test template trigger entity.""" +from typing import Any + +import pytest + +from homeassistant.const import ( + CONF_ICON, + CONF_NAME, + CONF_STATE, + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerEntity, + ValueTemplate, +) + +_ICON_TEMPLATE = 'mdi:o{{ "n" if value=="on" else "ff" }}' +_PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' + + +@pytest.mark.parametrize( + ("value", "test_template", "error_value", "expected", "error"), + [ + (1, "{{ value == 1 }}", None, "True", None), + (1, "1", None, "1", None), + ( + 1, + "{{ x - 4 }}", + None, + None, + "", + ), + ( + 1, + "{{ x - 4 }}", + template._SENTINEL, + template._SENTINEL, + "Error parsing value for test.entity: 'x' is undefined (value: 1, template: {{ x - 4 }})", + ), + ], +) +async def test_value_template_object( + hass: HomeAssistant, + value: Any, + test_template: str, + error_value: Any, + expected: Any, + error: str | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ValueTemplate object.""" + entity = ManualTriggerEntity( + hass, + { + CONF_NAME: template.Template("test_entity", hass), + }, + ) + entity.entity_id = "test.entity" + + value_template = ValueTemplate.from_template(template.Template(test_template, hass)) + + variables = entity._template_variables_with_value(value) + result = value_template.async_render_as_value_template( + entity.entity_id, variables, error_value + ) + + assert result == expected + + if error is not None: + assert error in caplog.text async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: @@ -20,21 +94,197 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: entity = ManualTriggerEntity(hass, config) entity.entity_id = "test.entity" - hass.states.async_set("test.entity", "on") + hass.states.async_set("test.entity", STATE_ON) await entity.async_added_to_hass() - entity._process_manual_data("on") + variables = entity._template_variables_with_value(STATE_ON) + entity._process_manual_data(variables) await hass.async_block_till_done() assert entity.name == "test_entity" assert entity.icon == "mdi:on" assert entity.entity_picture == "/local/picture_on" - hass.states.async_set("test.entity", "off") + hass.states.async_set("test.entity", STATE_OFF) await entity.async_added_to_hass() - entity._process_manual_data("off") + + variables = entity._template_variables_with_value(STATE_OFF) + entity._process_manual_data(variables) await hass.async_block_till_done() assert entity.name == "test_entity" assert entity.icon == "mdi:off" assert entity.entity_picture == "/local/picture_off" + + +@pytest.mark.parametrize( + ("test_template", "test_entity_state", "expected"), + [ + ('{{ has_value("test.entity") }}', STATE_ON, True), + ('{{ has_value("test.entity") }}', STATE_OFF, True), + ('{{ has_value("test.entity") }}', STATE_UNKNOWN, False), + ('{{ "a" if has_value("test.entity") else "b" }}', STATE_ON, False), + ('{{ "something_not_boolean" }}', STATE_OFF, False), + ("{{ 1 }}", STATE_OFF, True), + ("{{ 0 }}", STATE_OFF, False), + ], +) +async def test_trigger_template_availability( + hass: HomeAssistant, + test_template: str, + test_entity_state: str, + expected: bool, +) -> None: + """Test manual trigger template entity availability template.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_AVAILABILITY: template.Template(test_template, hass), + CONF_UNIQUE_ID: "9961786c-f8c8-4ea0-ab1d-b9e922c39088", + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", test_entity_state) + await entity.async_added_to_hass() + + variables = entity._template_variables() + assert entity._render_availability_template(variables) is expected + await hass.async_block_till_done() + + assert entity.unique_id == "9961786c-f8c8-4ea0-ab1d-b9e922c39088" + assert entity.available is expected + + +async def test_trigger_no_availability_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template(_ICON_TEMPLATE, hass), + CONF_PICTURE: template.Template(_PICTURE_TEMPLATE, hass), + CONF_STATE: template.Template("{{ value == 'on' }}", hass), + } + + class TestEntity(ManualTriggerEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return self._rendered.get(CONF_STATE) + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value(STATE_ON) + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.state == "True" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + variables = entity._template_variables_with_value(STATE_OFF) + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.state == "False" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" + + +async def test_trigger_template_availability_with_syntax_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_AVAILABILITY: template.Template("{{ incorrect ", hass), + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + + variables = entity._template_variables() + entity._render_availability_template(variables) + assert entity.available is True + + assert "Error rendering availability template for test.entity" in caplog.text + + +async def test_attribute_order( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability render fails.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ATTRIBUTES: { + "beer": template.Template("{{ value }}", hass), + "no_beer": template.Template("{{ sad - 1 }}", hass), + "more_beer": template.Template("{{ beer + 1 }}", hass), + }, + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", STATE_ON) + await entity.async_added_to_hass() + + variables = entity._template_variables_with_value(1) + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.extra_state_attributes == {"beer": 1, "more_beer": 2} + + assert ( + "Error rendering attributes.no_beer template for test.entity: UndefinedError: 'sad' is undefined" + in caplog.text + ) + + +async def test_trigger_template_complex(hass: HomeAssistant) -> None: + """Test manual trigger template entity complex template.""" + complex_template = """ + {% set d = {'test_key':'test_data'} %} + {{ dict(d) }} + +""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_ICON: template.Template( + '{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass + ), + CONF_PICTURE: template.Template( + '{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}', + hass, + ), + CONF_AVAILABILITY: template.Template('{{ has_value("test.entity") }}', hass), + "other_key": template.Template(complex_template, hass), + } + + class TestEntity(ManualTriggerEntity): + """Test entity class.""" + + extra_template_keys_complex = ("other_key",) + + @property + def some_other_key(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get("other_key") + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", STATE_ON) + await entity.async_added_to_hass() + + variables = entity._template_variables_with_value(STATE_ON) + entity._process_manual_data(variables) + await hass.async_block_till_done() + + assert entity.some_other_key == {"test_key": "test_data"}