diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 295450e8211..94f2cedd58e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -32,7 +32,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import condition, extract_domain_configs, template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity @@ -588,7 +588,11 @@ async def _async_process_if(hass, config, p_config): def if_action(variables=None): """AND all conditions.""" - return all(check(hass, variables) for check in checks) + try: + return all(check(hass, variables) for check in checks) + except ConditionError as ex: + LOGGER.warning("Error in 'condition' evaluation: %s", ex) + return False if_action.config = if_configs diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 4768b3f4fe6..15176d45349 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import callback -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import ConditionError, TemplateError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( @@ -340,14 +340,17 @@ class BayesianBinarySensor(BinarySensorEntity): """Return True if numeric condition is met.""" entity = entity_observation["entity_id"] - return condition.async_numeric_state( - self.hass, - entity, - entity_observation.get("below"), - entity_observation.get("above"), - None, - entity_observation, - ) + try: + return condition.async_numeric_state( + self.hass, + entity, + entity_observation.get("below"), + entity_observation.get("above"), + None, + entity_observation, + ) + except ConditionError: + return False def _process_state(self, entity_observation): """Return True if state conditions are met.""" diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 7cfee8fad93..55e875c90de 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -96,13 +96,20 @@ async def async_attach_trigger( @callback def check_numeric_state(entity_id, from_s, to_s): """Return True if criteria are now met.""" - if to_s is None: + try: + return condition.async_numeric_state( + hass, + to_s, + below, + above, + value_template, + variables(entity_id), + attribute, + ) + except exceptions.ConditionError as err: + _LOGGER.warning("%s", err) return False - return condition.async_numeric_state( - hass, to_s, below, above, value_template, variables(entity_id), attribute - ) - @callback def state_automation_listener(event): """Listen for state changes and calls action.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index e37f68a07bf..852795ebb4a 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -25,6 +25,10 @@ class TemplateError(HomeAssistantError): super().__init__(f"{exception.__class__.__name__}: {exception}") +class ConditionError(HomeAssistantError): + """Error during condition evaluation.""" + + class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5ace4c91bcf..e47374a9d17 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -36,7 +36,7 @@ from homeassistant.const import ( WEEKDAYS, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.exceptions import ConditionError, HomeAssistantError, TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.template import Template @@ -204,11 +204,22 @@ def async_numeric_state( attribute: Optional[str] = None, ) -> bool: """Test a numeric state condition.""" + if entity is None: + raise ConditionError("No entity specified") + if isinstance(entity, str): + entity_id = entity entity = hass.states.get(entity) - if entity is None or (attribute is not None and attribute not in entity.attributes): - return False + if entity is None: + raise ConditionError(f"Unknown entity {entity_id}") + else: + entity_id = entity.entity_id + + if attribute is not None and attribute not in entity.attributes: + raise ConditionError( + f"Attribute '{attribute}' (of entity {entity_id}) does not exist" + ) value: Any = None if value_template is None: @@ -222,30 +233,27 @@ def async_numeric_state( try: value = value_template.async_render(variables) except TemplateError as ex: - _LOGGER.error("Template error: %s", ex) - return False + raise ConditionError(f"Template error: {ex}") from ex if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return False + raise ConditionError("State is not available") try: fvalue = float(value) - except ValueError: - _LOGGER.warning( - "Value cannot be processed as a number: %s (Offending entity: %s)", - entity, - value, - ) - return False + except ValueError as ex: + raise ConditionError( + f"Entity {entity_id} state '{value}' cannot be processed as a number" + ) from ex if below is not None: if isinstance(below, str): below_entity = hass.states.get(below) - if ( - not below_entity - or below_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or fvalue >= float(below_entity.state) + if not below_entity or below_entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, ): + raise ConditionError(f"The below entity {below} is not available") + if fvalue >= float(below_entity.state): return False elif fvalue >= below: return False @@ -253,11 +261,12 @@ def async_numeric_state( if above is not None: if isinstance(above, str): above_entity = hass.states.get(above) - if ( - not above_entity - or above_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) - or fvalue <= float(above_entity.state) + if not above_entity or above_entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, ): + raise ConditionError(f"The above entity {above} is not available") + if fvalue <= float(above_entity.state): return False elif fvalue <= above: return False diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 60a1be6103a..8706f765b50 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -519,7 +519,12 @@ class _ScriptRun: CONF_ALIAS, self._action[CONF_CONDITION] ) cond = await self._async_get_condition(self._action) - check = cond(self._hass, self._variables) + try: + check = cond(self._hass, self._variables) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'condition' evaluation: %s", ex) + check = False + self._log("Test condition %s: %s", self._script.last_action, check) if not check: raise _StopScript @@ -570,10 +575,15 @@ class _ScriptRun: ] for iteration in itertools.count(1): set_repeat_var(iteration) - if self._stop.is_set() or not all( - cond(self._hass, self._variables) for cond in conditions - ): + try: + if self._stop.is_set() or not all( + cond(self._hass, self._variables) for cond in conditions + ): + break + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'while' evaluation: %s", ex) break + await async_run_sequence(iteration) elif CONF_UNTIL in repeat: @@ -583,9 +593,13 @@ class _ScriptRun: for iteration in itertools.count(1): set_repeat_var(iteration) await async_run_sequence(iteration) - if self._stop.is_set() or all( - cond(self._hass, self._variables) for cond in conditions - ): + try: + if self._stop.is_set() or all( + cond(self._hass, self._variables) for cond in conditions + ): + break + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'until' evaluation: %s", ex) break if saved_repeat_vars: @@ -599,9 +613,14 @@ class _ScriptRun: choose_data = await self._script._async_get_choose_data(self._step) for conditions, script in choose_data["choices"]: - if all(condition(self._hass, self._variables) for condition in conditions): - await self._async_run_script(script) - return + try: + if all( + condition(self._hass, self._variables) for condition in conditions + ): + await self._async_run_script(script) + return + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'choose' evaluation: %s", ex) if choose_data["default"]: await self._async_run_script(choose_data["default"]) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index c31af555e32..0dbc4b2cc69 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -162,18 +162,20 @@ async def test_trigger_service_ignoring_condition(hass, calls): "alias": "test", "trigger": [{"platform": "event", "event_type": "test_event"}], "condition": { - "condition": "state", + "condition": "numeric_state", "entity_id": "non.existing", - "state": "beer", + "above": "1", }, "action": {"service": "test.automation"}, } }, ) - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - assert len(calls) == 0 + with patch("homeassistant.components.automation.LOGGER.warning") as logwarn: + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 0 + assert len(logwarn.mock_calls) == 1 await hass.services.async_call( "automation", "trigger", {"entity_id": "automation.test"}, blocking=True diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index b9696fffe06..979c5bad5d7 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -572,6 +572,32 @@ async def test_if_not_fires_if_entity_not_match(hass, calls, below): assert len(calls) == 0 +async def test_if_not_fires_and_warns_if_below_entity_unknown(hass, calls): + """Test if warns with unknown below entity.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "below": "input_number.unknown", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + with patch( + "homeassistant.components.homeassistant.triggers.numeric_state._LOGGER.warning" + ) as logwarn: + hass.states.async_set("test.entity", 1) + await hass.async_block_till_done() + assert len(calls) == 0 + assert len(logwarn.mock_calls) == 1 + + @pytest.mark.parametrize("below", (10, "input_number.value_10")) async def test_if_fires_on_entity_change_below_with_attribute(hass, calls, below): """Test attributes change.""" diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index fe2a9aa4406..5c388aa6db4 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import condition from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component @@ -338,8 +338,8 @@ async def test_time_using_input_datetime(hass): assert not condition.time(hass, before="input_datetime.not_existing") -async def test_if_numeric_state_not_raise_on_unavailable(hass): - """Test numeric_state doesn't raise on unavailable/unknown state.""" +async def test_if_numeric_state_raises_on_unavailable(hass): + """Test numeric_state raises on unavailable/unknown state.""" test = await condition.async_from_config( hass, {"condition": "numeric_state", "entity_id": "sensor.temperature", "below": 42}, @@ -347,11 +347,13 @@ async def test_if_numeric_state_not_raise_on_unavailable(hass): with patch("homeassistant.helpers.condition._LOGGER.warning") as logwarn: hass.states.async_set("sensor.temperature", "unavailable") - assert not test(hass) + with pytest.raises(ConditionError): + test(hass) assert len(logwarn.mock_calls) == 0 hass.states.async_set("sensor.temperature", "unknown") - assert not test(hass) + with pytest.raises(ConditionError): + test(hass) assert len(logwarn.mock_calls) == 0 @@ -550,6 +552,108 @@ async def test_state_using_input_entities(hass): assert test(hass) +async def test_numeric_state_raises(hass): + """Test that numeric_state raises ConditionError on errors.""" + # Unknown entity_id + with pytest.raises(ConditionError, match="Unknown entity"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature_unknown", + "above": 0, + }, + ) + + assert test(hass) + + # Unknown attribute + with pytest.raises(ConditionError, match=r"Attribute .* does not exist"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "attribute": "temperature", + "above": 0, + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + # Template error + with pytest.raises(ConditionError, match="ZeroDivisionError"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "value_template": "{{ 1 / 0 }}", + "above": 0, + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + # Unavailable state + with pytest.raises(ConditionError, match="State is not available"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + }, + ) + + hass.states.async_set("sensor.temperature", "unavailable") + test(hass) + + # Bad number + with pytest.raises(ConditionError, match="cannot be processed as a number"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + }, + ) + + hass.states.async_set("sensor.temperature", "fifty") + test(hass) + + # Below entity missing + with pytest.raises(ConditionError, match="below entity"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": "input_number.missing", + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + # Above entity missing + with pytest.raises(ConditionError, match="above entity"): + test = await condition.async_from_config( + hass, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": "input_number.missing", + }, + ) + + hass.states.async_set("sensor.temperature", 50) + test(hass) + + async def test_numeric_state_multiple_entities(hass): """Test with multiple entities in condition.""" test = await condition.async_from_config( @@ -660,12 +764,14 @@ async def test_numeric_state_using_input_number(hass): ) assert test(hass) - assert not condition.async_numeric_state( - hass, entity="sensor.temperature", below="input_number.not_exist" - ) - assert not condition.async_numeric_state( - hass, entity="sensor.temperature", above="input_number.not_exist" - ) + with pytest.raises(ConditionError): + condition.async_numeric_state( + hass, entity="sensor.temperature", below="input_number.not_exist" + ) + with pytest.raises(ConditionError): + condition.async_numeric_state( + hass, entity="sensor.temperature", above="input_number.not_exist" + ) async def test_zone_multiple_entities(hass): diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 65d8b442bf0..003e903de14 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -990,6 +990,32 @@ async def test_wait_for_trigger_generated_exception(hass, caplog): assert "something bad" in caplog.text +async def test_condition_warning(hass): + """Test warning on condition.""" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + { + "condition": "numeric_state", + "entity_id": "test.entity", + "above": 0, + }, + {"event": event}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.states.async_set("test.entity", "string") + with patch("homeassistant.helpers.script._LOGGER.warning") as logwarn: + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + assert len(logwarn.mock_calls) == 1 + + assert len(events) == 1 + + async def test_condition_basic(hass): """Test if we can use conditions in a script.""" event = "test_event" @@ -1100,6 +1126,44 @@ async def test_repeat_count(hass): assert event.data.get("last") == (index == count - 1) +@pytest.mark.parametrize("condition", ["while", "until"]) +async def test_repeat_condition_warning(hass, condition): + """Test warning on repeat conditions.""" + event = "test_event" + events = async_capture_events(hass, event) + count = 0 if condition == "while" else 1 + + sequence = { + "repeat": { + "sequence": [ + { + "event": event, + }, + ], + } + } + sequence["repeat"][condition] = { + "condition": "numeric_state", + "entity_id": "sensor.test", + "value_template": "{{ unassigned_variable }}", + "above": "0", + } + + script_obj = script.Script( + hass, cv.SCRIPT_SCHEMA(sequence), f"Test {condition}", "test_domain" + ) + + # wait_started = async_watch_for_action(script_obj, "wait") + hass.states.async_set("sensor.test", "1") + + with patch("homeassistant.helpers.script._LOGGER.warning") as logwarn: + hass.async_create_task(script_obj.async_run(context=Context())) + await asyncio.wait_for(hass.async_block_till_done(), 1) + assert len(logwarn.mock_calls) == 1 + + assert len(events) == count + + @pytest.mark.parametrize("condition", ["while", "until"]) @pytest.mark.parametrize("direct_template", [False, True]) async def test_repeat_conditional(hass, condition, direct_template): @@ -1305,6 +1369,51 @@ async def test_repeat_nested(hass, variables, first_last, inside_x): } +async def test_choose_warning(hass): + """Test warning on choose.""" + event = "test_event" + events = async_capture_events(hass, event) + + sequence = cv.SCRIPT_SCHEMA( + { + "choose": [ + { + "conditions": { + "condition": "numeric_state", + "entity_id": "test.entity", + "value_template": "{{ undefined_a + undefined_b }}", + "above": 1, + }, + "sequence": {"event": event, "event_data": {"choice": "first"}}, + }, + { + "conditions": { + "condition": "numeric_state", + "entity_id": "test.entity", + "value_template": "{{ 'string' }}", + "above": 2, + }, + "sequence": {"event": event, "event_data": {"choice": "second"}}, + }, + ], + "default": {"event": event, "event_data": {"choice": "default"}}, + } + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.states.async_set("test.entity", "9") + await hass.async_block_till_done() + + with patch("homeassistant.helpers.script._LOGGER.warning") as logwarn: + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + print(logwarn.mock_calls) + assert len(logwarn.mock_calls) == 2 + + assert len(events) == 1 + assert events[0].data["choice"] == "default" + + @pytest.mark.parametrize("var,result", [(1, "first"), (2, "second"), (3, "default")]) async def test_choose(hass, var, result): """Test choose action."""