From 32f82d480f03f89355489ddc2feee38b7b0e3153 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Apr 2024 11:24:54 -0500 Subject: [PATCH] Ensure scripts with timeouts of zero timeout immediately (#115830) --- homeassistant/helpers/script.py | 25 ++++- tests/helpers/test_script.py | 178 ++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index b4e02e0e4adf7..2b0eb90827e17 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -656,6 +656,12 @@ async def _async_wait_template_step(self): # check if condition already okay if condition.async_template(self._hass, wait_template, self._variables, False): self._variables["wait"]["completed"] = True + self._changed() + return + + if timeout == 0: + self._changed() + self._async_handle_timeout() return futures, timeout_handle, timeout_future = self._async_futures_with_timeout( @@ -1085,6 +1091,11 @@ async def _async_wait_for_trigger_step(self): self._variables["wait"] = {"remaining": timeout, "trigger": None} trace_set_result(wait=self._variables["wait"]) + if timeout == 0: + self._changed() + self._async_handle_timeout() + return + futures, timeout_handle, timeout_future = self._async_futures_with_timeout( timeout ) @@ -1115,6 +1126,14 @@ def log_cb(level, msg, **kwargs): futures, timeout_handle, timeout_future, remove_triggers ) + def _async_handle_timeout(self) -> None: + """Handle timeout.""" + self._variables["wait"]["remaining"] = 0.0 + if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): + self._log(_TIMEOUT_MSG) + trace_set_result(wait=self._variables["wait"], timeout=True) + raise _AbortScript from TimeoutError() + async def _async_wait_with_optional_timeout( self, futures: list[asyncio.Future[None]], @@ -1125,11 +1144,7 @@ async def _async_wait_with_optional_timeout( try: await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) if timeout_future and timeout_future.done(): - self._variables["wait"]["remaining"] = 0.0 - if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): - self._log(_TIMEOUT_MSG) - trace_set_result(wait=self._variables["wait"], timeout=True) - raise _AbortScript from TimeoutError() + self._async_handle_timeout() finally: if timeout_future and not timeout_future.done() and timeout_handle: timeout_handle.cancel() diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 409b3639d4303..16db9fb7b0541 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1311,6 +1311,184 @@ async def test_wait_timeout( assert_action_trace(expected_trace) +@pytest.mark.parametrize( + "timeout_param", [0, "{{ 0 }}", {"minutes": 0}, {"minutes": "{{ 0 }}"}] +) +async def test_wait_trigger_with_zero_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, timeout_param: int | str +) -> None: + """Test the wait trigger with zero timeout option.""" + event = "test_event" + events = async_capture_events(hass, event) + action = { + "wait_for_trigger": { + "platform": "state", + "entity_id": "switch.test", + "to": "off", + } + } + action["timeout"] = timeout_param + action["continue_on_timeout"] = True + sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + wait_started_flag = async_watch_for_action(script_obj, "wait") + hass.states.async_set("switch.test", "on") + hass.async_create_task(script_obj.async_run(context=Context())) + + try: + await asyncio.wait_for(wait_started_flag.wait(), 1) + except (AssertionError, TimeoutError): + await script_obj.async_stop() + raise + + assert not script_obj.is_running + assert len(events) == 1 + assert "(timeout: 0:00:00)" in caplog.text + + variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} + expected_trace = { + "0": [ + { + "result": variable_wait, + "variables": variable_wait, + } + ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], + } + assert_action_trace(expected_trace) + + +@pytest.mark.parametrize( + "timeout_param", [0, "{{ 0 }}", {"minutes": 0}, {"minutes": "{{ 0 }}"}] +) +async def test_wait_trigger_matches_with_zero_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, timeout_param: int | str +) -> None: + """Test the wait trigger that matches with zero timeout option.""" + event = "test_event" + events = async_capture_events(hass, event) + action = { + "wait_for_trigger": { + "platform": "state", + "entity_id": "switch.test", + "to": "off", + } + } + action["timeout"] = timeout_param + action["continue_on_timeout"] = True + sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + wait_started_flag = async_watch_for_action(script_obj, "wait") + hass.states.async_set("switch.test", "off") + hass.async_create_task(script_obj.async_run(context=Context())) + + try: + await asyncio.wait_for(wait_started_flag.wait(), 1) + except (AssertionError, TimeoutError): + await script_obj.async_stop() + raise + + assert not script_obj.is_running + assert len(events) == 1 + assert "(timeout: 0:00:00)" in caplog.text + + variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} + expected_trace = { + "0": [ + { + "result": variable_wait, + "variables": variable_wait, + } + ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], + } + assert_action_trace(expected_trace) + + +@pytest.mark.parametrize( + "timeout_param", [0, "{{ 0 }}", {"minutes": 0}, {"minutes": "{{ 0 }}"}] +) +async def test_wait_template_with_zero_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, timeout_param: int | str +) -> None: + """Test the wait template with zero timeout option.""" + event = "test_event" + events = async_capture_events(hass, event) + action = {"wait_template": "{{ states.switch.test.state == 'off' }}"} + action["timeout"] = timeout_param + action["continue_on_timeout"] = True + sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + wait_started_flag = async_watch_for_action(script_obj, "wait") + hass.states.async_set("switch.test", "on") + hass.async_create_task(script_obj.async_run(context=Context())) + + try: + await asyncio.wait_for(wait_started_flag.wait(), 1) + except (AssertionError, TimeoutError): + await script_obj.async_stop() + raise + + assert not script_obj.is_running + assert len(events) == 1 + assert "(timeout: 0:00:00)" in caplog.text + variable_wait = {"wait": {"completed": False, "remaining": 0.0}} + expected_trace = { + "0": [ + { + "result": variable_wait, + "variables": variable_wait, + } + ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], + } + assert_action_trace(expected_trace) + + +@pytest.mark.parametrize( + "timeout_param", [0, "{{ 0 }}", {"minutes": 0}, {"minutes": "{{ 0 }}"}] +) +async def test_wait_template_matches_with_zero_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, timeout_param: int | str +) -> None: + """Test the wait template that matches with zero timeout option.""" + event = "test_event" + events = async_capture_events(hass, event) + action = {"wait_template": "{{ states.switch.test.state == 'off' }}"} + action["timeout"] = timeout_param + action["continue_on_timeout"] = True + sequence = cv.SCRIPT_SCHEMA([action, {"event": event}]) + sequence = await script.async_validate_actions_config(hass, sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + wait_started_flag = async_watch_for_action(script_obj, "wait") + hass.states.async_set("switch.test", "off") + hass.async_create_task(script_obj.async_run(context=Context())) + + try: + await asyncio.wait_for(wait_started_flag.wait(), 1) + except (AssertionError, TimeoutError): + await script_obj.async_stop() + raise + + assert not script_obj.is_running + assert len(events) == 1 + assert "(timeout: 0:00:00)" in caplog.text + variable_wait = {"wait": {"completed": True, "remaining": 0.0}} + expected_trace = { + "0": [ + { + "result": variable_wait, + "variables": variable_wait, + } + ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], + } + assert_action_trace(expected_trace) + + @pytest.mark.parametrize( ("continue_on_timeout", "n_events"), [(False, 0), (True, 1), (None, 1)] )