Raise ConditionError for numeric_state errors (#45923)

This commit is contained in:
Anders Melchiorsen 2021-02-08 10:47:57 +01:00 committed by GitHub
parent 9e07910ab0
commit b9b1caf4d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 352 additions and 63 deletions

View File

@ -32,7 +32,7 @@ from homeassistant.core import (
callback, callback,
split_entity_id, split_entity_id,
) )
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import ConditionError, HomeAssistantError
from homeassistant.helpers import condition, extract_domain_configs, template from homeassistant.helpers import condition, extract_domain_configs, template
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity import ToggleEntity
@ -588,7 +588,11 @@ async def _async_process_if(hass, config, p_config):
def if_action(variables=None): def if_action(variables=None):
"""AND all conditions.""" """AND all conditions."""
try:
return all(check(hass, variables) for check in checks) 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 if_action.config = if_configs

View File

@ -17,7 +17,7 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import ConditionError, TemplateError
from homeassistant.helpers import condition from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
@ -340,6 +340,7 @@ class BayesianBinarySensor(BinarySensorEntity):
"""Return True if numeric condition is met.""" """Return True if numeric condition is met."""
entity = entity_observation["entity_id"] entity = entity_observation["entity_id"]
try:
return condition.async_numeric_state( return condition.async_numeric_state(
self.hass, self.hass,
entity, entity,
@ -348,6 +349,8 @@ class BayesianBinarySensor(BinarySensorEntity):
None, None,
entity_observation, entity_observation,
) )
except ConditionError:
return False
def _process_state(self, entity_observation): def _process_state(self, entity_observation):
"""Return True if state conditions are met.""" """Return True if state conditions are met."""

View File

@ -96,12 +96,19 @@ async def async_attach_trigger(
@callback @callback
def check_numeric_state(entity_id, from_s, to_s): def check_numeric_state(entity_id, from_s, to_s):
"""Return True if criteria are now met.""" """Return True if criteria are now met."""
if to_s is None: try:
return False
return condition.async_numeric_state( return condition.async_numeric_state(
hass, to_s, below, above, value_template, variables(entity_id), attribute hass,
to_s,
below,
above,
value_template,
variables(entity_id),
attribute,
) )
except exceptions.ConditionError as err:
_LOGGER.warning("%s", err)
return False
@callback @callback
def state_automation_listener(event): def state_automation_listener(event):

View File

@ -25,6 +25,10 @@ class TemplateError(HomeAssistantError):
super().__init__(f"{exception.__class__.__name__}: {exception}") super().__init__(f"{exception.__class__.__name__}: {exception}")
class ConditionError(HomeAssistantError):
"""Error during condition evaluation."""
class PlatformNotReady(HomeAssistantError): class PlatformNotReady(HomeAssistantError):
"""Error to indicate that platform is not ready.""" """Error to indicate that platform is not ready."""

View File

@ -36,7 +36,7 @@ from homeassistant.const import (
WEEKDAYS, WEEKDAYS,
) )
from homeassistant.core import HomeAssistant, State, callback 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.sun import get_astral_event_date
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
@ -204,11 +204,22 @@ def async_numeric_state(
attribute: Optional[str] = None, attribute: Optional[str] = None,
) -> bool: ) -> bool:
"""Test a numeric state condition.""" """Test a numeric state condition."""
if entity is None:
raise ConditionError("No entity specified")
if isinstance(entity, str): if isinstance(entity, str):
entity_id = entity
entity = hass.states.get(entity) entity = hass.states.get(entity)
if entity is None or (attribute is not None and attribute not in entity.attributes): if entity is None:
return False 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 value: Any = None
if value_template is None: if value_template is None:
@ -222,30 +233,27 @@ def async_numeric_state(
try: try:
value = value_template.async_render(variables) value = value_template.async_render(variables)
except TemplateError as ex: except TemplateError as ex:
_LOGGER.error("Template error: %s", ex) raise ConditionError(f"Template error: {ex}") from ex
return False
if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): if value in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False raise ConditionError("State is not available")
try: try:
fvalue = float(value) fvalue = float(value)
except ValueError: except ValueError as ex:
_LOGGER.warning( raise ConditionError(
"Value cannot be processed as a number: %s (Offending entity: %s)", f"Entity {entity_id} state '{value}' cannot be processed as a number"
entity, ) from ex
value,
)
return False
if below is not None: if below is not None:
if isinstance(below, str): if isinstance(below, str):
below_entity = hass.states.get(below) below_entity = hass.states.get(below)
if ( if not below_entity or below_entity.state in (
not below_entity STATE_UNAVAILABLE,
or below_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) STATE_UNKNOWN,
or fvalue >= float(below_entity.state)
): ):
raise ConditionError(f"The below entity {below} is not available")
if fvalue >= float(below_entity.state):
return False return False
elif fvalue >= below: elif fvalue >= below:
return False return False
@ -253,11 +261,12 @@ def async_numeric_state(
if above is not None: if above is not None:
if isinstance(above, str): if isinstance(above, str):
above_entity = hass.states.get(above) above_entity = hass.states.get(above)
if ( if not above_entity or above_entity.state in (
not above_entity STATE_UNAVAILABLE,
or above_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) STATE_UNKNOWN,
or fvalue <= float(above_entity.state)
): ):
raise ConditionError(f"The above entity {above} is not available")
if fvalue <= float(above_entity.state):
return False return False
elif fvalue <= above: elif fvalue <= above:
return False return False

