diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 5d3ba863f1f..1429fa7ce7b 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -47,11 +47,7 @@ TRIGGER_SCHEMA = vol.All( vol.Optional(CONF_BELOW): vol.Coerce(float), vol.Optional(CONF_ABOVE): vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOR): vol.Any( - vol.All(cv.time_period, cv.positive_timedelta), - cv.template, - cv.template_complex, - ), + vol.Optional(CONF_FOR): cv.positive_time_period_template, } ), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), @@ -141,20 +137,9 @@ async def async_attach_trigger( } try: - if isinstance(time_delta, template.Template): - period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( - time_delta.async_render(variables) - ) - elif isinstance(time_delta, dict): - time_delta_data = {} - time_delta_data.update( - template.render_complex(time_delta, variables) - ) - period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( - time_delta_data - ) - else: - period[entity] = time_delta + period[entity] = cv.positive_time_period( + template.render_complex(time_delta, variables) + ) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error( "Error rendering '%s' for template: %s", diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 9d504d40de5..603fff5993e 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -33,11 +33,7 @@ TRIGGER_SCHEMA = vol.All( # These are str on purpose. Want to catch YAML conversions vol.Optional(CONF_FROM): vol.Any(str, [str]), vol.Optional(CONF_TO): vol.Any(str, [str]), - vol.Optional(CONF_FOR): vol.Any( - vol.All(cv.time_period, cv.positive_timedelta), - cv.template, - cv.template_complex, - ), + vol.Optional(CONF_FOR): cv.positive_time_period_template, } ), cv.key_dependency(CONF_FOR, CONF_TO), @@ -115,18 +111,9 @@ async def async_attach_trigger( } try: - if isinstance(time_delta, template.Template): - period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( - time_delta.async_render(variables) - ) - elif isinstance(time_delta, dict): - time_delta_data = {} - time_delta_data.update(template.render_complex(time_delta, variables)) - period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( - time_delta_data - ) - else: - period[entity] = time_delta + period[entity] = cv.positive_time_period( + template.render_complex(time_delta, variables) + ) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error( "Error rendering '%s' for template: %s", automation_info["name"], ex diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index ee4484410cd..f376cedd0b0 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -17,11 +17,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): "template", vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOR): vol.Any( - vol.All(cv.time_period, cv.positive_timedelta), - cv.template, - cv.template_complex, - ), + vol.Optional(CONF_FOR): cv.positive_time_period_template, } ) @@ -73,16 +69,9 @@ async def async_attach_trigger( } try: - if isinstance(time_delta, template.Template): - period = vol.All(cv.time_period, cv.positive_timedelta)( - time_delta.async_render(variables) - ) - elif isinstance(time_delta, dict): - time_delta_data = {} - time_delta_data.update(template.render_complex(time_delta, variables)) - period = vol.All(cv.time_period, cv.positive_timedelta)(time_delta_data) - else: - period = time_delta + period = cv.positive_time_period( + template.render_complex(time_delta, variables) + ) except (exceptions.TemplateError, vol.Invalid) as ex: _LOGGER.error( "Error rendering '%s' for template: %s", automation_info["name"], ex diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index be0ec93f225..31231d1ffb2 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -68,13 +68,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_SENSOR): cv.entity_id, vol.Optional(CONF_AC_MODE): cv.boolean, vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), - vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MIN_DUR): cv.positive_time_period, vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), - vol.Optional(CONF_KEEP_ALIVE): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_KEEP_ALIVE): cv.positive_time_period, vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In( [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] ), diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index 7131ac505b5..7e5df865aa0 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -56,7 +56,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_DEVICE_PORT): cv.port, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): vol.All(cv.time_period, cv.positive_timedelta), + ): cv.positive_time_period, vol.Optional(CONF_ZONES, default=DEFAULT_ZONES): vol.All( cv.ensure_list, [ZONE_SCHEMA] ), diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 6fd2dec5efd..95b7cdb3d18 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_SERVER_ID): cv.positive_int, vol.Optional( CONF_SCAN_INTERVAL, default=timedelta(minutes=DEFAULT_SCAN_INTERVAL) - ): vol.All(cv.time_period, cv.positive_timedelta), + ): cv.positive_time_period, vol.Optional(CONF_MANUAL, default=False): cv.boolean, vol.Optional( CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 22eb8b9d242..504cf82297f 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -49,8 +49,8 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_DELAY_ON): vol.All(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_DELAY_OFF): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_ON): cv.positive_time_period, + vol.Optional(CONF_DELAY_OFF): cv.positive_time_period, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index bdfe8e35c74..e2171f02290 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): vol.All(cv.time_period, cv.positive_timedelta), + ): cv.positive_time_period, } ), ) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index cc9343aa2c0..967ea8043f3 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -102,7 +102,7 @@ SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend( ) SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_TIME_PERIOD): vol.All(cv.time_period, cv.positive_timedelta)} + {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period} ) SERVICE_TO_METHOD = { diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e5b113f8a4d..5d883dc8a05 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -402,6 +402,7 @@ def positive_timedelta(value: timedelta) -> timedelta: positive_time_period_dict = vol.All(time_period_dict, positive_timedelta) +positive_time_period = vol.All(time_period, positive_timedelta) def remove_falsy(value: List[T]) -> List[T]: @@ -530,6 +531,11 @@ def template_complex(value: Any) -> Any: return value +positive_time_period_template = vol.Any( + positive_time_period, template, template_complex +) + + def datetime(value: Any) -> datetime_sys: """Validate datetime.""" if isinstance(value, datetime_sys): @@ -876,7 +882,7 @@ STATE_CONDITION_SCHEMA = vol.All( vol.Required(CONF_CONDITION): "state", vol.Required(CONF_ENTITY_ID): entity_ids, vol.Required(CONF_STATE): vol.Any(str, [str]), - vol.Optional(CONF_FOR): vol.All(time_period, positive_timedelta), + vol.Optional(CONF_FOR): positive_time_period, # To support use_trigger_value in automation # Deprecated 2016/04/25 vol.Optional("from"): str, @@ -992,9 +998,7 @@ CONDITION_SCHEMA: vol.Schema = key_value_schemas( _SCRIPT_DELAY_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): string, - vol.Required(CONF_DELAY): vol.Any( - vol.All(time_period, positive_timedelta), template, template_complex - ), + vol.Required(CONF_DELAY): positive_time_period_template, } ) @@ -1002,7 +1006,7 @@ _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): string, vol.Required(CONF_WAIT_TEMPLATE): template, - vol.Optional(CONF_TIMEOUT): vol.All(time_period, positive_timedelta), + vol.Optional(CONF_TIMEOUT): positive_time_period_template, vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean, } ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 9b2d9a9a990..9f415b10300 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,6 +1,6 @@ """Helpers to execute scripts.""" import asyncio -from datetime import datetime +from datetime import datetime, timedelta from functools import partial import itertools import logging @@ -241,21 +241,25 @@ class _ScriptRun: level=level, ) - async def _async_delay_step(self): - """Handle delay.""" + def _get_pos_time_period_template(self, key): try: - delay = vol.All(cv.time_period, cv.positive_timedelta)( - template.render_complex(self._action[CONF_DELAY], self._variables) + return cv.positive_time_period( + template.render_complex(self._action[key], self._variables) ) except (exceptions.TemplateError, vol.Invalid) as ex: self._log( - "Error rendering %s delay template: %s", + "Error rendering %s %s template: %s", self._script.name, + key, ex, level=logging.ERROR, ) raise _StopScript + async def _async_delay_step(self): + """Handle delay.""" + delay = self._get_pos_time_period_template(CONF_DELAY) + self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}") self._log("Executing step %s", self._script.last_action) @@ -269,41 +273,55 @@ class _ScriptRun: async def _async_wait_template_step(self): """Handle a wait template.""" + if CONF_TIMEOUT in self._action: + delay = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() + else: + delay = None + self._script.last_action = self._action.get(CONF_ALIAS, "wait template") - self._log("Executing step %s", self._script.last_action) + self._log( + "Executing step %s%s", + self._script.last_action, + "" if delay is None else f" (timeout: {timedelta(seconds=delay)})", + ) + + self._variables["wait"] = {"remaining": delay, "completed": False} wait_template = self._action[CONF_WAIT_TEMPLATE] wait_template.hass = self._hass # check if condition already okay if condition.async_template(self._hass, wait_template, self._variables): + self._variables["wait"]["completed"] = True return @callback def async_script_wait(entity_id, from_s, to_s): """Handle script after template condition is true.""" + self._variables["wait"] = { + "remaining": to_context.remaining if to_context else delay, + "completed": True, + } done.set() + to_context = None unsub = async_track_template( self._hass, wait_template, async_script_wait, self._variables ) self._changed() - try: - delay = self._action[CONF_TIMEOUT].total_seconds() - except KeyError: - delay = None done = asyncio.Event() tasks = [ self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with timeout(delay): + async with timeout(delay) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError: if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._log(_TIMEOUT_MSG) raise _StopScript + self._variables["wait"]["remaining"] = 0.0 finally: for task in tasks: task.cancel() diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 968826dd2d5..305f11b0258 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -16,7 +16,6 @@ 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.helpers.event import async_call_later import homeassistant.util.dt as dt_util from tests.async_mock import patch @@ -29,49 +28,6 @@ from tests.common import ( ENTITY_ID = "script.test" -@pytest.fixture -def mock_timeout(hass, monkeypatch): - """Mock async_timeout.timeout.""" - - class MockTimeout: - def __init__(self, timeout): - self._timeout = timeout - self._loop = asyncio.get_event_loop() - self._task = None - self._cancelled = False - self._unsub = None - - async def __aenter__(self): - if self._timeout is None: - return self - self._task = asyncio.Task.current_task() - if self._timeout <= 0: - self._loop.call_soon(self._cancel_task) - return self - # Wait for a time_changed event instead of real time passing. - self._unsub = async_call_later(hass, self._timeout, self._cancel_task) - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - if exc_type is asyncio.CancelledError and self._cancelled: - self._unsub = None - self._task = None - raise asyncio.TimeoutError - if self._timeout is not None and self._unsub: - self._unsub() - self._unsub = None - self._task = None - return None - - @callback - def _cancel_task(self, now=None): - if self._task is not None: - self._task.cancel() - self._cancelled = True - - monkeypatch.setattr(script, "timeout", MockTimeout) - - def async_watch_for_action(script_obj, message): """Watch for message in last_action.""" flag = asyncio.Event() @@ -326,7 +282,7 @@ async def test_stop_no_wait(hass, count): assert len(events) == 0 -async def test_delay_basic(hass, mock_timeout): +async def test_delay_basic(hass): """Test the delay.""" delay_alias = "delay step" sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 5}, "alias": delay_alias}) @@ -350,7 +306,7 @@ async def test_delay_basic(hass, mock_timeout): assert script_obj.last_action is None -async def test_multiple_runs_delay(hass, mock_timeout): +async def test_multiple_runs_delay(hass): """Test multiple runs with delay in script.""" event = "test_event" events = async_capture_events(hass, event) @@ -393,7 +349,7 @@ async def test_multiple_runs_delay(hass, mock_timeout): assert events[-1].data["value"] == 2 -async def test_delay_template_ok(hass, mock_timeout): +async def test_delay_template_ok(hass): """Test the delay as a template.""" sequence = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 5 }}"}) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") @@ -441,7 +397,7 @@ async def test_delay_template_invalid(hass, caplog): assert len(events) == 1 -async def test_delay_template_complex_ok(hass, mock_timeout): +async def test_delay_template_complex_ok(hass): """Test the delay with a working complex template.""" sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": "{{ 5 }}"}}) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") @@ -647,11 +603,56 @@ async def test_wait_template_not_schedule(hass): assert len(events) == 2 +@pytest.mark.parametrize( + "timeout_param", [5, "{{ 5 }}", {"seconds": 5}, {"seconds": "{{ 5 }}"}] +) +async def test_wait_template_timeout(hass, caplog, timeout_param): + """Test the wait timeout option.""" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + [ + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "timeout": timeout_param, + "continue_on_timeout": True, + }, + {"event": event}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + wait_started_flag = async_watch_for_action(script_obj, "wait") + + try: + hass.states.async_set("switch.test", "on") + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + cur_time = dt_util.utcnow() + async_fire_time_changed(hass, cur_time + timedelta(seconds=4)) + await asyncio.sleep(0) + + assert len(events) == 0 + + async_fire_time_changed(hass, cur_time + timedelta(seconds=5)) + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 1 + assert "(timeout: 0:00:05)" in caplog.text + + @pytest.mark.parametrize( "continue_on_timeout,n_events", [(False, 0), (True, 1), (None, 1)] ) -async def test_wait_template_timeout(hass, mock_timeout, continue_on_timeout, n_events): - """Test the wait template, halt on timeout.""" +async def test_wait_template_continue_on_timeout(hass, continue_on_timeout, n_events): + """Test the wait template continue_on_timeout option.""" event = "test_event" events = async_capture_events(hass, event) sequence = [ @@ -682,8 +683,8 @@ async def test_wait_template_timeout(hass, mock_timeout, continue_on_timeout, n_ assert len(events) == n_events -async def test_wait_template_variables(hass): - """Test the wait template with variables.""" +async def test_wait_template_variables_in(hass): + """Test the wait template with input variables.""" sequence = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"}) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") wait_started_flag = async_watch_for_action(script_obj, "wait") @@ -706,6 +707,58 @@ async def test_wait_template_variables(hass): assert not script_obj.is_running +@pytest.mark.parametrize("mode", ["no_timeout", "timeout_finish", "timeout_not_finish"]) +async def test_wait_template_variables_out(hass, mode): + """Test the wait template output variable.""" + event = "test_event" + events = async_capture_events(hass, event) + action = {"wait_template": "{{ states.switch.test.state == 'off' }}"} + if mode != "no_timeout": + action["timeout"] = 5 + action["continue_on_timeout"] = True + sequence = [ + action, + { + "event": event, + "event_data_template": { + "completed": "{{ wait.completed }}", + "remaining": "{{ wait.remaining }}", + }, + }, + ] + sequence = cv.SCRIPT_SCHEMA(sequence) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + wait_started_flag = async_watch_for_action(script_obj, "wait") + + try: + hass.states.async_set("switch.test", "on") + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if mode == "timeout_not_finish": + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 1 + assert events[0].data["completed"] == str(mode != "timeout_not_finish") + remaining = events[0].data["remaining"] + if mode == "no_timeout": + assert remaining == "None" + elif mode == "timeout_finish": + assert 0.0 < float(remaining) < 5 + else: + assert float(remaining) == 0.0 + + async def test_condition_basic(hass): """Test if we can use conditions in a script.""" event = "test_event"