From 9224997411e95f9c5739018878573daabe8b154a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 May 2024 09:34:49 +0200 Subject: [PATCH] Add sequence action for automations & scripts (#117690) Co-authored-by: Robert Resch --- homeassistant/helpers/config_validation.py | 7 +- homeassistant/helpers/script.py | 45 ++++++++- tests/helpers/test_script.py | 101 +++++++++++++++++++++ 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 978057180c1..a7754f9aaa8 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1783,7 +1783,7 @@ _SCRIPT_STOP_SCHEMA = vol.Schema( } ) -_SCRIPT_PARALLEL_SEQUENCE = vol.Schema( +_SCRIPT_SEQUENCE_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, @@ -1802,7 +1802,7 @@ _SCRIPT_PARALLEL_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_PARALLEL): vol.All( - ensure_list, [vol.Any(_SCRIPT_PARALLEL_SEQUENCE, _parallel_sequence_action)] + ensure_list, [vol.Any(_SCRIPT_SEQUENCE_SCHEMA, _parallel_sequence_action)] ), } ) @@ -1818,6 +1818,7 @@ SCRIPT_ACTION_FIRE_EVENT = "event" SCRIPT_ACTION_IF = "if" SCRIPT_ACTION_PARALLEL = "parallel" SCRIPT_ACTION_REPEAT = "repeat" +SCRIPT_ACTION_SEQUENCE = "sequence" SCRIPT_ACTION_SET_CONVERSATION_RESPONSE = "set_conversation_response" SCRIPT_ACTION_STOP = "stop" SCRIPT_ACTION_VARIABLES = "variables" @@ -1844,6 +1845,7 @@ ACTIONS_MAP = { CONF_SERVICE_TEMPLATE: SCRIPT_ACTION_CALL_SERVICE, CONF_STOP: SCRIPT_ACTION_STOP, CONF_PARALLEL: SCRIPT_ACTION_PARALLEL, + CONF_SEQUENCE: SCRIPT_ACTION_SEQUENCE, CONF_SET_CONVERSATION_RESPONSE: SCRIPT_ACTION_SET_CONVERSATION_RESPONSE, } @@ -1874,6 +1876,7 @@ ACTION_TYPE_SCHEMAS: dict[str, Callable[[Any], dict]] = { SCRIPT_ACTION_IF: _SCRIPT_IF_SCHEMA, SCRIPT_ACTION_PARALLEL: _SCRIPT_PARALLEL_SCHEMA, SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, + SCRIPT_ACTION_SEQUENCE: _SCRIPT_SEQUENCE_SCHEMA, SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA, SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index c268a21758f..ed0bfafd16b 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -370,6 +370,11 @@ async def async_validate_action_config( hass, parallel_conf[CONF_SEQUENCE] ) + elif action_type == cv.SCRIPT_ACTION_SEQUENCE: + config[CONF_SEQUENCE] = await async_validate_actions_config( + hass, config[CONF_SEQUENCE] + ) + else: raise ValueError(f"No validation for {action_type}") @@ -431,9 +436,7 @@ class _ScriptRun: def _log( self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any ) -> None: - self._script._log( # noqa: SLF001 - msg, *args, level=level, **kwargs - ) + self._script._log(msg, *args, level=level, **kwargs) # noqa: SLF001 def _step_log(self, default_message, timeout=None): self._script.last_action = self._action.get(CONF_ALIAS, default_message) @@ -1206,6 +1209,12 @@ class _ScriptRun: response = None raise _StopScript(stop, response) + @async_trace_path("sequence") + async def _async_sequence_step(self) -> None: + """Run a sequence.""" + sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001 + await self._async_run_script(sequence) + @async_trace_path("parallel") async def _async_parallel_step(self) -> None: """Run a sequence in parallel.""" @@ -1416,6 +1425,7 @@ class Script: self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} + self._sequence_scripts: dict[int, Script] = {} self.variables = variables self._variables_dynamic = template.is_complex(variables) if self._variables_dynamic: @@ -1942,6 +1952,35 @@ class Script: self._parallel_scripts[step] = parallel_scripts return parallel_scripts + async def _async_prep_sequence_script(self, step: int) -> Script: + """Prepare a sequence script.""" + action = self.sequence[step] + step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}") + + sequence_script = Script( + self._hass, + action[CONF_SEQUENCE], + f"{self.name}: {step_name}", + self.domain, + running_description=self.running_description, + script_mode=SCRIPT_MODE_PARALLEL, + max_runs=self.max_runs, + logger=self._logger, + top_level=False, + ) + sequence_script.change_listener = partial( + self._chain_change_listener, sequence_script + ) + + return sequence_script + + async def _async_get_sequence_script(self, step: int) -> Script: + """Get a (cached) sequence script.""" + if not (sequence_script := self._sequence_scripts.get(step)): + sequence_script = await self._async_prep_sequence_script(step) + self._sequence_scripts[step] = sequence_script + return sequence_script + def _log( self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any ) -> None: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8892eb75069..948255ccea5 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -3538,6 +3538,103 @@ async def test_if_condition_validation( ) +async def test_sequence(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test sequence action.""" + events = async_capture_events(hass, "test_event") + + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "sequence group, action 1", + "event": "test_event", + "event_data": { + "sequence": "group", + "action": "1", + "what": "{{ what }}", + }, + }, + { + "alias": "sequence group, action 2", + "event": "test_event", + "event_data": { + "sequence": "group", + "action": "2", + "what": "{{ what }}", + }, + }, + ], + }, + { + "alias": "action 2", + "event": "test_event", + "event_data": {"action": "2", "what": "{{ what }}"}, + }, + ] + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(MappingProxyType({"what": "world"}), Context()) + + assert len(events) == 3 + assert events[0].data == { + "sequence": "group", + "action": "1", + "what": "world", + } + assert events[1].data == { + "sequence": "group", + "action": "2", + "what": "world", + } + assert events[2].data == { + "action": "2", + "what": "world", + } + + assert ( + "Test Name: Sequential group: Executing step sequence group, action 1" + in caplog.text + ) + assert ( + "Test Name: Sequential group: Executing step sequence group, action 2" + in caplog.text + ) + assert "Test Name: Executing step action 2" in caplog.text + + expected_trace = { + "0": [{"variables": {"what": "world"}}], + "0/sequence/0": [ + { + "result": { + "event": "test_event", + "event_data": {"sequence": "group", "action": "1", "what": "world"}, + }, + } + ], + "0/sequence/1": [ + { + "result": { + "event": "test_event", + "event_data": {"sequence": "group", "action": "2", "what": "world"}, + }, + } + ], + "1": [ + { + "result": { + "event": "test_event", + "event_data": {"action": "2", "what": "world"}, + }, + } + ], + } + assert_action_trace(expected_trace) + + async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test parallel action.""" events = async_capture_events(hass, "test_event") @@ -5167,6 +5264,9 @@ async def test_validate_action_config( cv.SCRIPT_ACTION_PARALLEL: { "parallel": [templated_device_action("parallel_event")], }, + cv.SCRIPT_ACTION_SEQUENCE: { + "sequence": [templated_device_action("sequence_event")], + }, cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: { "set_conversation_response": "Hello world" }, @@ -5179,6 +5279,7 @@ async def test_validate_action_config( cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: None, cv.SCRIPT_ACTION_IF: None, cv.SCRIPT_ACTION_PARALLEL: None, + cv.SCRIPT_ACTION_SEQUENCE: None, } for key in cv.ACTION_TYPE_SCHEMAS: