diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index ae734f4fc67..b2ec7397e3d 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator, Sequence from enum import Enum import logging from typing import TYPE_CHECKING, Any @@ -29,8 +28,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -39,6 +37,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import validators as tcv from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity @@ -56,19 +55,6 @@ from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -_VALID_STATES = [ - AlarmControlPanelState.ARMED_AWAY, - AlarmControlPanelState.ARMED_CUSTOM_BYPASS, - AlarmControlPanelState.ARMED_HOME, - AlarmControlPanelState.ARMED_NIGHT, - AlarmControlPanelState.ARMED_VACATION, - AlarmControlPanelState.ARMING, - AlarmControlPanelState.DISARMED, - AlarmControlPanelState.DISARMING, - AlarmControlPanelState.PENDING, - AlarmControlPanelState.TRIGGERED, - STATE_UNAVAILABLE, -] CONF_ALARM_CONTROL_PANELS = "panels" CONF_ARM_AWAY_ACTION = "arm_away" @@ -212,22 +198,22 @@ class AbstractTemplateAlarmControlPanel( _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True - # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. - # This ensures that the __init__ on AbstractTemplateEntity is not called twice. - def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called - """Initialize the features.""" - self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] - self._attr_code_format = config[CONF_CODE_FORMAT].value + # The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__. + def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called + """Setup the templates and scripts.""" + + self._attr_code_arm_required: bool = self._config[CONF_CODE_ARM_REQUIRED] + self._attr_code_format = self._config[CONF_CODE_FORMAT].value + + self.setup_state_template( + CONF_STATE, + "_attr_alarm_state", + validator=tcv.strenum(self, CONF_STATE, AlarmControlPanelState), + ) self._attr_supported_features: AlarmControlPanelEntityFeature = ( AlarmControlPanelEntityFeature(0) ) - - def _iterate_scripts( - self, config: dict[str, Any] - ) -> Generator[ - tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int] - ]: for action_id, supported_feature in ( (CONF_DISARM_ACTION, 0), (CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY), @@ -240,35 +226,21 @@ class AbstractTemplateAlarmControlPanel( ), (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), ): - if (action_config := config.get(action_id)) is not None: - yield (action_id, action_config, supported_feature) + if (action_config := self._config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature async def _async_handle_restored_state(self) -> None: if ( (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - and last_state.state in _VALID_STATES + and last_state.state in AlarmControlPanelState # The trigger might have fired already while we waited for stored data, # then we should not restore state and self._attr_alarm_state is None ): self._attr_alarm_state = AlarmControlPanelState(last_state.state) - def _handle_state(self, result: Any) -> None: - # Validate state - if result in _VALID_STATES: - self._attr_alarm_state = result - _LOGGER.debug("Valid state - %s", result) - return - - _LOGGER.error( - "Received invalid alarm panel state: %s for entity %s. Expected: %s", - result, - self.entity_id, - ", ".join(_VALID_STATES), - ) - self._attr_alarm_state = None - async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any): """Arm the panel to specified state with supplied script.""" @@ -351,39 +323,17 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP ) -> None: """Initialize the panel.""" TemplateEntity.__init__(self, hass, config, unique_id) - AbstractTemplateAlarmControlPanel.__init__(self, config) name = self._attr_name if TYPE_CHECKING: assert name is not None - for action_id, action_config, supported_feature in self._iterate_scripts( - config - ): - self.add_script(action_id, action_config, name, DOMAIN) - self._attr_supported_features |= supported_feature + AbstractTemplateAlarmControlPanel.__init__(self, name) async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() await self._async_handle_restored_state() - @callback - def _update_state(self, result): - if isinstance(result, TemplateError): - self._attr_alarm_state = None - return - - self._handle_state(result) - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - if self._template: - self.add_template_attribute( - "_attr_alarm_state", self._template, None, self._update_state - ) - super()._async_setup_templates() - class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel): """Alarm Control Panel entity based on trigger data.""" @@ -398,19 +348,8 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl ) -> None: """Initialize the entity.""" TriggerEntity.__init__(self, hass, coordinator, config) - AbstractTemplateAlarmControlPanel.__init__(self, config) - self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) - - if isinstance(config.get(CONF_STATE), template.Template): - self._to_render_simple.append(CONF_STATE) - self._parse_result.add(CONF_STATE) - - for action_id, action_config, supported_feature in self._iterate_scripts( - config - ): - self.add_script(action_id, action_config, name, DOMAIN) - self._attr_supported_features |= supported_feature + AbstractTemplateAlarmControlPanel.__init__(self, name) async def async_added_to_hass(self) -> None: """Restore last state.""" @@ -426,7 +365,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl self.async_write_ha_state() return - if (rendered := self._rendered.get(CONF_STATE)) is not None: - self._handle_state(rendered) + if self.handle_rendered_result(CONF_STATE): self.async_set_context(self.coordinator.data["context"]) self.async_write_ha_state() diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 605e39410f6..8b50de146b4 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -1,7 +1,8 @@ """Template entity base class.""" from abc import abstractmethod -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from dataclasses import dataclass import logging from typing import Any @@ -18,6 +19,17 @@ from .const import CONF_DEFAULT_ENTITY_ID _LOGGER = logging.getLogger(__name__) +@dataclass +class EntityTemplate: + """Information class for properly handling template results.""" + + attribute: str + template: Template + validator: Callable[[Any], Any] | None + on_update: Callable[[Any], None] | None + none_on_template_error: bool + + class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" @@ -34,6 +46,8 @@ class AbstractTemplateEntity(Entity): """Initialize the entity.""" self.hass = hass + self._config = config + self._templates: dict[str, EntityTemplate] = {} self._action_scripts: dict[str, Script] = {} if self._optimistic_entity: @@ -72,6 +86,35 @@ class AbstractTemplateEntity(Entity): def _render_script_variables(self) -> dict: """Render configured variables.""" + @abstractmethod + def setup_state_template( + self, + option: str, + attribute: str, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + ) -> None: + """Set up a template that manages the main state of the entity.""" + + def add_template( + self, + option: str, + attribute: str, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool = False, + add_if_static: bool = True, + ) -> Template | None: + """Add a template.""" + if (template := self._config.get(option)) and isinstance(template, Template): + if add_if_static or (not template.is_static): + self._templates[option] = EntityTemplate( + attribute, template, validator, on_update, none_on_template_error + ) + return template + + return None + def add_script( self, script_id: str, diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 834beaeb3fd..44216299b47 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -181,9 +181,6 @@ class TemplateEntity(AbstractTemplateEntity): self._run_variables: ScriptVariables | dict self._attribute_templates = config.get(CONF_ATTRIBUTES) self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) self._run_variables = config.get(CONF_VARIABLES, {}) self._blueprint_inputs = config.get("raw_blueprint_inputs") @@ -208,27 +205,28 @@ class TemplateEntity(AbstractTemplateEntity): ) variables = {"this": DummyState(), **variables} - # Try to render the name as it can influence the entity ID + self.add_template( + CONF_AVAILABILITY, "_attr_available", on_update=self._update_available + ) + + # Render name, icon, and picture early. name is rendered early because it influences + # the entity_id. icon and picture are rendered early to ensure they are populated even + # if the entity renders unavailable. self._attr_name = None - if self._friendly_name_template: - with contextlib.suppress(TemplateError): - self._attr_name = self._friendly_name_template.async_render( - variables=variables, parse_result=False - ) - - # Templates will not render while the entity is unavailable, try to render the - # icon and picture templates. - if self._entity_picture_template: - with contextlib.suppress(TemplateError): - self._attr_entity_picture = self._entity_picture_template.async_render( - variables=variables, parse_result=False - ) - - if self._icon_template: - with contextlib.suppress(TemplateError): - self._attr_icon = self._icon_template.async_render( - variables=variables, parse_result=False - ) + for option, attribute, validator in ( + (CONF_ICON, "_attr_icon", vol.Or(cv.whitespace, cv.icon)), + (CONF_PICTURE, "_attr_entity_picture", cv.string), + (CONF_NAME, "_attr_name", cv.string), + ): + if template := self.add_template( + option, attribute, validator, add_if_static=option != CONF_NAME + ): + with contextlib.suppress(TemplateError): + setattr( + self, + attribute, + template.async_render(variables=variables, parse_result=False), + ) @callback def _update_available(self, result: str | TemplateError) -> None: @@ -278,6 +276,33 @@ class TemplateEntity(AbstractTemplateEntity): }, ) + def setup_state_template( + self, + option: str, + attribute: str, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + ) -> None: + """Set up a template that manages the main state of the entity.""" + + @callback + def _update_state(result: Any) -> None: + if isinstance(result, TemplateError): + setattr(self, attribute, None) + if self._availability_template: + return + + self._attr_available = False + return + + state = validator(result) if validator else result + if on_update: + on_update(state) + else: + setattr(self, attribute, state) + + self.add_template(option, attribute, on_update=_update_state) + def add_template_attribute( self, attribute: str, @@ -417,30 +442,20 @@ class TemplateEntity(AbstractTemplateEntity): @callback def _async_setup_templates(self) -> None: """Set up templates.""" - if self._availability_template is not None: - self.add_template_attribute( - "_attr_available", - self._availability_template, - None, - self._update_available, - ) + + # Handle attributes as a dictionary. if self._attribute_templates is not None: for key, value in self._attribute_templates.items(): self._add_attribute_template(key, value) - if self._icon_template is not None: + + # Iterate all dynamic templates and add listeners. + for entity_template in self._templates.values(): self.add_template_attribute( - "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) - ) - if self._entity_picture_template is not None: - self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template, cv.string - ) - if ( - self._friendly_name_template is not None - and not self._friendly_name_template.is_static - ): - self.add_template_attribute( - "_attr_name", self._friendly_name_template, cv.string + entity_template.attribute, + entity_template.template, + entity_template.validator, + entity_template.on_update, + entity_template.none_on_template_error, ) @callback diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index e75d62352b5..65a6f592470 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any from homeassistant.const import CONF_STATE, CONF_VARIABLES @@ -50,6 +51,19 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module else: self._unique_id = unique_id + def setup_state_template( + self, + option: str, + attribute: str, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + ) -> None: + """Set up a template that manages the main state of the entity.""" + if self._config.get(option): + self._to_render_simple.append(CONF_STATE) + self._parse_result.add(CONF_STATE) + self.add_template(option, attribute, validator, on_update) + @property def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" @@ -89,6 +103,23 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module self._render_attributes(rendered, variables) self._rendered = rendered + def handle_rendered_result(self, key: str) -> bool: + """Get a rendered result and return the value.""" + if (rendered := self._rendered.get(key)) is not None: + if (entity_template := self._templates.get(key)) is not None: + value = rendered + if entity_template.validator: + value = entity_template.validator(rendered) + + if entity_template.on_update: + entity_template.on_update(value) + else: + setattr(self, entity_template.attribute, value) + + return True + + return False + @callback def _process_data(self) -> None: """Process new data.""" diff --git a/homeassistant/components/template/validators.py b/homeassistant/components/template/validators.py new file mode 100644 index 00000000000..3e2709d8401 --- /dev/null +++ b/homeassistant/components/template/validators.py @@ -0,0 +1,305 @@ +"""Template config validation methods.""" + +from collections.abc import Callable +from enum import StrEnum +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +# Valid on/off values for booleans. These tuples are pulled +# from cv.boolean and are used to produce logger errors for the user. +RESULT_ON = ("1", "true", "yes", "on", "enable") +RESULT_OFF = ("0", "false", "no", "off", "disable") + + +def _log_validation_result_error( + entity: Entity, + attribute: str, + value: Any, + expected: tuple[str, ...] | str, +) -> None: + """Log a template result error.""" + + # in some cases, like `preview` entities, the entity_id does not exist. + if entity.entity_id is None: + message = f"Received invalid {attribute}: {value} for entity {entity.name}, %s" + else: + message = ( + f"Received invalid {entity.entity_id.split('.')[0]} {attribute}" + f": {value} for entity {entity.entity_id}, %s" + ) + + _LOGGER.error( + message, + expected + if isinstance(expected, str) + else "expected one of " + ", ".join(expected), + ) + + +def _check_result_for_none(result: Any, **kwargs: Any) -> bool: + """Checks the result for none, unknown, unavailable.""" + if result is None: + return True + + if kwargs.get("none_on_unknown_unavailable") and isinstance(result, str): + return result.lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + return False + + +def strenum[T: StrEnum]( + entity: Entity, + attribute: str, + state_enum: type[T], + state_on: T | None = None, + state_off: T | None = None, + **kwargs: Any, +) -> Callable[[Any], T | None]: + """Converts the template result to an StrEnum. + + All strings will attempt to convert to the StrEnum + If state_on or state_off are provided, boolean values will return the + enum that represents each boolean value. + Anything that cannot convert will result in None. + + none_on_unknown_unavailable + """ + + def convert(result: Any) -> T | None: + if _check_result_for_none(result, **kwargs): + return None + + if isinstance(result, str): + value = result.lower().strip() + try: + return state_enum(value) + except ValueError: + pass + + if state_on or state_off: + try: + bool_value = cv.boolean(result) + if state_on and bool_value: + return state_on + + if state_off and not bool_value: + return state_off + + except vol.Invalid: + pass + + expected = tuple(s.value for s in state_enum) + if state_on: + expected += RESULT_ON + if state_off: + expected += RESULT_OFF + + _log_validation_result_error( + entity, + attribute, + result, + expected, + ) + return None + + return convert + + +def boolean( + entity: Entity, + attribute: str, + as_true: tuple[str, ...] | None = None, + as_false: tuple[str, ...] | None = None, + **kwargs: Any, +) -> Callable[[Any], bool | None]: + """Convert the result to a boolean. + + True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy + False/0/'0'/'false'/'no'/'off'/'disable' are considered falsy + Additional values provided by as_true are considered truthy + Additional values provided by as_false are considered truthy + All other values are None + """ + + def convert(result: Any) -> bool | None: + if _check_result_for_none(result, **kwargs): + return None + + if isinstance(result, bool): + return result + + if isinstance(result, str) and (as_true or as_false): + value = result.lower().strip() + if as_true and value in as_true: + return True + if as_false and value in as_false: + return False + + try: + return cv.boolean(result) + except vol.Invalid: + pass + + items: tuple[str, ...] = RESULT_ON + RESULT_OFF + if as_true: + items += as_true + if as_false: + items += as_false + + _log_validation_result_error(entity, attribute, result, items) + return None + + return convert + + +def number( + entity: Entity, + attribute: str, + minimum: float | None = None, + maximum: float | None = None, + return_type: type[float] | type[int] = float, + **kwargs: Any, +) -> Callable[[Any], float | int | None]: + """Convert the result to a number (float or int). + + Any value in the range is converted to a float or int + All other values are None + """ + message = "expected a number" + if minimum is not None and maximum is not None: + message = f"{message} between {minimum:0.1f} and {maximum:0.1f}" + elif minimum is not None and maximum is None: + message = f"{message} greater than or equal to {minimum:0.1f}" + elif minimum is None and maximum is not None: + message = f"{message} less than or equal to {maximum:0.1f}" + + def convert(result: Any) -> float | int | None: + if _check_result_for_none(result, **kwargs): + return None + + if (result_type := type(result)) is bool: + _log_validation_result_error(entity, attribute, result, message) + return None + + if isinstance(result, (float, int)): + value = result + if return_type is int and result_type is float: + value = int(value) + elif return_type is float and result_type is int: + value = float(value) + else: + try: + value = vol.Coerce(float)(result) + if return_type is int: + value = int(value) + except vol.Invalid: + _log_validation_result_error(entity, attribute, result, message) + return None + + if minimum is None and maximum is None: + return value + + if ( + ( + minimum is not None + and maximum is not None + and minimum <= value <= maximum + ) + or (minimum is not None and maximum is None and value >= minimum) + or (minimum is None and maximum is not None and value <= maximum) + ): + return value + + _log_validation_result_error(entity, attribute, result, message) + return None + + return convert + + +def list_of_strings( + entity: Entity, + attribute: str, + none_on_empty: bool = False, + **kwargs: Any, +) -> Callable[[Any], list[str] | None]: + """Convert the result to a list of strings. + + This ensures the result is a list of strings. + All other values that are not lists will result in None. + + none_on_empty will cause the converter to return None when the list is empty. + """ + + def convert(result: Any) -> list[str] | None: + if _check_result_for_none(result, **kwargs): + return None + + if not isinstance(result, list): + _log_validation_result_error( + entity, + attribute, + result, + "expected a list of strings", + ) + return None + + if none_on_empty and len(result) == 0: + return None + + # Ensure the result are strings. + return [str(v) for v in result] + + return convert + + +def item_in_list[T]( + entity: Entity, + attribute: str, + items: list[Any] | None, + items_attribute: str | None = None, + **kwargs: Any, +) -> Callable[[Any], Any | None]: + """Assert the result of the template is an item inside a list. + + Returns the result if the result is inside the list. + All results that are not inside the list will return None. + """ + + def convert(result: Any) -> Any | None: + if _check_result_for_none(result, **kwargs): + return None + + # items may be mutable based on another template field. Always + # perform this check when the items come from an configured + # attribute. + if items is None or (len(items) == 0): + if items_attribute: + _log_validation_result_error( + entity, + attribute, + result, + f"{items_attribute} is empty", + ) + + return None + + if result not in items: + _log_validation_result_error( + entity, + attribute, + result, + tuple(str(v) for v in items), + ) + return None + + return result + + return convert diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 40b10789bd4..665e33db4c0 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,7 +1,7 @@ """template conftest.""" from dataclasses import dataclass -from enum import Enum +from enum import Enum, StrEnum import pytest @@ -25,6 +25,15 @@ class ConfigurationStyle(Enum): TRIGGER = "Trigger" +class Brewery(StrEnum): + """Test enum.""" + + MMMM = "mmmm" + BEER = "beer" + IS = "is" + GOOD = "good" + + def make_test_trigger(*entities: str) -> dict: """Make a test state trigger.""" return { diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 319d02a1056..503a63481e4 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -364,23 +364,19 @@ async def test_template_state_text(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("state_template", "expected", "trigger_expected"), + ("state_template", "expected"), [ - ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED, None), - ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME, None), - ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY, None), - ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT, None), - ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION, None), - ( - "{{ 'armed_custom_bypass' }}", - AlarmControlPanelState.ARMED_CUSTOM_BYPASS, - None, - ), - ("{{ 'pending' }}", AlarmControlPanelState.PENDING, None), - ("{{ 'arming' }}", AlarmControlPanelState.ARMING, None), - ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING, None), - ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED, None), - ("{{ x - 1 }}", STATE_UNKNOWN, STATE_UNAVAILABLE), + ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED), + ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME), + ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY), + ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT), + ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION), + ("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS), + ("{{ 'pending' }}", AlarmControlPanelState.PENDING), + ("{{ 'arming' }}", AlarmControlPanelState.ARMING), + ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING), + ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED), + ("{{ x - 1 }}", STATE_UNAVAILABLE), ], ) @pytest.mark.parametrize( @@ -388,9 +384,7 @@ async def test_template_state_text(hass: HomeAssistant) -> None: [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_panel") -async def test_state_template_states( - hass: HomeAssistant, expected: str, trigger_expected: str, style: ConfigurationStyle -) -> None: +async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: """Test the state template.""" # Force a trigger @@ -399,9 +393,6 @@ async def test_state_template_states( state = hass.states.get(TEST_ENTITY_ID) - if trigger_expected and style == ConfigurationStyle.TRIGGER: - expected = trigger_expected - assert state.state == expected diff --git a/tests/components/template/test_validators.py b/tests/components/template/test_validators.py new file mode 100644 index 00000000000..2cbb13b47b5 --- /dev/null +++ b/tests/components/template/test_validators.py @@ -0,0 +1,924 @@ +"""Test template validators.""" + +from typing import Any + +import pytest + +from homeassistant.components.template import validators as cv +from homeassistant.components.template.template_entity import TemplateEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.template import Template + +from .conftest import Brewery + + +def expect_boolean_on(true_value: Any = True) -> list[tuple[Any, Any]]: + """Tuple of commonn boolean on value expected pairs.""" + return [ + ("on", true_value), + ("On", true_value), + ("oN", true_value), + ("true", true_value), + ("yes", true_value), + ("enable", true_value), + ("1", true_value), + (True, true_value), + (1, true_value), + (8.23432, true_value), + (0.23432, true_value), + ] + + +def expect_boolean_off(false_value: Any = False) -> list[tuple[Any, Any]]: + """Tuple of commonn boolean off value expected pairs.""" + return [ + ("off", false_value), + ("false", false_value), + ("no", false_value), + ("disable", false_value), + ("0", false_value), + (False, false_value), + (0, false_value), + ] + + +def expect_none(*args: Any) -> list[tuple[Any, None]]: + """Tuple of results that should return None.""" + return [(v, None) for v in args] + + +def check_for_error(value: Any, expected: Any, caplog_text: str, error: str) -> None: + """Test the validator error.""" + if expected is None and value is not None: + assert error in caplog_text + else: + assert error not in caplog_text + + +def create_test_entity(hass: HomeAssistant, config: dict) -> TemplateEntity: + """Create a template result handler.""" + + class Test(TemplateEntity): + _entity_id_format = "test.{}" + + config = { + "name": Template("Test", hass), + **config, + } + + return Test(hass, config, "something_unique") + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("mmmm", Brewery.MMMM), + ("MmMM", Brewery.MMMM), + ("mmMM", Brewery.MMMM), + ("beer", Brewery.BEER), + ("is", Brewery.IS), + ("good", Brewery.GOOD), + *expect_none( + None, + "mm", + "beeal;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + True, + False, + 1, + 1.0, + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_enum( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enum validator.""" + entity = create_test_entity(hass, config) + assert cv.strenum(entity, "state", Brewery)(value) == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + "value", + [ + "unknown", + "unavailable", + "UknoWn", # codespell:ignore + "UnavailablE", # codespell:ignore UnavailablE + ], +) +async def test_none_on_unknown_and_unavailable( + hass: HomeAssistant, + value: Any, +) -> None: + """Test enum validator.""" + entity = create_test_entity(hass, {}) + assert ( + cv.strenum(entity, "state", Brewery, none_on_unknown_unavailable=True)(value) + is None + ) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable, 0, false, no, off, disable", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable, 0, false, no, off, disable", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("mmmm", Brewery.MMMM), + ("MmMM", Brewery.MMMM), + ("mmMM", Brewery.MMMM), + ("beer", Brewery.BEER), + ("is", Brewery.IS), + ("good", Brewery.GOOD), + *expect_boolean_on(Brewery.MMMM), + *expect_boolean_off(Brewery.BEER), + *expect_none( + None, + "mm", + "beeal;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_enum_with_on_off( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enum validator.""" + entity = create_test_entity(hass, config) + assert ( + cv.strenum(entity, "state", Brewery, Brewery.MMMM, Brewery.BEER)(value) + == expected + ) + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good, 1, true, yes, on, enable", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("mmmm", Brewery.MMMM), + ("MmMM", Brewery.MMMM), + ("mmMM", Brewery.MMMM), + ("beer", Brewery.BEER), + ("is", Brewery.IS), + ("good", Brewery.GOOD), + *expect_boolean_on(Brewery.MMMM), + *expect_boolean_off(None), + *expect_none( + None, + "mm", + "beeal;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_enum_with_on( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enum with state_on validator.""" + entity = create_test_entity(hass, config) + assert ( + cv.strenum(entity, "state", Brewery, state_on=Brewery.MMMM)(value) == expected + ) + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of mmmm, beer, is, good, 0, false, no, off, disable", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of mmmm, beer, is, good, 0, false, no, off, disable", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("mmmm", Brewery.MMMM), + ("MmMM", Brewery.MMMM), + ("mmMM", Brewery.MMMM), + ("beer", Brewery.BEER), + ("is", Brewery.IS), + ("good", Brewery.GOOD), + *expect_boolean_on(None), + *expect_boolean_off(Brewery.BEER), + *expect_none( + None, + "mm", + "beeal;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_enum_with_off( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enum with state_off validator.""" + entity = create_test_entity(hass, config) + assert ( + cv.strenum(entity, "state", Brewery, state_off=Brewery.BEER)(value) == expected + ) + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + *expect_boolean_on(), + *expect_boolean_off(), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_boolean( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test boolean validator.""" + entity = create_test_entity(hass, config) + assert cv.boolean(entity, "state")(value) == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("something_unique", True), + *expect_boolean_on(), + *expect_boolean_off(), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_boolean_as_true( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test boolean validator.""" + entity = create_test_entity(hass, config) + assert cv.boolean(entity, "state", as_true=("something_unique",))(value) == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable, something_unique", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of 1, true, yes, on, enable, 0, false, no, off, disable, something_unique", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("something_unique", False), + *expect_boolean_on(), + *expect_boolean_off(), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "7", + "-1", + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_boolean_as_false( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test boolean validator.""" + entity = create_test_entity(hass, config) + assert ( + cv.boolean(entity, "state", as_false=("something_unique",))(value) == expected + ) + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a number", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a number", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("7.5", 7.5), + ("0.0", 0.0), + ("-324.4564", -324.4564), + ("5e-4", 0.0005), + ("5e4", 50000.0), + (7.5, 7.5), + (0.0, 0.0), + (-324.4564, -324.4564), + (1, 1.0), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_number_as_float( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test number validator.""" + entity = create_test_entity(hass, config) + value = cv.number(entity, "state")(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a number", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a number", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("7.5", 7), + ("0.0", 0), + ("-324.4564", -324), + ("5e-4", 0), + ("5e4", 50000), + (7.5, 7), + (0.0, 0), + (-324.4564, -324), + (1, 1), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_number_as_int( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test number with return_type int validator.""" + entity = create_test_entity(hass, config) + value = cv.number(entity, "state", return_type=int)(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a number greater than or equal to 0.0", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a number greater than or equal to 0.0", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("7.5", 7.5), + ("0.0", 0), + ("5e-4", 0.0005), + ("5e4", 50000.0), + (7.5, 7.5), + (0.0, 0), + (1, 1.0), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "-324.4564", + -324.4564, + "-0.00001", + -0.00001, + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + [], + ["stuff"], + ), + ], +) +async def test_number_with_minimum( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test number with minimum validator.""" + entity = create_test_entity(hass, config) + value = cv.number(entity, "state", minimum=0.0)(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a number less than or equal to 0.0", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a number less than or equal to 0.0", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("-7.5", -7.5), + ("0.0", 0), + ("-5e-4", -0.0005), + ("-5e4", -50000), + (-7.5, -7.5), + (0.0, 0.0), + (-1, -1.0), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "324.4564", + "0.00001", + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + ), + ], +) +async def test_number_with_maximum( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test number with maximum validator.""" + entity = create_test_entity(hass, config) + value = cv.number(entity, "state", maximum=0.0)(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a number between 0.0 and 100.0", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a number between 0.0 and 100.0", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("7.5", 7.5), + ("0.0", 0), + ("0.0012", 0.0012), + ("99.0", 99.0), + ("100", 100), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + "324.4564", + "-5e4101", + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + ), + ], +) +async def test_number_in_range( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test number within a range validator.""" + entity = create_test_entity(hass, config) + value = cv.number(entity, "state", minimum=0.0, maximum=100.0)(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected a list of strings", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected a list of strings", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (["beer", "is", "good"], ["beer", "is", "good"]), + (["beer", None, True], ["beer", "None", "True"]), + ([], []), + (["99.0", 99.0, 99], ["99.0", "99.0", "99"]), + *expect_none( + None, + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + 83242.2342, + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + ), + ], +) +async def test_list_of_strings( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test result as a list of strings.""" + entity = create_test_entity(hass, config) + value = cv.list_of_strings(entity, "state")(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +async def test_list_of_strings_none_on_empty( + hass: HomeAssistant, +) -> None: + """Test result as a list of strings with an empty list returning None.""" + entity = create_test_entity(hass, {"default_entity_id": "test.test"}) + value = cv.list_of_strings(entity, "state", none_on_empty=True)([]) + assert value is None + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected beer, is, GOOD", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected beer, is, GOOD", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("beer", "beer"), + ("is", "is"), + ("GOOD", "GOOD"), + *expect_none( + None, + "BEER", + "IS", + "good", + "al;dfj", + "unknown", + "unavailable", + "tru", # codespell:ignore tru + 83242.2342, + True, + False, + {}, + {"junk": "stuff"}, + {"junk"}, + ), + ], +) +async def test_item_in_list( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test result is in a list.""" + entity = create_test_entity(hass, config) + value = cv.item_in_list(entity, "state", ["beer", "is", "GOOD"])(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value)) + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, expected one of beer, is, GOOD", + ), + ( + {}, + "Received invalid state: {} for entity Test, expected one of beer, is, GOOD", + ), + ], +) +async def test_item_in_list_changes( + hass: HomeAssistant, + config: dict, + error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test test an item is in a list after the list changes.""" + entity = create_test_entity(hass, config) + items = ["beer", "is", "GOOD"] + value = cv.item_in_list(entity, "state", items)("mmmm") + assert value is None + assert error.format("mmmm") in caplog.text + + items.append("mmmm") + + value = cv.item_in_list(entity, "state", items)("mmmm") + assert value == "mmmm" + assert error.format(value) + ", mmmm" not in caplog.text + + +@pytest.mark.parametrize( + ("config", "error"), + [ + ( + {"default_entity_id": "test.test"}, + "Received invalid test state: {} for entity test.test, bar is empty", + ), + ( + {}, + "Received invalid state: {} for entity Test, bar is empty", + ), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [("anything", None)], +) +@pytest.mark.parametrize( + ("the_list"), + [None, []], +) +async def test_empty_items_in_list( + hass: HomeAssistant, + config: dict, + error: str, + value: Any, + expected: bool | None, + the_list: list | None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test result is in a list.""" + entity = create_test_entity(hass, config) + value = cv.item_in_list(entity, "state", the_list, "bar")(value) + assert value == expected + check_for_error(value, expected, caplog.text, error.format(value))