View File

@ -519,7 +519,12 @@ class _ScriptRun:
CONF_ALIAS, self._action[CONF_CONDITION] CONF_ALIAS, self._action[CONF_CONDITION]
) )
cond = await self._async_get_condition(self._action) cond = await self._async_get_condition(self._action)
try:
check = cond(self._hass, self._variables) 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) self._log("Test condition %s: %s", self._script.last_action, check)
if not check: if not check:
raise _StopScript raise _StopScript
@ -570,10 +575,15 @@ class _ScriptRun:
] ]
for iteration in itertools.count(1): for iteration in itertools.count(1):
set_repeat_var(iteration) set_repeat_var(iteration)
try:
if self._stop.is_set() or not all( if self._stop.is_set() or not all(
cond(self._hass, self._variables) for cond in conditions cond(self._hass, self._variables) for cond in conditions
): ):
break break
except exceptions.ConditionError as ex:
_LOGGER.warning("Error in 'while' evaluation: %s", ex)
break
await async_run_sequence(iteration) await async_run_sequence(iteration)
elif CONF_UNTIL in repeat: elif CONF_UNTIL in repeat:
@ -583,10 +593,14 @@ class _ScriptRun:
for iteration in itertools.count(1): for iteration in itertools.count(1):
set_repeat_var(iteration) set_repeat_var(iteration)
await async_run_sequence(iteration) await async_run_sequence(iteration)
try:
if self._stop.is_set() or all( if self._stop.is_set() or all(
cond(self._hass, self._variables) for cond in conditions cond(self._hass, self._variables) for cond in conditions
): ):
break break
except exceptions.ConditionError as ex:
_LOGGER.warning("Error in 'until' evaluation: %s", ex)
break
if saved_repeat_vars: if saved_repeat_vars:
self._variables["repeat"] = saved_repeat_vars self._variables["repeat"] = saved_repeat_vars
@ -599,9 +613,14 @@ class _ScriptRun:
choose_data = await self._script._async_get_choose_data(self._step) choose_data = await self._script._async_get_choose_data(self._step)
for conditions, script in choose_data["choices"]: for conditions, script in choose_data["choices"]:
if all(condition(self._hass, self._variables) for condition in conditions): try:
if all(
condition(self._hass, self._variables) for condition in conditions
):
await self._async_run_script(script) await self._async_run_script(script)
return return
except exceptions.ConditionError as ex:
_LOGGER.warning("Error in 'choose' evaluation: %s", ex)
if choose_data["default"]: if choose_data["default"]:
await self._async_run_script(choose_data["default"]) await self._async_run_script(choose_data["default"])

View File

