Update trigger based template entity resolution order (#140660)

* Update trigger based template entity resolution order

* add test

* fix most comments

* Move resolution to base class

* add comment

* remove uncessary if statement

* add more tests

* update availability tests

* update logic stage 1

* phase 2 changes

* fix trigger template entity tests

* fix trigger template entities

* command line tests

* sql tests

* scrape test

* update doc string

* add rest tests

* update sql sensor _update signature

* fix scrape test constructor

* move state check to trigger_entity

* fix comments

* Update homeassistant/components/template/trigger_entity.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/helpers/trigger_template_entity.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/helpers/trigger_template_entity.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* update command_line and rest

* update scrape

* update sql

* add case to command_line sensor

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Petro31 2025-04-25 07:17:25 -04:00 committed by GitHub
parent dc8e1773f1
commit ff2c901930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1892 additions and 185 deletions

View File

@ -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,

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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()

View File

@ -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,
}

View File

@ -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()

View File

@ -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()

View File

@ -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
),
}
)

View File

@ -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:

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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"])

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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"}