diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 30181751f8c..7513e2b0087 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -12,6 +12,8 @@ import re import sys from typing import Any, cast +import voluptuous as vol + from homeassistant.components import zone as zone_cmp from homeassistant.components.device_automation import condition as device_condition from homeassistant.components.sensor import SensorDeviceClass @@ -29,6 +31,7 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_ENABLED, CONF_ENTITY_ID, + CONF_FOR, CONF_ID, CONF_MATCH, CONF_STATE, @@ -57,7 +60,7 @@ import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date -from .template import Template +from .template import Template, attach as template_attach, render_complex from .trace import ( TraceElement, trace_append_element, @@ -481,6 +484,7 @@ def state( req_state: Any, for_period: timedelta | None = None, attribute: str | None = None, + variables: TemplateVarsType = None, ) -> bool: """Test if state matches requirements. @@ -534,7 +538,14 @@ def state( condition_trace_set_result(is_state, state=value, wanted_state=state_value) return is_state - duration = dt_util.utcnow() - for_period + try: + for_period = cv.positive_time_period(render_complex(for_period, variables)) + except TemplateError as ex: + raise ConditionErrorMessage("state", f"template error: {ex}") from ex + except vol.Invalid as ex: + raise ConditionErrorMessage("state", f"schema error: {ex}") from ex + + duration = dt_util.utcnow() - cast(timedelta, for_period) duration_ok = duration > entity.last_changed condition_trace_set_result(duration_ok, state=value, duration=duration) return duration_ok @@ -544,7 +555,7 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" entity_ids = config.get(CONF_ENTITY_ID, []) req_states: str | list[str] = config.get(CONF_STATE, []) - for_period = config.get("for") + for_period = config.get(CONF_FOR) attribute = config.get(CONF_ATTRIBUTE) match = config.get(CONF_MATCH, ENTITY_MATCH_ALL) @@ -554,12 +565,15 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: @trace_condition_function def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" + template_attach(hass, for_period) errors = [] result: bool = match != ENTITY_MATCH_ANY for index, entity_id in enumerate(entity_ids): try: with trace_path(["entity_id", str(index)]), trace_condition(variables): - if state(hass, entity_id, req_states, for_period, attribute): + if state( + hass, entity_id, req_states, for_period, attribute, variables + ): result = True elif match == ENTITY_MATCH_ALL: return False diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index c13b703cae6..42e1927e09b 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -386,6 +386,8 @@ def icon(value: Any) -> str: raise vol.Invalid('Icons should be specified in the form "prefix:name"') +_TIME_PERIOD_DICT_KEYS = ("days", "hours", "minutes", "seconds", "milliseconds") + time_period_dict = vol.All( dict, vol.Schema( @@ -397,7 +399,7 @@ time_period_dict = vol.All( "milliseconds": vol.Coerce(float), } ), - has_at_least_one_key("days", "hours", "minutes", "seconds", "milliseconds"), + has_at_least_one_key(*_TIME_PERIOD_DICT_KEYS), lambda value: timedelta(**value), ) @@ -639,8 +641,24 @@ def template_complex(value: Any) -> Any: return value +def _positive_time_period_template_complex(value: Any) -> Any: + """Do basic validation of a positive time period expressed as a templated dict.""" + if not isinstance(value, dict) or not value: + raise vol.Invalid("template should be a dict") + for key, element in value.items(): + if not isinstance(key, str): + raise vol.Invalid("key should be a string") + if not template_helper.is_template_string(key): + vol.In(_TIME_PERIOD_DICT_KEYS)(key) + if not isinstance(element, str) or ( + isinstance(element, str) and not template_helper.is_template_string(element) + ): + vol.All(vol.Coerce(float), vol.Range(min=0))(element) + return template_complex(value) + + positive_time_period_template = vol.Any( - positive_time_period, template, template_complex + positive_time_period, dynamic_template, _positive_time_period_template_complex ) @@ -1166,7 +1184,7 @@ STATE_CONDITION_BASE_SCHEMA = { vol.Lower, vol.Any(ENTITY_MATCH_ALL, ENTITY_MATCH_ANY) ), vol.Optional(CONF_ATTRIBUTE): str, - vol.Optional(CONF_FOR): positive_time_period, + vol.Optional(CONF_FOR): positive_time_period_template, # To support use_trigger_value in automation # Deprecated 2016/04/25 vol.Optional("from"): str, diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 477751556cf..0940542bdb0 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1044,27 +1044,23 @@ async def test_if_fails_setup_bad_for(hass, calls, above, below): hass.states.async_set("test.entity", 5) await hass.async_block_till_done() - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "numeric_state", - "entity_id": "test.entity", - "above": above, - "below": below, - "for": {"invalid": 5}, - }, - "action": {"service": "homeassistant.turn_on"}, - } - }, - ) - - with patch.object(numeric_state_trigger, "_LOGGER") as mock_logger: - hass.states.async_set("test.entity", 9) - await hass.async_block_till_done() - assert mock_logger.error.called + with assert_setup_component(0, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "above": above, + "below": below, + "for": {"invalid": 5}, + }, + "action": {"service": "homeassistant.turn_on"}, + } + }, + ) async def test_if_fails_setup_for_without_above_below(hass, calls): diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 2cd90d50547..26f02ce8d71 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -578,26 +578,22 @@ async def test_if_fails_setup_if_from_boolean_value(hass, calls): async def test_if_fails_setup_bad_for(hass, calls): """Test for setup failure for bad for.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "state", - "entity_id": "test.entity", - "to": "world", - "for": {"invalid": 5}, - }, - "action": {"service": "homeassistant.turn_on"}, - } - }, - ) - - with patch.object(state_trigger, "_LOGGER") as mock_logger: - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert mock_logger.error.called + with assert_setup_component(0, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "to": "world", + "for": {"invalid": 5}, + }, + "action": {"service": "homeassistant.turn_on"}, + } + }, + ) async def test_if_not_fires_on_entity_change_with_for(hass, calls): diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 364e4c9fb95..bb4f8627115 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,5 +1,5 @@ """Test the condition helper.""" -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch import pytest @@ -1125,6 +1125,81 @@ async def test_state_raises(hass: HomeAssistant) -> None: test(hass) +async def test_state_for(hass: HomeAssistant) -> None: + """Test state with duration.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature"], + "state": "100", + "for": {"seconds": 5}, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 100) + assert not test(hass) + + now = dt_util.utcnow() + timedelta(seconds=5) + with patch("homeassistant.util.dt.utcnow", return_value=now): + assert test(hass) + + +async def test_state_for_template(hass: HomeAssistant) -> None: + """Test state with templated duration.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature"], + "state": "100", + "for": {"seconds": "{{ states('input_number.test')|int }}"}, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 100) + hass.states.async_set("input_number.test", 5) + assert not test(hass) + + now = dt_util.utcnow() + timedelta(seconds=5) + with patch("homeassistant.util.dt.utcnow", return_value=now): + assert test(hass) + + +@pytest.mark.parametrize("for_template", [{"{{invalid}}": 5}, {"hours": "{{ 1/0 }}"}]) +async def test_state_for_invalid_template(hass: HomeAssistant, for_template) -> None: + """Test state with invalid templated duration.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature"], + "state": "100", + "for": for_template, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 100) + hass.states.async_set("input_number.test", 5) + with pytest.raises(ConditionError): + assert not test(hass) + + async def test_state_unknown_attribute(hass: HomeAssistant) -> None: """Test that state returns False on unknown attribute.""" # Unknown attribute diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 9e1c84151c2..6823e9655bd 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1376,3 +1376,28 @@ def test_language() -> None: for value in ("en", "sv"): assert schema(value) + + +def test_positive_time_period_template() -> None: + """Test positive time period template validation.""" + schema = vol.Schema(cv.positive_time_period_template) + + with pytest.raises(vol.MultipleInvalid): + schema({}) + with pytest.raises(vol.MultipleInvalid): + schema({5: 5}) + with pytest.raises(vol.MultipleInvalid): + schema({"invalid": 5}) + with pytest.raises(vol.MultipleInvalid): + schema("invalid") + + # Time periods pass + schema("00:01") + schema("00:00:01") + schema("00:00:00.500") + schema({"minutes": 5}) + + # Templates are not evaluated and will pass + schema("{{ 'invalid' }}") + schema({"{{ 'invalid' }}": 5}) + schema({"minutes": "{{ 'invalid' }}"})