From 8ae3f575dd8e0516a41b864d5c381761b7faa6cb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 5 Oct 2020 14:03:48 +0200 Subject: [PATCH] Add extended validation for script repeat/choose (#41265) --- homeassistant/components/automation/config.py | 6 +- homeassistant/helpers/script.py | 59 +++++++-- tests/helpers/test_script.py | 112 ++++++++++++++++++ 3 files changed, 164 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 2ac2b8d9354..3a296178aeb 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -10,7 +10,7 @@ from homeassistant.config import async_log_exception, config_without_domain from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform from homeassistant.helpers.condition import async_validate_condition_config -from homeassistant.helpers.script import async_validate_action_config +from homeassistant.helpers.script import async_validate_actions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.loader import IntegrationNotFound @@ -36,9 +36,7 @@ async def async_validate_config_item(hass, config, full_config=None): ] ) - config[CONF_ACTION] = await asyncio.gather( - *[async_validate_action_config(hass, action) for action in config[CONF_ACTION]] - ) + config[CONF_ACTION] = await async_validate_actions_config(hass, config[CONF_ACTION]) return config diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 05808d3bcd5..4d958fe431f 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -123,30 +123,71 @@ def make_script_schema(schema, default_script_mode, extra=vol.PREVENT_EXTRA): ) +STATIC_VALIDATION_ACTION_TYPES = ( + cv.SCRIPT_ACTION_CALL_SERVICE, + cv.SCRIPT_ACTION_DELAY, + cv.SCRIPT_ACTION_WAIT_TEMPLATE, + cv.SCRIPT_ACTION_FIRE_EVENT, + cv.SCRIPT_ACTION_ACTIVATE_SCENE, + cv.SCRIPT_ACTION_VARIABLES, +) + + +async def async_validate_actions_config( + hass: HomeAssistant, actions: List[ConfigType] +) -> List[ConfigType]: + """Validate a list of actions.""" + return await asyncio.gather( + *[async_validate_action_config(hass, action) for action in actions] + ) + + async def async_validate_action_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" action_type = cv.determine_script_action(config) - if action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: + if action_type in STATIC_VALIDATION_ACTION_TYPES: + pass + + elif action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: platform = await device_automation.async_get_device_automation_platform( hass, config[CONF_DOMAIN], "action" ) config = platform.ACTION_SCHEMA(config) # type: ignore - elif ( - action_type == cv.SCRIPT_ACTION_CHECK_CONDITION - and config[CONF_CONDITION] == "device" - ): - platform = await device_automation.async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "condition" - ) - config = platform.CONDITION_SCHEMA(config) # type: ignore + + elif action_type == cv.SCRIPT_ACTION_CHECK_CONDITION: + if config[CONF_CONDITION] == "device": + platform = await device_automation.async_get_device_automation_platform( + hass, config[CONF_DOMAIN], "condition" + ) + config = platform.CONDITION_SCHEMA(config) # type: ignore + elif action_type == cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: config[CONF_WAIT_FOR_TRIGGER] = await async_validate_trigger_config( hass, config[CONF_WAIT_FOR_TRIGGER] ) + elif action_type == cv.SCRIPT_ACTION_REPEAT: + config[CONF_SEQUENCE] = await async_validate_actions_config( + hass, config[CONF_REPEAT][CONF_SEQUENCE] + ) + + elif action_type == cv.SCRIPT_ACTION_CHOOSE: + if CONF_DEFAULT in config: + config[CONF_DEFAULT] = await async_validate_actions_config( + hass, config[CONF_DEFAULT] + ) + + for choose_conf in config[CONF_CHOOSE]: + choose_conf[CONF_SEQUENCE] = await async_validate_actions_config( + hass, choose_conf[CONF_SEQUENCE] + ) + + else: + raise ValueError(f"No validation for {action_type}") + return config diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 936866b4306..93bb249c485 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -16,6 +16,7 @@ import homeassistant.components.scene as scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import Context, CoreState, callback from homeassistant.helpers import config_validation as cv, script +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.async_mock import patch @@ -1828,3 +1829,114 @@ async def test_set_redefines_variable(hass, caplog): assert mock_calls[0].data["value"] == "1" assert mock_calls[1].data["value"] == "2" + + +async def test_validate_action_config(hass): + """Validate action config.""" + configs = { + cv.SCRIPT_ACTION_CALL_SERVICE: {"service": "light.turn_on"}, + cv.SCRIPT_ACTION_DELAY: {"delay": 5}, + cv.SCRIPT_ACTION_WAIT_TEMPLATE: { + "wait_template": "{{ states.light.kitchen.state == 'on' }}" + }, + cv.SCRIPT_ACTION_FIRE_EVENT: {"event": "my_event"}, + cv.SCRIPT_ACTION_CHECK_CONDITION: { + "condition": "{{ states.light.kitchen.state == 'on' }}" + }, + cv.SCRIPT_ACTION_DEVICE_AUTOMATION: { + "domain": "light", + "entity_id": "light.kitchen", + "device_id": "abcd", + "type": "turn_on", + }, + cv.SCRIPT_ACTION_ACTIVATE_SCENE: {"scene": "scene.relax"}, + cv.SCRIPT_ACTION_REPEAT: { + "repeat": {"count": 3, "sequence": [{"event": "repeat_event"}]} + }, + cv.SCRIPT_ACTION_CHOOSE: { + "choose": [ + { + "condition": "{{ states.light.kitchen.state == 'on' }}", + "sequence": [{"event": "choose_event"}], + } + ], + "default": [{"event": "choose_default_event"}], + }, + cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: { + "wait_for_trigger": [ + {"platform": "event", "event_type": "wait_for_trigger_event"} + ] + }, + cv.SCRIPT_ACTION_VARIABLES: {"variables": {"hello": "world"}}, + } + + for key in cv.ACTION_TYPE_SCHEMAS: + assert key in configs, f"No validate config test found for {key}" + + # Verify we raise if we don't know the action type + with patch( + "homeassistant.helpers.config_validation.determine_script_action", + return_value="non-existing", + ), pytest.raises(ValueError): + await script.async_validate_action_config(hass, {}) + + for action_type, config in configs.items(): + assert cv.determine_script_action(config) == action_type + try: + await script.async_validate_action_config(hass, config) + except vol.Invalid as err: + assert False, f"{action_type} config invalid: {err}" + + +async def test_embedded_wait_for_trigger_in_automation(hass): + """Test an embedded wait for trigger.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": { + "repeat": { + "while": [ + { + "condition": "template", + "value_template": '{{ is_state("test.value1", "trigger-while") }}', + } + ], + "sequence": [ + {"event": "trigger_wait_event"}, + { + "wait_for_trigger": [ + { + "platform": "template", + "value_template": '{{ is_state("test.value2", "trigger-wait") }}', + } + ] + }, + {"service": "test.script"}, + ], + } + }, + } + }, + ) + + hass.states.async_set("test.value1", "trigger-while") + hass.states.async_set("test.value2", "not-trigger-wait") + mock_calls = async_mock_service(hass, "test", "script") + + async def trigger_wait_event(_): + # give script the time to attach the trigger. + await asyncio.sleep(0) + hass.states.async_set("test.value1", "not-trigger-while") + hass.states.async_set("test.value2", "trigger-wait") + + hass.bus.async_listen("trigger_wait_event", trigger_wait_event) + + # Start automation + hass.bus.async_fire("test_event") + + await hass.async_block_till_done() + + assert len(mock_calls) == 1