diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e423be718ea..df0afb1e443 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -973,6 +973,41 @@ def custom_serializer(schema: Any) -> Any: return voluptuous_serialize.UNSUPPORTED +def expand_condition_shorthand(value: Any | None) -> Any: + """Expand boolean condition shorthand notations.""" + + if not isinstance(value, dict) or CONF_CONDITIONS in value: + return value + + for key, schema in ( + ("and", AND_CONDITION_SHORTHAND_SCHEMA), + ("or", OR_CONDITION_SHORTHAND_SCHEMA), + ("not", NOT_CONDITION_SHORTHAND_SCHEMA), + ): + try: + schema(value) + return { + CONF_CONDITION: key, + CONF_CONDITIONS: value[key], + **{k: value[k] for k in value if k != key}, + } + except vol.MultipleInvalid: + pass + + if isinstance(value.get(CONF_CONDITION), list): + try: + CONDITION_SHORTHAND_SCHEMA(value) + return { + CONF_CONDITION: "and", + CONF_CONDITIONS: value[CONF_CONDITION], + **{k: value[k] for k in value if k != CONF_CONDITION}, + } + except vol.MultipleInvalid: + pass + + return value + + # Schemas PLATFORM_SCHEMA = vol.Schema( { @@ -1236,6 +1271,17 @@ AND_CONDITION_SCHEMA = vol.Schema( } ) +AND_CONDITION_SHORTHAND_SCHEMA = vol.Schema( + { + **CONDITION_BASE_SCHEMA, + vol.Required("and"): vol.All( + ensure_list, + # pylint: disable=unnecessary-lambda + [lambda value: CONDITION_SCHEMA(value)], + ), + } +) + OR_CONDITION_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, @@ -1248,6 +1294,17 @@ OR_CONDITION_SCHEMA = vol.Schema( } ) +OR_CONDITION_SHORTHAND_SCHEMA = vol.Schema( + { + **CONDITION_BASE_SCHEMA, + vol.Required("or"): vol.All( + ensure_list, + # pylint: disable=unnecessary-lambda + [lambda value: CONDITION_SCHEMA(value)], + ), + } +) + NOT_CONDITION_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, @@ -1260,6 +1317,17 @@ NOT_CONDITION_SCHEMA = vol.Schema( } ) +NOT_CONDITION_SHORTHAND_SCHEMA = vol.Schema( + { + **CONDITION_BASE_SCHEMA, + vol.Required("not"): vol.All( + ensure_list, + # pylint: disable=unnecessary-lambda + [lambda value: CONDITION_SCHEMA(value)], + ), + } +) + DEVICE_CONDITION_BASE_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, @@ -1280,24 +1348,37 @@ dynamic_template_condition_action = vol.All( }, ) +CONDITION_SHORTHAND_SCHEMA = vol.Schema( + { + **CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): vol.All( + ensure_list, + # pylint: disable=unnecessary-lambda + [lambda value: CONDITION_SCHEMA(value)], + ), + } +) CONDITION_SCHEMA: vol.Schema = vol.Schema( vol.Any( - key_value_schemas( - CONF_CONDITION, - { - "and": AND_CONDITION_SCHEMA, - "device": DEVICE_CONDITION_SCHEMA, - "not": NOT_CONDITION_SCHEMA, - "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, - "or": OR_CONDITION_SCHEMA, - "state": STATE_CONDITION_SCHEMA, - "sun": SUN_CONDITION_SCHEMA, - "template": TEMPLATE_CONDITION_SCHEMA, - "time": TIME_CONDITION_SCHEMA, - "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, - }, + vol.All( + expand_condition_shorthand, + key_value_schemas( + CONF_CONDITION, + { + "and": AND_CONDITION_SCHEMA, + "device": DEVICE_CONDITION_SCHEMA, + "not": NOT_CONDITION_SCHEMA, + "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, + "or": OR_CONDITION_SCHEMA, + "state": STATE_CONDITION_SCHEMA, + "sun": SUN_CONDITION_SCHEMA, + "template": TEMPLATE_CONDITION_SCHEMA, + "time": TIME_CONDITION_SCHEMA, + "trigger": TRIGGER_CONDITION_SCHEMA, + "zone": ZONE_CONDITION_SCHEMA, + }, + ), ), dynamic_template_condition_action, ) @@ -1318,23 +1399,26 @@ dynamic_template_condition_action = vol.All( CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( - key_value_schemas( - CONF_CONDITION, - { - "and": AND_CONDITION_SCHEMA, - "device": DEVICE_CONDITION_SCHEMA, - "not": NOT_CONDITION_SCHEMA, - "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, - "or": OR_CONDITION_SCHEMA, - "state": STATE_CONDITION_SCHEMA, - "sun": SUN_CONDITION_SCHEMA, - "template": TEMPLATE_CONDITION_SCHEMA, - "time": TIME_CONDITION_SCHEMA, - "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, - }, - dynamic_template_condition_action, - "a valid template", + vol.All( + expand_condition_shorthand, + key_value_schemas( + CONF_CONDITION, + { + "and": AND_CONDITION_SCHEMA, + "device": DEVICE_CONDITION_SCHEMA, + "not": NOT_CONDITION_SCHEMA, + "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, + "or": OR_CONDITION_SCHEMA, + "state": STATE_CONDITION_SCHEMA, + "sun": SUN_CONDITION_SCHEMA, + "template": TEMPLATE_CONDITION_SCHEMA, + "time": TIME_CONDITION_SCHEMA, + "trigger": TRIGGER_CONDITION_SCHEMA, + "zone": ZONE_CONDITION_SCHEMA, + }, + dynamic_template_condition_action, + "a list of conditions or a valid template", + ), ) ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 449df4f1108..b7e4caf68c7 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import AsyncMock, patch import pytest +import voluptuous as vol from homeassistant.components import sun import homeassistant.components.automation as automation @@ -288,6 +289,101 @@ async def test_and_condition_with_template(hass): assert test(hass) +async def test_and_condition_shorthand(hass): + """Test the 'and' condition shorthand.""" + config = { + "alias": "And Condition Shorthand", + "and": [ + { + "alias": "Template Condition", + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + assert config["alias"] == "And Condition Shorthand" + assert "and" not in config.keys() + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + assert_condition_trace( + { + "": [{"result": {"result": False}}], + "conditions/0": [ + {"result": {"entities": ["sensor.temperature"], "result": False}} + ], + } + ) + + hass.states.async_set("sensor.temperature", 105) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + +async def test_and_condition_list_shorthand(hass): + """Test the 'and' condition list shorthand.""" + config = { + "alias": "And Condition List Shorthand", + "condition": [ + { + "alias": "Template Condition", + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + assert config["alias"] == "And Condition List Shorthand" + assert "and" not in config.keys() + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + assert_condition_trace( + { + "": [{"result": {"result": False}}], + "conditions/0": [ + {"result": {"entities": ["sensor.temperature"], "result": False}} + ], + } + ) + + hass.states.async_set("sensor.temperature", 105) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + +async def test_malformed_and_condition_list_shorthand(hass): + """Test the 'and' condition list shorthand syntax check.""" + config = { + "alias": "Bad shorthand syntax", + "condition": ["bad", "syntax"], + } + + with pytest.raises(vol.MultipleInvalid): + cv.CONDITION_SCHEMA(config) + + async def test_or_condition(hass): """Test the 'or' condition.""" config = { @@ -471,6 +567,36 @@ async def test_or_condition_with_template(hass): assert test(hass) +async def test_or_condition_shorthand(hass): + """Test the 'or' condition shorthand.""" + config = { + "alias": "Or Condition Shorthand", + "or": [ + {'{{ states.sensor.temperature.state == "100" }}'}, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + assert config["alias"] == "Or Condition Shorthand" + assert "or" not in config.keys() + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 105) + assert test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + async def test_not_condition(hass): """Test the 'not' condition.""" config = { @@ -670,6 +796,42 @@ async def test_not_condition_with_template(hass): assert not test(hass) +async def test_not_condition_shorthand(hass): + """Test the 'or' condition shorthand.""" + config = { + "alias": "Not Condition Shorthand", + "not": [ + { + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 50, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + assert config["alias"] == "Not Condition Shorthand" + assert "not" not in config.keys() + + hass.states.async_set("sensor.temperature", 101) + assert test(hass) + + hass.states.async_set("sensor.temperature", 50) + assert test(hass) + + hass.states.async_set("sensor.temperature", 49) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert not test(hass) + + async def test_time_window(hass): """Test time condition windows.""" sixam = "06:00:00" diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index b1c65cfe971..80a7a163181 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1508,6 +1508,42 @@ async def test_condition_basic(hass, caplog): ) +async def test_and_default_condition(hass, caplog): + """Test that a list of conditions evaluates as AND.""" + alias = "condition step" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": alias, + "condition": [ + { + "condition": "template", + "value_template": "{{ states.test.entity.state == 'hello' }}", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + }, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.states.async_set("sensor.temperature", 100) + hass.states.async_set("test.entity", "hello") + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + assert f"Test condition {alias}: True" in caplog.text + caplog.clear() + + hass.states.async_set("sensor.temperature", 120) + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + assert f"Test condition {alias}: False" in caplog.text + + async def test_shorthand_template_condition(hass, caplog): """Test if we can use shorthand template conditions in a script.""" event = "test_event"