From 8635da2e624734d1cee64ce613dff5e845a45f6c Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:22:44 -0400 Subject: [PATCH] Add `conditions:` to trigger based template entity options (#174055) --- homeassistant/components/template/config.py | 89 +- .../components/template/coordinator.py | 18 +- homeassistant/components/template/helpers.py | 42 +- homeassistant/components/template/schemas.py | 2 + .../components/template/trigger_entity.py | 32 +- .../components/template/validators.py | 20 + tests/components/template/test_config.py | 758 ++++++++++++------ .../template/test_trigger_entity.py | 152 ++++ 8 files changed, 825 insertions(+), 288 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 3ae34ac97fb1..20622091071b 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -44,6 +44,7 @@ from homeassistant.const import ( CONF_TRIGGERS, CONF_UNIQUE_ID, CONF_VARIABLES, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, issue_registry as ir @@ -82,31 +83,82 @@ _LOGGER = logging.getLogger(__name__) PACKAGE_MERGE_HINT = "list" +_DEFAULT_NAME = "Template Entity" +_DEFAULT_NAMES = { + Platform.ALARM_CONTROL_PANEL: alarm_control_panel_platform.DEFAULT_NAME, + Platform.BINARY_SENSOR: binary_sensor_platform.DEFAULT_NAME, + Platform.BUTTON: button_platform.DEFAULT_NAME, + Platform.COVER: cover_platform.DEFAULT_NAME, + Platform.DEVICE_TRACKER: device_tracker_platform.DEFAULT_NAME, + Platform.EVENT: event_platform.DEFAULT_NAME, + Platform.FAN: fan_platform.DEFAULT_NAME, + Platform.IMAGE: image_platform.DEFAULT_NAME, + Platform.LIGHT: light_platform.DEFAULT_NAME, + Platform.LOCK: lock_platform.DEFAULT_NAME, + Platform.NUMBER: number_platform.DEFAULT_NAME, + Platform.SELECT: select_platform.DEFAULT_NAME, + Platform.SENSOR: sensor_platform.DEFAULT_NAME, + Platform.SWITCH: switch_platform.DEFAULT_NAME, + Platform.UPDATE: update_platform.DEFAULT_NAME, + Platform.VACUUM: vacuum_platform.DEFAULT_NAME, + Platform.WEATHER: weather_platform.DEFAULT_NAME, +} + + +def _identify_entity_config_requires_trigger( + platform: Platform, option: str, entity_config: ConfigType +) -> None: + """Raise vol.Invalid if an entity sets an option that requires a trigger.""" + if option not in entity_config: + return + + _default_name = _DEFAULT_NAMES.get(platform, _DEFAULT_NAME) + identifier = f"{CONF_NAME}: {_default_name}" + if ( + (name := entity_config.get(CONF_NAME)) + and isinstance(name, Template) + and name.template != _default_name + ): + identifier = f"{CONF_NAME}: {name.template}" + elif default_entity_id := entity_config.get(CONF_DEFAULT_ENTITY_ID): + identifier = f"{CONF_DEFAULT_ENTITY_ID}: {default_entity_id}" + elif unique_id := entity_config.get(CONF_UNIQUE_ID): + identifier = f"{CONF_UNIQUE_ID}: {unique_id}" + + raise vol.Invalid( + f"The {option} option for template {platform.replace('_', ' ')}: {identifier} " + f"requires a trigger, remove the {option} option or rewrite " + "configuration to use a trigger" + ) + + def validate_binary_sensor_auto_off_has_trigger(obj: dict) -> dict: """Validate that binary sensors with auto_off have triggers.""" if CONF_TRIGGERS not in obj and BINARY_SENSOR_DOMAIN in obj: binary_sensors: list[ConfigType] = obj[BINARY_SENSOR_DOMAIN] for binary_sensor in binary_sensors: - if binary_sensor_platform.CONF_AUTO_OFF not in binary_sensor: + _identify_entity_config_requires_trigger( + Platform.BINARY_SENSOR, + binary_sensor_platform.CONF_AUTO_OFF, + binary_sensor, + ) + + return obj + + +def validate_entity_config_with_conditions_has_trigger(obj: dict) -> dict: + """Validate entity condition requires trigger.""" + if CONF_TRIGGERS not in obj: + for platform in PLATFORMS: + if platform not in obj: continue - identifier = f"{CONF_NAME}: {binary_sensor_platform.DEFAULT_NAME}" - if ( - (name := binary_sensor.get(CONF_NAME)) - and isinstance(name, Template) - and name.template != binary_sensor_platform.DEFAULT_NAME - ): - identifier = f"{CONF_NAME}: {name.template}" - elif default_entity_id := binary_sensor.get(CONF_DEFAULT_ENTITY_ID): - identifier = f"{CONF_DEFAULT_ENTITY_ID}: {default_entity_id}" - elif unique_id := binary_sensor.get(CONF_UNIQUE_ID): - identifier = f"{CONF_UNIQUE_ID}: {unique_id}" - - raise vol.Invalid( - f"The auto_off option for template binary sensor: {identifier} " - "requires a trigger, remove the auto_off option or rewrite " - "configuration to use a trigger" - ) + for entity_config in obj[platform]: + _identify_entity_config_requires_trigger( + platform, + CONF_CONDITIONS, + entity_config, + ) return obj @@ -252,6 +304,7 @@ CONFIG_SECTION_SCHEMA = vol.All( BUTTON_DOMAIN, ), validate_binary_sensor_auto_off_has_trigger, + validate_entity_config_with_conditions_has_trigger, ) TEMPLATE_BLUEPRINT_SCHEMA = vol.All( diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 6a78aed9ed13..adf790452e23 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -17,11 +17,11 @@ from homeassistant.core import Context, CoreState, Event, HomeAssistant, callbac from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script from homeassistant.helpers.script_variables import ScriptVariables -from homeassistant.helpers.trace import trace_get from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, PLATFORMS +from .validators import check_conditions _LOGGER = logging.getLogger(__name__) @@ -135,7 +135,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): if self._run_variables: run_variables = self._run_variables.async_render(self.hass, run_variables) - if not self._check_condition(run_variables): + if not check_conditions(self._cond_func, run_variables): return # Create a context referring to the trigger context. trigger_context_id = None if context is None else context.id @@ -153,22 +153,10 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): if self._run_variables: run_variables = self._run_variables.async_render(self.hass, run_variables) - if not self._check_condition(run_variables): + if not check_conditions(self._cond_func, run_variables): return self._execute_update(run_variables, context) - def _check_condition(self, run_variables: TemplateVarsType) -> bool: - if not self._cond_func: - return True - condition_result = self._cond_func.async_check(variables=run_variables) - if condition_result is False: - _LOGGER.debug( - "Conditions not met, aborting template" - " trigger update. Condition summary: %s", - trace_get(clear=False), - ) - return condition_result - @callback def _execute_update( self, run_variables: TemplateVarsType, context: Context | None = None diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 491fbbff6e76..9b1cc998b42b 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -10,6 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_CONDITIONS, CONF_NAME, CONF_STATE, CONF_UNIQUE_ID, @@ -19,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import template +from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -129,14 +131,16 @@ def _get_config_breadcrumbs(config: ConfigType) -> str: return breadcrumb -async def validate_template_scripts( +async def validate_actions_and_conditions_config( hass: HomeAssistant, config: ConfigType, script_options: tuple[str, ...] | None = None, ) -> bool: - """Validate template scripts.""" - if not script_options: - return True + """Validate template entity actions and conditions. + + Returns True when all conditions and actions validate without error. + When any condition or action fails, return False. + """ def _humanize(err: Exception, data: Any) -> str: """Humanize vol.Invalid, stringify other exceptions.""" @@ -145,6 +149,24 @@ async def validate_template_scripts( return str(err) breadcrumb: str | None = None + if (condition_config := config.pop(CONF_CONDITIONS, None)) is not None: + try: + config[CONF_CONDITIONS] = await async_validate_conditions_config( + hass, condition_config + ) + except (vol.Invalid, HomeAssistantError) as err: + if not breadcrumb: + breadcrumb = _get_config_breadcrumbs(config) + _LOGGER.error( + "The condition for %s failed to setup: %s", + breadcrumb, + _humanize(err, condition_config), + ) + return False + + if not script_options: + return True + for script_option in script_options: if (script_config := config.pop(script_option, None)) is not None: try: @@ -205,7 +227,9 @@ async def async_setup_template_platform( if trigger_entities := [ trigger_entity_cls(hass, discovery_info["coordinator"], entity_config) for entity_config in discovery_info["entities"] - if await validate_template_scripts(hass, entity_config, script_options) + if await validate_actions_and_conditions_config( + hass, entity_config, script_options + ) ]: async_add_entities(trigger_entities) else: @@ -218,7 +242,9 @@ async def async_setup_template_platform( if state_entities := [ entity_config for entity_config in discovery_info["entities"] - if await validate_template_scripts(hass, entity_config, script_options) + if await validate_actions_and_conditions_config( + hass, entity_config, script_options + ) ]: async_create_template_tracking_entities( state_entity_cls, @@ -249,7 +275,9 @@ async def async_setup_template_entry( options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) validated_config = config_schema(options) - if await validate_template_scripts(hass, validated_config, script_options): + if await validate_actions_and_conditions_config( + hass, validated_config, script_options + ): async_add_entities( [state_entity_cls(hass, validated_config, config_entry.entry_id)] ) diff --git a/homeassistant/components/template/schemas.py b/homeassistant/components/template/schemas.py index 031fae5378cb..5d48bea9192e 100644 --- a/homeassistant/components/template/schemas.py +++ b/homeassistant/components/template/schemas.py @@ -8,6 +8,7 @@ from typing import TypeVar import voluptuous as vol from homeassistant.const import ( + CONF_CONDITIONS, CONF_DEVICE_ID, CONF_ICON, CONF_NAME, @@ -94,6 +95,7 @@ def make_template_entity_common_schema( vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_ATTRIBUTES): vol.Schema( vol.All( {cv.string: cv.template}, diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 4a051af96877..4fb6dac43a7b 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -1,11 +1,13 @@ """Trigger entity.""" from collections.abc import Callable +import logging from typing import Any, override -from homeassistant.const import CONF_VARIABLES +from homeassistant.const import CONF_CONDITIONS, CONF_VARIABLES from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import condition from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.template import ( _SENTINEL, @@ -17,8 +19,11 @@ from homeassistant.helpers.trigger_template_entity import ( ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TriggerUpdateCoordinator +from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .validators import check_conditions + +_LOGGER = logging.getLogger(__name__) class TriggerEntity( # pylint: disable=home-assistant-enforce-class-module @@ -44,6 +49,7 @@ class TriggerEntity( # pylint: disable=home-assistant-enforce-class-module self._entity_variables: ScriptVariables | None = config.get(CONF_VARIABLES) self._rendered_entity_variables: dict | None = None self._state_render_error = False + self._cond_func: condition.ConditionsChecker | None = None self._skip_rendered_result: list[str] = [] if self.skip_rendered_result is not None: @@ -52,7 +58,19 @@ class TriggerEntity( # pylint: disable=home-assistant-enforce-class-module @override async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" + + # Setup condition before calling async_added_to_hass to ensure + # the condition is available before a trigger can occur + if condition_config := self._config.get(CONF_CONDITIONS): + self._cond_func = await condition.async_conditions_from_config( + self.hass, + condition_config, + _LOGGER, + f"template {self.domain} entity", + ) + await super().async_added_to_hass() + if self.coordinator.data is not None: # The trigger already produced data; rendering it must win over # restored state, so skip restore entirely to avoid clobbering the @@ -61,6 +79,13 @@ class TriggerEntity( # pylint: disable=home-assistant-enforce-class-module else: await self.async_restore_last_state() + @override + async def async_will_remove_from_hass(self) -> None: + """Clean up conditions when removing from Home Assistant.""" + await super().async_will_remove_from_hass() + if self._cond_func: + self._cond_func.async_unload() + @override def _set_unique_id(self, unique_id: str | None) -> None: """Set unique id.""" @@ -293,6 +318,9 @@ class TriggerEntity( # pylint: disable=home-assistant-enforce-class-module self._rendered_entity_variables = coordinator_variables variables = self._template_variables(self._rendered_entity_variables) + if not check_conditions(self._cond_func, variables): + return + self.async_set_context(self.coordinator.data["context"]) if self._render_availability_template(variables): self._render_templates(variables) diff --git a/homeassistant/components/template/validators.py b/homeassistant/components/template/validators.py index 6169e3e6aa9e..8137bc5a3cc4 100644 --- a/homeassistant/components/template/validators.py +++ b/homeassistant/components/template/validators.py @@ -9,7 +9,10 @@ import voluptuous as vol from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ConditionsChecker from homeassistant.helpers.entity import Entity +from homeassistant.helpers.trace import trace_get +from homeassistant.helpers.typing import TemplateVarsType _LOGGER = logging.getLogger(__name__) @@ -361,3 +364,20 @@ def string( return None return convert + + +def check_conditions( + condition_func: ConditionsChecker | None, run_variables: TemplateVarsType +) -> bool: + """Check if conditions have been met using run variables.""" + if not condition_func: + return True + + condition_result = condition_func.async_check(variables=run_variables) + if condition_result is False: + _LOGGER.debug( + "Conditions not met, aborting template trigger update. Condition summary: %s", + trace_get(clear=False), + ) + + return condition_result diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py index ba5e60824658..5d3db0b1c4ce 100644 --- a/tests/components/template/test_config.py +++ b/tests/components/template/test_config.py @@ -26,6 +26,7 @@ from .conftest import ( assert_action, async_trigger, make_mock_device_actions, + make_test_trigger, setup_entity, setup_mock_devices, ) @@ -298,6 +299,515 @@ async def test_invalid_binary_sensor_schema_with_auto_off( ) or expected_error in caplog.text +# Button is omitted because it does not support triggers +TRIGGER_PLATFORM_CONFIGURATIONS = [ + ( + Platform.ALARM_CONTROL_PANEL, + { + "state": "{{ 'disarmed' }}", + }, + ), + ( + Platform.BINARY_SENSOR, + { + "state": "{{ 'on' }}", + }, + ), + ( + Platform.COVER, + { + "state": "{{ 'open' }}", + "open_cover": [], + "close_cover": [], + }, + ), + ( + Platform.DEVICE_TRACKER, + { + "in_zones": "{{ ['zone.home'] }}", + }, + ), + ( + Platform.EVENT, + { + "event_type": "{{ 'single' }}", + "event_types": "{{ ['single'] }}", + }, + ), + ( + Platform.FAN, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.IMAGE, + { + "url": "{{ 'http://www.test.com' }}", + }, + ), + ( + Platform.LIGHT, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.LOCK, + { + "state": "{{ 'on' }}", + "lock": [], + "unlock": [], + }, + ), + ( + Platform.NUMBER, + { + "state": "{{ 4 }}", + "min": "0", + "max": "100", + "step": "0.1", + "unit_of_measurement": "cm", + "set_value": [], + }, + ), + ( + Platform.SELECT, + { + "state": "{{ 'on' }}", + "options": "{{ ['off', 'on', 'auto'] }}", + "select_option": [], + }, + ), + ( + Platform.SENSOR, + { + "state": "{{ 'yes' }}", + }, + ), + ( + Platform.SWITCH, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.UPDATE, + { + "installed_version": "{{ '1.0' }}", + "latest_version": "{{ '2.0' }}", + }, + ), + ( + Platform.VACUUM, + { + "state": "{{ 'docked' }}", + "start": [], + }, + ), + ( + Platform.WEATHER, + { + "condition": "{{ 'cloudy' }}", + "temperature": "{{ 20 }}", + "humidity": "{{ 50 }}", + }, + ), +] + + +@pytest.mark.parametrize(("platform", "config"), TRIGGER_PLATFORM_CONFIGURATIONS) +async def test_trigger_schema_with_conditions( + hass: HomeAssistant, + platform: Platform, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test conditions schema with valid and invalid configurations.""" + + await setup_entity( + hass, + TemplatePlatformSetup( + platform, "test_entity", make_test_trigger("sensor.test_state") + ), + ConfigurationStyle.TRIGGER, + 1, + { + "conditions": { + "condition": "template", + "value_template": "{{ 1 == 1 }}", + }, + **config, + }, + ) + + assert "ERROR" not in caplog.text + + +@pytest.mark.parametrize( + ("platform", "config"), + [ + ( + Platform.BUTTON, + { + "press": [], + }, + ), + ], +) +async def test_invalid_trigger_schema_with_conditions( + hass: HomeAssistant, + platform: Platform, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test conditions schema with valid and invalid configurations.""" + + await setup_entity( + hass, + TemplatePlatformSetup( + platform, "test_entity", make_test_trigger("sensor.test_state") + ), + ConfigurationStyle.TRIGGER, + 0, + { + "conditions": { + "condition": "template", + "value_template": "{{ 1 == 1 }}", + }, + **config, + }, + ) + + error = ( + "Invalid config for 'template': Unsupported option(s) found for domain button" + ) + assert error in caplog.text + + +@pytest.mark.parametrize( + ("platform", "config"), + [ + ( + Platform.ALARM_CONTROL_PANEL, + { + "state": "{{ 'disarmed' }}", + }, + ), + ( + Platform.BINARY_SENSOR, + { + "state": "{{ 'on' }}", + }, + ), + ( + Platform.COVER, + { + "state": "{{ 'open' }}", + "open_cover": [], + "close_cover": [], + }, + ), + ( + Platform.DEVICE_TRACKER, + { + "in_zones": "{{ ['zone.home'] }}", + }, + ), + ( + Platform.EVENT, + { + "event_type": "{{ 'single' }}", + "event_types": "{{ ['single'] }}", + }, + ), + ( + Platform.FAN, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.IMAGE, + { + "url": "{{ 'http://www.test.com' }}", + }, + ), + ( + Platform.LIGHT, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.LOCK, + { + "state": "{{ 'on' }}", + "lock": [], + "unlock": [], + }, + ), + ( + Platform.NUMBER, + { + "state": "{{ 4 }}", + "min": "0", + "max": "100", + "step": "0.1", + "unit_of_measurement": "cm", + "set_value": [], + }, + ), + ( + Platform.SELECT, + { + "state": "{{ 'on' }}", + "options": "{{ ['off', 'on', 'auto'] }}", + "select_option": [], + }, + ), + ( + Platform.SENSOR, + { + "state": "{{ 'yes' }}", + }, + ), + ( + Platform.SWITCH, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.UPDATE, + { + "installed_version": "{{ '1.0' }}", + "latest_version": "{{ '2.0' }}", + }, + ), + ( + Platform.VACUUM, + { + "state": "{{ 'docked' }}", + "start": [], + }, + ), + ( + Platform.WEATHER, + { + "condition": "{{ 'cloudy' }}", + "temperature": "{{ 20 }}", + "humidity": "{{ 50 }}", + }, + ), + ], +) +async def test_trigger_schema_with_invalid_condition( + hass: HomeAssistant, + platform: Platform, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test conditions schema with valid and invalid configurations.""" + + await setup_entity( + hass, + TemplatePlatformSetup( + platform, "test_entity", make_test_trigger("sensor.test_state") + ), + ConfigurationStyle.TRIGGER, + 1, + { + "conditions": { + "condition": "device", + "type": "is_off", + "device_id": "70c5f67ec2f82f9ba128fe6e99eb7dfa", + "entity_id": "c7e6f3753cb18937f2147bbbdccdd949", + "domain": "light", + }, + **config, + }, + ) + + assert len(hass.states.async_entity_ids(platform)) == 0 + assert ( + "The condition for test_entity failed to setup: Unknown device '70c5f67ec2f82f9ba128fe6e99eb7dfa'" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("platform", "config"), + [ + ( + Platform.ALARM_CONTROL_PANEL, + { + "state": "{{ 'disarmed' }}", + }, + ), + ( + Platform.BINARY_SENSOR, + { + "state": "{{ 'on' }}", + }, + ), + ( + Platform.BUTTON, + { + "press": [], + }, + ), + ( + Platform.COVER, + { + "state": "{{ 'open' }}", + "open_cover": [], + "close_cover": [], + }, + ), + ( + Platform.DEVICE_TRACKER, + { + "in_zones": "{{ ['zone.home'] }}", + }, + ), + ( + Platform.EVENT, + { + "event_type": "{{ 'single' }}", + "event_types": "{{ ['single'] }}", + }, + ), + ( + Platform.FAN, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.IMAGE, + { + "url": "{{ 'http://www.test.com' }}", + }, + ), + ( + Platform.LIGHT, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.LOCK, + { + "state": "{{ 'on' }}", + "lock": [], + "unlock": [], + }, + ), + ( + Platform.NUMBER, + { + "state": "{{ 4 }}", + "min": "0", + "max": "100", + "step": "0.1", + "unit_of_measurement": "cm", + "set_value": [], + }, + ), + ( + Platform.SELECT, + { + "state": "{{ 'on' }}", + "options": "{{ ['off', 'on', 'auto'] }}", + "select_option": [], + }, + ), + ( + Platform.SENSOR, + { + "state": "{{ 'yes' }}", + }, + ), + ( + Platform.SWITCH, + { + "state": "{{ 'on' }}", + "turn_on": [], + "turn_off": [], + }, + ), + ( + Platform.UPDATE, + { + "installed_version": "{{ '1.0' }}", + "latest_version": "{{ '2.0' }}", + }, + ), + ( + Platform.VACUUM, + { + "state": "{{ 'docked' }}", + "start": [], + }, + ), + ( + Platform.WEATHER, + { + "condition": "{{ 'cloudy' }}", + "temperature": "{{ 20 }}", + "humidity": "{{ 50 }}", + }, + ), + ], +) +async def test_invalid_schema_with_conditions( + hass: HomeAssistant, + platform: Platform, + config: ConfigType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test conditions schema with valid and invalid configurations.""" + + await setup_entity( + hass, + TemplatePlatformSetup( + platform, "test_entity", make_test_trigger("sensor.test_state") + ), + ConfigurationStyle.MODERN, + 0, + { + "conditions": { + "condition": "template", + "value_template": "{{ 1 == 1 }}", + }, + **config, + }, + ) + + error = ( + f"The conditions option for template {platform.replace('_', ' ')}: name: test_entity requires a trigger," + " remove the conditions option or rewrite configuration to use a trigger" + ) + assert error in caplog.text + + @pytest.mark.parametrize( ("config", "expected"), [ @@ -458,129 +968,7 @@ async def test_setup_component_bad_config_logs_error( assert expected_error in caplog.text -@pytest.mark.parametrize( - ("platform", "config"), - [ - ( - Platform.ALARM_CONTROL_PANEL, - { - "state": "{{ 'disarmed' }}", - }, - ), - ( - Platform.BINARY_SENSOR, - { - "state": "{{ 'on' }}", - }, - ), - ( - Platform.COVER, - { - "state": "{{ 'open' }}", - "open_cover": [], - "close_cover": [], - }, - ), - ( - Platform.DEVICE_TRACKER, - { - "in_zones": "{{ ['zone.home'] }}", - }, - ), - ( - Platform.EVENT, - { - "event_type": "{{ 'single' }}", - "event_types": "{{ ['single'] }}", - }, - ), - ( - Platform.FAN, - { - "state": "{{ 'on' }}", - "turn_on": [], - "turn_off": [], - }, - ), - ( - Platform.IMAGE, - { - "url": "{{ 'http://www.test.com' }}", - }, - ), - ( - Platform.LIGHT, - { - "state": "{{ 'on' }}", - "turn_on": [], - "turn_off": [], - }, - ), - ( - Platform.LOCK, - { - "state": "{{ 'on' }}", - "lock": [], - "unlock": [], - }, - ), - ( - Platform.NUMBER, - { - "state": "{{ 4 }}", - "min": "0", - "max": "100", - "step": "0.1", - "unit_of_measurement": "cm", - "set_value": [], - }, - ), - ( - Platform.SELECT, - { - "state": "{{ 'on' }}", - "options": "{{ ['off', 'on', 'auto'] }}", - "select_option": [], - }, - ), - ( - Platform.SENSOR, - { - "state": "{{ 'yes' }}", - }, - ), - ( - Platform.SWITCH, - { - "state": "{{ 'on' }}", - "turn_on": [], - "turn_off": [], - }, - ), - ( - Platform.UPDATE, - { - "installed_version": "{{ '1.0' }}", - "latest_version": "{{ '2.0' }}", - }, - ), - ( - Platform.VACUUM, - { - "state": "{{ 'docked' }}", - "start": [], - }, - ), - ( - Platform.WEATHER, - { - "condition": "{{ 'cloudy' }}", - "temperature": "{{ 20 }}", - "humidity": "{{ 50 }}", - }, - ), - ], -) +@pytest.mark.parametrize(("platform", "config"), TRIGGER_PLATFORM_CONFIGURATIONS) @pytest.mark.parametrize( ("extra_section_config", "breadcrumb"), [ @@ -630,129 +1018,7 @@ async def test_trigger_schema_with_invalid_actions( ) -@pytest.mark.parametrize( - ("platform", "config"), - [ - ( - Platform.ALARM_CONTROL_PANEL, - { - "state": "{{ 'disarmed' }}", - }, - ), - ( - Platform.BINARY_SENSOR, - { - "state": "{{ 'on' }}", - }, - ), - ( - Platform.COVER, - { - "state": "{{ 'open' }}", - "open_cover": [], - "close_cover": [], - }, - ), - ( - Platform.DEVICE_TRACKER, - { - "in_zones": "{{ ['zone.home'] }}", - }, - ), - ( - Platform.EVENT, - { - "event_type": "{{ 'single' }}", - "event_types": "{{ ['single'] }}", - }, - ), - ( - Platform.FAN, - { - "state": "{{ 'on' }}", - "turn_on": [], - "turn_off": [], - }, - ), - ( - Platform.IMAGE, - { - "url": "{{ 'http://www.test.com' }}", - }, - ), - ( - Platform.LIGHT, - { - "state": "{{ 'on' }}", - "turn_on": [], - "turn_off": [], - }, - ), - ( - Platform.LOCK, - { - "state": "{{ 'on' }}", - "lock": [], - "unlock": [], - }, - ), - ( - Platform.NUMBER, - { - "state": "{{ 4 }}", - "min": "0", - "max": "100", - "step": "0.1", - "unit_of_measurement": "cm", - "set_value": [], - }, - ), - ( - Platform.SELECT, - { - "state": "{{ 'on' }}", - "options": "{{ ['off', 'on', 'auto'] }}", - "select_option": [], - }, - ), - ( - Platform.SENSOR, - { - "state": "{{ 'yes' }}", - }, - ), - ( - Platform.SWITCH, - { - "state": "{{ 'on' }}", - "turn_on": [], - "turn_off": [], - }, - ), - ( - Platform.UPDATE, - { - "installed_version": "{{ '1.0' }}", - "latest_version": "{{ '2.0' }}", - }, - ), - ( - Platform.VACUUM, - { - "state": "{{ 'docked' }}", - "start": [], - }, - ), - ( - Platform.WEATHER, - { - "condition": "{{ 'cloudy' }}", - "temperature": "{{ 20 }}", - "humidity": "{{ 50 }}", - }, - ), - ], -) +@pytest.mark.parametrize(("platform", "config"), TRIGGER_PLATFORM_CONFIGURATIONS) async def test_trigger_schema_with_valid_actions( hass: HomeAssistant, platform: Platform, diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 30a9dc9a8d15..2cb22c091a5d 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -15,6 +15,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import condition, template @@ -33,6 +34,7 @@ _PICTURE_TEMPLATE = '/local/picture_o{{ "n" if value=="on" else "ff" }}' class TestEntity(trigger_entity.TriggerEntity): """Test entity class.""" + domain = "test" __test__ = False _entity_id_format = "test.{}" extra_template_keys = (CONF_STATE,) @@ -370,3 +372,153 @@ async def test_reload_stops_script_and_unsubscribes_triggers( # Old trigger should be unsubscribed listeners = hass.bus.async_listeners() assert listeners.get("test_event", 0) == 0 + + +async def test_entity_conditions_with_multiple_entities(hass: HomeAssistant) -> None: + """Test entity conditions with multiple entities.""" + with assert_setup_component(1, DOMAIN): + assert await async_setup_component( + hass, + DOMAIN, + { + "template": { + "triggers": { + "trigger": "state", + "entity_id": ["sensor.trigger"], + }, + "sensor": [ + { + "name": "a", + "state": "{{ states('sensor.trigger') }}", + "conditions": { + "condition": "numeric_state", + "entity_id": "sensor.trigger", + "above": 1, + }, + }, + { + "name": "b", + "state": "{{ trigger.to_state.state }}", + "conditions": { + "condition": "numeric_state", + "entity_id": "sensor.trigger", + "below": 1, + }, + }, + ], + }, + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("sensor.a") + assert state + assert state.state == STATE_UNKNOWN + + state = hass.states.get("sensor.b") + assert state + assert state.state == STATE_UNKNOWN + + await async_trigger(hass, "sensor.trigger", "2") + + state = hass.states.get("sensor.a") + assert state + assert state.state == "2" + + state = hass.states.get("sensor.b") + assert state + assert state.state == STATE_UNKNOWN + + await async_trigger(hass, "sensor.trigger", "0") + + state = hass.states.get("sensor.a") + assert state + assert state.state == "2" + + state = hass.states.get("sensor.b") + assert state + assert state.state == "0" + + +async def test_entity_conditions_variables(hass: HomeAssistant) -> None: + """Test entity conditions variables.""" + await async_trigger(hass, "sensor.start", "0") + with assert_setup_component(1, DOMAIN): + assert await async_setup_component( + hass, + DOMAIN, + { + "template": { + "triggers": { + "trigger": "state", + "entity_id": ["sensor.trigger", "sensor.start"], + }, + "variables": {"a": "{{ states('sensor.start') }}"}, + "sensor": [ + { + "name": "test", + "state": "{{ states('sensor.start') }}", + "variables": {"b": "{{ a + 1 }}"}, + "conditions": { + "condition": "template", + "value_template": "{{ b > 1 }}", + }, + "attributes": { + "a": "{{ a }}", + "b": "{{ b }}", + }, + }, + ], + }, + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state + assert state.state == STATE_UNKNOWN + assert "a" not in state.attributes + assert "b" not in state.attributes + + await async_trigger(hass, "sensor.trigger", "anything") + + state = hass.states.get("sensor.test") + assert state + assert state.state == STATE_UNKNOWN + assert "a" not in state.attributes + assert "b" not in state.attributes + + await async_trigger(hass, "sensor.start", "1") + + state = hass.states.get("sensor.test") + assert state + assert state.state == "1" + assert state.attributes["a"] == 1 + assert state.attributes["b"] == 2 + + await async_trigger(hass, "sensor.start", "0") + + state = hass.states.get("sensor.test") + assert state + assert state.state == "1" + assert state.attributes["a"] == 1 + assert state.attributes["b"] == 2 + + +async def test_entity_remove_unloads_condition( + hass: HomeAssistant, +) -> None: + """Test that removing the entity unloads the condition.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + + mock_cond = Mock(spec=condition.ConditionsChecker) + + entity = TestEntity(hass, coordinator, {}) + entity._cond_func = mock_cond + await entity.async_will_remove_from_hass() + + mock_cond.async_unload.assert_called_once()