Add shorthand notation for boolean conditions (#70120)

This commit is contained in:
Thomas Lovén 2022-04-18 22:09:09 +02:00 committed by GitHub
parent 8f4979ea17
commit b50f369fe4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 314 additions and 32 deletions

View File

@ -973,6 +973,41 @@ def custom_serializer(schema: Any) -> Any:
return voluptuous_serialize.UNSUPPORTED 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 # Schemas
PLATFORM_SCHEMA = vol.Schema( 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( OR_CONDITION_SCHEMA = vol.Schema(
{ {
**CONDITION_BASE_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( NOT_CONDITION_SCHEMA = vol.Schema(
{ {
**CONDITION_BASE_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( DEVICE_CONDITION_BASE_SCHEMA = vol.Schema(
{ {
**CONDITION_BASE_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( CONDITION_SCHEMA: vol.Schema = vol.Schema(
vol.Any( vol.Any(
key_value_schemas( vol.All(
CONF_CONDITION, expand_condition_shorthand,
{ key_value_schemas(
"and": AND_CONDITION_SCHEMA, CONF_CONDITION,
"device": DEVICE_CONDITION_SCHEMA, {
"not": NOT_CONDITION_SCHEMA, "and": AND_CONDITION_SCHEMA,
"numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, "device": DEVICE_CONDITION_SCHEMA,
"or": OR_CONDITION_SCHEMA, "not": NOT_CONDITION_SCHEMA,
"state": STATE_CONDITION_SCHEMA, "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA,
"sun": SUN_CONDITION_SCHEMA, "or": OR_CONDITION_SCHEMA,
"template": TEMPLATE_CONDITION_SCHEMA, "state": STATE_CONDITION_SCHEMA,
"time": TIME_CONDITION_SCHEMA, "sun": SUN_CONDITION_SCHEMA,
"trigger": TRIGGER_CONDITION_SCHEMA, "template": TEMPLATE_CONDITION_SCHEMA,
"zone": ZONE_CONDITION_SCHEMA, "time": TIME_CONDITION_SCHEMA,
}, "trigger": TRIGGER_CONDITION_SCHEMA,
"zone": ZONE_CONDITION_SCHEMA,
},
),
), ),
dynamic_template_condition_action, dynamic_template_condition_action,
) )
@ -1318,23 +1399,26 @@ dynamic_template_condition_action = vol.All(
CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema(
key_value_schemas( vol.All(
CONF_CONDITION, expand_condition_shorthand,
{ key_value_schemas(
"and": AND_CONDITION_SCHEMA, CONF_CONDITION,
"device": DEVICE_CONDITION_SCHEMA, {
"not": NOT_CONDITION_SCHEMA, "and": AND_CONDITION_SCHEMA,
"numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, "device": DEVICE_CONDITION_SCHEMA,
"or": OR_CONDITION_SCHEMA, "not": NOT_CONDITION_SCHEMA,
"state": STATE_CONDITION_SCHEMA, "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA,
"sun": SUN_CONDITION_SCHEMA, "or": OR_CONDITION_SCHEMA,
"template": TEMPLATE_CONDITION_SCHEMA, "state": STATE_CONDITION_SCHEMA,
"time": TIME_CONDITION_SCHEMA, "sun": SUN_CONDITION_SCHEMA,
"trigger": TRIGGER_CONDITION_SCHEMA, "template": TEMPLATE_CONDITION_SCHEMA,
"zone": ZONE_CONDITION_SCHEMA, "time": TIME_CONDITION_SCHEMA,
}, "trigger": TRIGGER_CONDITION_SCHEMA,
dynamic_template_condition_action, "zone": ZONE_CONDITION_SCHEMA,
"a valid template", },
dynamic_template_condition_action,
"a list of conditions or a valid template",
),
) )
) )

View File

@ -3,6 +3,7 @@ from datetime import datetime
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import voluptuous as vol
from homeassistant.components import sun from homeassistant.components import sun
import homeassistant.components.automation as automation import homeassistant.components.automation as automation
@ -288,6 +289,101 @@ async def test_and_condition_with_template(hass):
assert test(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): async def test_or_condition(hass):
"""Test the 'or' condition.""" """Test the 'or' condition."""
config = { config = {
@ -471,6 +567,36 @@ async def test_or_condition_with_template(hass):
assert test(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): async def test_not_condition(hass):
"""Test the 'not' condition.""" """Test the 'not' condition."""
config = { config = {
@ -670,6 +796,42 @@ async def test_not_condition_with_template(hass):
assert not test(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): async def test_time_window(hass):
"""Test time condition windows.""" """Test time condition windows."""
sixam = "06:00:00" sixam = "06:00:00"

View File

@ -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): async def test_shorthand_template_condition(hass, caplog):
"""Test if we can use shorthand template conditions in a script.""" """Test if we can use shorthand template conditions in a script."""
event = "test_event" event = "test_event"