@ -162,18 +162,20 @@ async def test_trigger_service_ignoring_condition(hass, calls):
"alias": "test", "alias": "test",
"trigger": [{"platform": "event", "event_type": "test_event"}], "trigger": [{"platform": "event", "event_type": "test_event"}],
"condition": { "condition": {
"condition": "state", "condition": "numeric_state",
"entity_id": "non.existing", "entity_id": "non.existing",
"state": "beer", "above": "1",
}, },
"action": {"service": "test.automation"}, "action": {"service": "test.automation"},
} }
}, },
) )
with patch("homeassistant.components.automation.LOGGER.warning") as logwarn:
hass.bus.async_fire("test_event") hass.bus.async_fire("test_event")
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(calls) == 0 assert len(calls) == 0
assert len(logwarn.mock_calls) == 1
await hass.services.async_call( await hass.services.async_call(
"automation", "trigger", {"entity_id": "automation.test"}, blocking=True "automation", "trigger", {"entity_id": "automation.test"}, blocking=True

View File

@ -572,6 +572,32 @@ async def test_if_not_fires_if_entity_not_match(hass, calls, below):
assert len(calls) == 0 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")) @pytest.mark.parametrize("below", (10, "input_number.value_10"))
async def test_if_fires_on_entity_change_below_with_attribute(hass, calls, below): async def test_if_fires_on_entity_change_below_with_attribute(hass, calls, below):
"""Test attributes change.""" """Test attributes change."""

View File

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import ConditionError, HomeAssistantError
from homeassistant.helpers import condition from homeassistant.helpers import condition
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.setup import async_setup_component 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") assert not condition.time(hass, before="input_datetime.not_existing")
async def test_if_numeric_state_not_raise_on_unavailable(hass): async def test_if_numeric_state_raises_on_unavailable(hass):
"""Test numeric_state doesn't raise on unavailable/unknown state.""" """Test numeric_state raises on unavailable/unknown state."""
test = await condition.async_from_config( test = await condition.async_from_config(
hass, hass,
{"condition": "numeric_state", "entity_id": "sensor.temperature", "below": 42}, {"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: with patch("homeassistant.helpers.condition._LOGGER.warning") as logwarn:
hass.states.async_set("sensor.temperature", "unavailable") hass.states.async_set("sensor.temperature", "unavailable")
assert not test(hass) with pytest.raises(ConditionError):
test(hass)
assert len(logwarn.mock_calls) == 0 assert len(logwarn.mock_calls) == 0
hass.states.async_set("sensor.temperature", "unknown") hass.states.async_set("sensor.temperature", "unknown")
assert not test(hass) with pytest.raises(ConditionError):
test(hass)
assert len(logwarn.mock_calls) == 0 assert len(logwarn.mock_calls) == 0
@ -550,6 +552,108 @@ async def test_state_using_input_entities(hass):
assert test(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): async def test_numeric_state_multiple_entities(hass):
"""Test with multiple entities in condition.""" """Test with multiple entities in condition."""
test = await condition.async_from_config( test = await condition.async_from_config(
@ -660,10 +764,12 @@ async def test_numeric_state_using_input_number(hass):
) )
assert test(hass) assert test(hass)
assert not condition.async_numeric_state( with pytest.raises(ConditionError):
condition.async_numeric_state(
hass, entity="sensor.temperature", below="input_number.not_exist" hass, entity="sensor.temperature", below="input_number.not_exist"
) )
assert not condition.async_numeric_state( with pytest.raises(ConditionError):
condition.async_numeric_state(
hass, entity="sensor.temperature", above="input_number.not_exist" hass, entity="sensor.temperature", above="input_number.not_exist"
) )

View File

@ -990,6 +990,32 @@ async def test_wait_for_trigger_generated_exception(hass, caplog):
assert "something bad" in caplog.text 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): async def test_condition_basic(hass):
"""Test if we can use conditions in a script.""" """Test if we can use conditions in a script."""
event = "test_event" event = "test_event"
@ -1100,6 +1126,44 @@ async def test_repeat_count(hass):
assert event.data.get("last") == (index == count - 1) 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("condition", ["while", "until"])
@pytest.mark.parametrize("direct_template", [False, True]) @pytest.mark.parametrize("direct_template", [False, True])
async def test_repeat_conditional(hass, condition, direct_template): 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")]) @pytest.mark.parametrize("var,result", [(1, "first"), (2, "second"), (3, "default")])
async def test_choose(hass, var, result): async def test_choose(hass, var, result):
"""Test choose action.""" """Test choose action."""