From 4198c427362f8e6373a0b71b188c9638f7e54736 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Sep 2016 21:38:39 -0700 Subject: [PATCH 01/10] Have template platforms never leave the event loop --- .../components/binary_sensor/template.py | 9 +++++--- homeassistant/components/sensor/template.py | 9 +++++--- homeassistant/components/switch/template.py | 9 +++++--- homeassistant/helpers/entity.py | 21 ++++++++++++----- .../components/binary_sensor/test_template.py | 2 +- tests/helpers/test_entity.py | 23 ++++++++++++++++++- 6 files changed, 56 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 662a6982a11..85c9f0e8950 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -4,6 +4,7 @@ Support for exposing a templated binary sensor. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.template/ """ +import asyncio import logging import voluptuous as vol @@ -81,9 +82,10 @@ class BinarySensorTemplate(BinarySensorDevice): self.update() + @asyncio.coroutine def template_bsensor_state_listener(entity, old_state, new_state): """Called when the target device changes state.""" - self.update_ha_state(True) + yield from self.async_update_ha_state(True) track_state_change(hass, entity_ids, template_bsensor_state_listener) @@ -107,10 +109,11 @@ class BinarySensorTemplate(BinarySensorDevice): """No polling needed.""" return False - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data and update the state.""" try: - self._state = self._template.render().lower() == 'true' + self._state = self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index c7c94aeaf9e..4b6f322b5aa 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -4,6 +4,7 @@ Allows the creation of a sensor that breaks out state_attributes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.template/ """ +import asyncio import logging import voluptuous as vol @@ -78,9 +79,10 @@ class SensorTemplate(Entity): self.update() + @asyncio.coroutine def template_sensor_state_listener(entity, old_state, new_state): """Called when the target device changes state.""" - self.update_ha_state(True) + yield from self.async_update_ha_state(True) track_state_change(hass, entity_ids, template_sensor_state_listener) @@ -104,10 +106,11 @@ class SensorTemplate(Entity): """No polling needed.""" return False - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data and update the states.""" try: - self._state = self._template.render() + self._state = self._template.async_render() except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 5358a23d8c6..7c6f4f5886d 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -4,6 +4,7 @@ Support for switches which integrates with other components. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.template/ """ +import asyncio import logging import voluptuous as vol @@ -87,9 +88,10 @@ class SwitchTemplate(SwitchDevice): self.update() + @asyncio.coroutine def template_switch_state_listener(entity, old_state, new_state): """Called when the target device changes state.""" - self.update_ha_state(True) + yield from self.async_update_ha_state(True) track_state_change(hass, entity_ids, template_switch_state_listener) @@ -121,10 +123,11 @@ class SwitchTemplate(SwitchDevice): """Fire the off action.""" self._off_script.run() - def update(self): + @asyncio.coroutine + def async_update(self): """Update the state from the template.""" try: - state = self._template.render().lower() + state = self._template.async_render().lower() if state in _VALID_STATES: self._state = state in ('true', STATE_ON) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7529d6288ab..3c119eb456e 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -49,6 +49,11 @@ class Entity(object): # SAFE TO OVERWRITE # The properties and methods here are safe to overwrite when inheriting # this class. These may be used to customize the behavior of the entity. + entity_id = None # type: str + + # Owning hass instance. Will be set by EntityComponent + hass = None # type: Optional[HomeAssistant] + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -128,18 +133,22 @@ class Entity(object): return False def update(self): - """Retrieve latest state.""" - pass + """Retrieve latest state. - entity_id = None # type: str + When not implemented, will forward call to async version if available. + """ + async_update = getattr(self, 'async_update', None) + + if async_update is None: + return + + run_coroutine_threadsafe(async_update(), self.hass.loop).result() # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they # are used to perform a very specific function. Overwriting these may # produce undesirable effects in the entity's operation. - hass = None # type: Optional[HomeAssistant] - def update_ha_state(self, force_refresh=False): """Update Home Assistant with current state of entity. @@ -172,7 +181,7 @@ class Entity(object): if force_refresh: if hasattr(self, 'async_update'): # pylint: disable=no-member - self.async_update() + yield from self.async_update() else: # PS: Run this in our own thread pool once we have # future support? diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 7337bd4de03..c9e4bf6138b 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -119,7 +119,7 @@ class TestBinarySensorTemplate(unittest.TestCase): vs.update_ha_state() self.hass.block_till_done() - with mock.patch.object(vs, 'update') as mock_update: + with mock.patch.object(vs, 'async_update') as mock_update: self.hass.bus.fire(EVENT_STATE_CHANGED) self.hass.block_till_done() assert mock_update.call_count == 1 diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 81ef17ff0fd..f63e80ec1f9 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -53,7 +53,12 @@ def test_async_update_support(event_loop): assert len(sync_update) == 1 assert len(async_update) == 0 - ent.async_update = lambda: async_update.append(1) + @asyncio.coroutine + def async_update_func(): + """Async update.""" + async_update.append(1) + + ent.async_update = async_update_func event_loop.run_until_complete(test()) @@ -95,3 +100,19 @@ class TestHelpersEntity(object): assert entity.generate_entity_id( fmt, 'overwrite hidden true', hass=self.hass) == 'test.overwrite_hidden_true_2' + + def test_update_calls_async_update_if_available(self): + """Test async update getting called.""" + async_update = [] + + class AsyncEntity(entity.Entity): + hass = self.hass + entity_id = 'sensor.test' + + @asyncio.coroutine + def async_update(self): + async_update.append([1]) + + ent = AsyncEntity() + ent.update() + assert len(async_update) == 1 From 33a51623f893d0b2aa5d1e99fbaebaa538bd87a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Sep 2016 22:34:45 -0700 Subject: [PATCH 02/10] Make Service.call_from_config async --- homeassistant/helpers/service.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 665e22404c6..06df2eb992d 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,4 +1,5 @@ """Service calling related helpers.""" +import asyncio import functools import logging # pylint: disable=unused-import @@ -11,6 +12,7 @@ from homeassistant.core import HomeAssistant # NOQA from homeassistant.exceptions import TemplateError from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv +from homeassistant.util.async import run_coroutine_threadsafe HASS = None # type: Optional[HomeAssistant] @@ -37,6 +39,15 @@ def service(domain, service_name): def call_from_config(hass, config, blocking=False, variables=None, validate_config=True): """Call a service based on a config hash.""" + run_coroutine_threadsafe( + async_call_from_config(hass, config, blocking, variables, + validate_config), hass.loop).result() + + +@asyncio.coroutine +def async_call_from_config(hass, config, blocking=False, variables=None, + validate_config=True): + """Call a service based on a config hash.""" if validate_config: try: config = cv.SERVICE_SCHEMA(config) @@ -49,7 +60,8 @@ def call_from_config(hass, config, blocking=False, variables=None, else: try: config[CONF_SERVICE_TEMPLATE].hass = hass - domain_service = config[CONF_SERVICE_TEMPLATE].render(variables) + domain_service = config[CONF_SERVICE_TEMPLATE].async_render( + variables) domain_service = cv.service(domain_service) except TemplateError as ex: _LOGGER.error('Error rendering service name template: %s', ex) @@ -71,14 +83,15 @@ def call_from_config(hass, config, blocking=False, variables=None, return {key: _data_template_creator(item) for key, item in value.items()} value.hass = hass - return value.render(variables) + return value.async_render(variables) service_data.update(_data_template_creator( config[CONF_SERVICE_DATA_TEMPLATE])) if CONF_SERVICE_ENTITY_ID in config: service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] - hass.services.call(domain, service_name, service_data, blocking) + yield from hass.services.async_call( + domain, service_name, service_data, blocking) def extract_entity_ids(hass, service_call): From 185bd6c28a5c8abbfa62d7eeda43c135e012d96d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Sep 2016 23:11:57 -0700 Subject: [PATCH 03/10] Make helpers.condition.* async --- homeassistant/helpers/condition.py | 101 ++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 041f514aeda..ae1dc471706 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -20,15 +20,43 @@ import homeassistant.util.dt as dt_util from homeassistant.util.async import run_callback_threadsafe FROM_CONFIG_FORMAT = '{}_from_config' +ASYNC_FROM_CONFIG_FORMAT = 'async_{}_from_config' _LOGGER = logging.getLogger(__name__) +# PyLint does not like the use of _threaded_factory +# pylint: disable=invalid-name -def from_config(config: ConfigType, config_validation: bool=True): - """Turn a condition configuration into a method.""" - factory = getattr( - sys.modules[__name__], - FROM_CONFIG_FORMAT.format(config.get(CONF_CONDITION)), None) + +def _threaded_factory(async_factory): + """Helper method to create threaded versions of async factories.""" + def factory(config, config_validation=True): + """Threaded factory.""" + async_check = async_factory(config, config_validation) + + def condition_if(hass, variables=None): + """Validate condition.""" + return run_callback_threadsafe( + hass.loop, async_check, hass, variables, + ).result() + + return condition_if + + return factory + + +def async_from_config(config: ConfigType, config_validation: bool=True): + """Turn a condition configuration into a method. + + Should be run on the event loop. + """ + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr( + sys.modules[__name__], + fmt.format(config.get(CONF_CONDITION)), None) + + if factory: + break if factory is None: raise HomeAssistantError('Invalid condition "{}" specified {}'.format( @@ -37,49 +65,70 @@ def from_config(config: ConfigType, config_validation: bool=True): return factory(config, config_validation) -def and_from_config(config: ConfigType, config_validation: bool=True): +from_config = _threaded_factory(async_from_config) + + +def async_and_from_config(config: ConfigType, config_validation: bool=True): """Create multi condition matcher using 'AND'.""" if config_validation: config = cv.AND_CONDITION_SCHEMA(config) - checks = [from_config(entry, False) for entry in config['conditions']] + checks = None def if_and_condition(hass: HomeAssistant, variables=None) -> bool: """Test and condition.""" - for check in checks: - try: + nonlocal checks + + if checks is None: + checks = [async_from_config(entry, False) for entry + in config['conditions']] + + try: + for check in checks: if not check(hass, variables): return False - except Exception as ex: # pylint: disable=broad-except - _LOGGER.warning('Error during and-condition: %s', ex) - return False + except Exception as ex: # pylint: disable=broad-except + _LOGGER.warning('Error during and-condition: %s', ex) + return False return True return if_and_condition -def or_from_config(config: ConfigType, config_validation: bool=True): +and_from_config = _threaded_factory(async_and_from_config) + + +def async_or_from_config(config: ConfigType, config_validation: bool=True): """Create multi condition matcher using 'OR'.""" if config_validation: config = cv.OR_CONDITION_SCHEMA(config) - checks = [from_config(entry, False) for entry in config['conditions']] + checks = None def if_or_condition(hass: HomeAssistant, variables=None) -> bool: """Test and condition.""" - for check in checks: - try: + nonlocal checks + + if checks is None: + checks = [async_from_config(entry, False) for entry + in config['conditions']] + + try: + for check in checks: if check(hass, variables): return True - except Exception as ex: # pylint: disable=broad-except - _LOGGER.warning('Error during or-condition: %s', ex) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.warning('Error during or-condition: %s', ex) return False return if_or_condition +or_from_config = _threaded_factory(async_or_from_config) + + # pylint: disable=too-many-arguments def numeric_state(hass: HomeAssistant, entity, below=None, above=None, value_template=None, variables=None): @@ -125,7 +174,7 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, return True -def numeric_state_from_config(config, config_validation=True): +def async_numeric_state_from_config(config, config_validation=True): """Wrap action method with state based condition.""" if config_validation: config = cv.NUMERIC_STATE_CONDITION_SCHEMA(config) @@ -139,12 +188,15 @@ def numeric_state_from_config(config, config_validation=True): if value_template is not None: value_template.hass = hass - return numeric_state(hass, entity_id, below, above, value_template, - variables) + return async_numeric_state( + hass, entity_id, below, above, value_template, variables) return if_numeric_state +numeric_state_from_config = _threaded_factory(async_numeric_state_from_config) + + def state(hass, entity, req_state, for_period=None): """Test if state matches requirements.""" if isinstance(entity, str): @@ -235,7 +287,7 @@ def async_template(hass, value_template, variables=None): return value.lower() == 'true' -def template_from_config(config, config_validation=True): +def async_template_from_config(config, config_validation=True): """Wrap action method with state based condition.""" if config_validation: config = cv.TEMPLATE_CONDITION_SCHEMA(config) @@ -245,11 +297,14 @@ def template_from_config(config, config_validation=True): """Validate template based if-condition.""" value_template.hass = hass - return template(hass, value_template, variables) + return async_template(hass, value_template, variables) return template_if +template_from_config = _threaded_factory(async_template_from_config) + + def time(before=None, after=None, weekday=None): """Test if local time condition matches. From b8504f8fc8ff7767de62ed1b6db56694b3e2c56e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Sep 2016 23:26:01 -0700 Subject: [PATCH 04/10] Make helpers.script async --- homeassistant/helpers/script.py | 141 ++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 60 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 071326bf973..1bfe7d550ad 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,6 +1,6 @@ """Helpers to execute scripts.""" +import asyncio import logging -import threading from itertools import islice from typing import Optional, Sequence @@ -10,9 +10,11 @@ from homeassistant.core import HomeAssistant from homeassistant.const import CONF_CONDITION from homeassistant.helpers import ( service, condition, template, config_validation as cv) -from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as date_util +from homeassistant.util.async import ( + run_coroutine_threadsafe, run_callback_threadsafe) _LOGGER = logging.getLogger(__name__) @@ -47,8 +49,7 @@ class Script(): self.last_action = None self.can_cancel = any(CONF_DELAY in action for action in self.sequence) - self._lock = threading.Lock() - self._unsub_delay_listener = None + self._async_unsub_delay_listener = None self._template_cache = {} @property @@ -56,94 +57,107 @@ class Script(): """Return true if script is on.""" return self._cur != -1 - def run(self, variables: Optional[Sequence]=None) -> None: + def run(self, variables=None): """Run script.""" - with self._lock: - if self._cur == -1: - self._log('Running script') - self._cur = 0 + run_coroutine_threadsafe( + self.async_run(variables), self.hass.loop).result() - # Unregister callback if we were in a delay but turn on is called - # again. In that case we just continue execution. - self._remove_listener() + @asyncio.coroutine + def async_run(self, variables: Optional[Sequence]=None) -> None: + """Run script. - for cur, action in islice(enumerate(self.sequence), self._cur, - None): + Returns a coroutine. + """ + if self._cur == -1: + self._log('Running script') + self._cur = 0 - if CONF_DELAY in action: - # Call ourselves in the future to continue work - def script_delay(now): - """Called after delay is done.""" - self._unsub_delay_listener = None - self.run(variables) + # Unregister callback if we were in a delay but turn on is called + # again. In that case we just continue execution. + self._async_remove_listener() - delay = action[CONF_DELAY] + for cur, action in islice(enumerate(self.sequence), self._cur, + None): - if isinstance(delay, template.Template): - delay = vol.All( - cv.time_period, - cv.positive_timedelta)( - delay.render()) + if CONF_DELAY in action: + # Call ourselves in the future to continue work + @asyncio.coroutine + def script_delay(now): + """Called after delay is done.""" + self._async_unsub_delay_listener = None + yield from self.async_run(variables) - self._unsub_delay_listener = track_point_in_utc_time( + delay = action[CONF_DELAY] + + if isinstance(delay, template.Template): + delay = vol.All( + cv.time_period, + cv.positive_timedelta)( + delay.async_render()) + + self._async_unsub_delay_listener = \ + async_track_point_in_utc_time( self.hass, script_delay, date_util.utcnow() + delay) - self._cur = cur + 1 - if self._change_listener: - self._change_listener() - return + self._cur = cur + 1 + self._trigger_change_listener() + return - elif CONF_CONDITION in action: - if not self._check_condition(action, variables): - break + elif CONF_CONDITION in action: + if not self._async_check_condition(action, variables): + break - elif CONF_EVENT in action: - self._fire_event(action) + elif CONF_EVENT in action: + self._async_fire_event(action) - else: - self._call_service(action, variables) + else: + yield from self._async_call_service(action, variables) - self._cur = -1 - self.last_action = None - if self._change_listener: - self._change_listener() + self._cur = -1 + self.last_action = None + self._trigger_change_listener() def stop(self) -> None: """Stop running script.""" - with self._lock: - if self._cur == -1: - return + run_callback_threadsafe(self.hass.loop, self.async_stop).result() - self._cur = -1 - self._remove_listener() - if self._change_listener: - self._change_listener() + def async_stop(self) -> None: + """Stop running script.""" + if self._cur == -1: + return - def _call_service(self, action, variables): + self._cur = -1 + self._async_remove_listener() + self._trigger_change_listener() + + @asyncio.coroutine + def _async_call_service(self, action, variables): """Call the service specified in the action.""" self.last_action = action.get(CONF_ALIAS, 'call service') self._log("Executing step %s" % self.last_action) - service.call_from_config(self.hass, action, True, variables, - validate_config=False) + yield from service.async_call_from_config( + self.hass, action, True, variables, validate_config=False) - def _fire_event(self, action): + def _async_fire_event(self, action): """Fire an event.""" self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) self._log("Executing step %s" % self.last_action) - self.hass.bus.fire(action[CONF_EVENT], action.get(CONF_EVENT_DATA)) + self.hass.bus.async_fire(action[CONF_EVENT], + action.get(CONF_EVENT_DATA)) - def _check_condition(self, action, variables): + def _async_check_condition(self, action, variables): """Test if condition is matching.""" self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) - check = condition.from_config(action, False)(self.hass, variables) + check = condition.async_from_config(action, False)( + self.hass, variables) self._log("Test condition {}: {}".format(self.last_action, check)) return check - def _remove_listener(self): + def _async_remove_listener(self): """Remove point in time listener, if any.""" - if self._unsub_delay_listener: - self._unsub_delay_listener() - self._unsub_delay_listener = None + if self._async_unsub_delay_listener: + self._async_unsub_delay_listener() + self._async_unsub_delay_listener = None def _log(self, msg): """Logger helper.""" @@ -151,3 +165,10 @@ class Script(): msg = "Script {}: {}".format(self.name, msg) _LOGGER.info(msg) + + def _trigger_change_listener(self): + """Trigger the change listener.""" + if not self._change_listener: + return + + self.hass.async_add_job(self._change_listener) From 16ff68ca84930da572fce6c205438815812449de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Oct 2016 00:39:07 -0700 Subject: [PATCH 05/10] Add mqtt.async_subscribe --- homeassistant/components/mqtt/__init__.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 01956a85c36..86896e8309e 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -21,6 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_VALUE_TEMPLATE) +from homeassistant.util.async import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -165,6 +166,18 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None): def subscribe(hass, topic, callback, qos=DEFAULT_QOS): + """Subscribe to an MQTT topic.""" + async_remove = run_callback_threadsafe( + hass.loop, async_subscribe, hass, topic, callback, qos).result() + + def remove_mqtt(): + """Remove MQTT subscription.""" + run_callback_threadsafe(hass.loop, async_remove).result() + + return remove_mqtt + + +def async_subscribe(hass, topic, callback, qos=DEFAULT_QOS): """Subscribe to an MQTT topic.""" @asyncio.coroutine def mqtt_topic_subscriber(event): @@ -181,13 +194,13 @@ def subscribe(hass, topic, callback, qos=DEFAULT_QOS): event.data[ATTR_PAYLOAD], event.data[ATTR_QOS], priority=JobPriority.EVENT_CALLBACK) - remove = hass.bus.listen(EVENT_MQTT_MESSAGE_RECEIVED, - mqtt_topic_subscriber) + async_remove = hass.bus.async_listen(EVENT_MQTT_MESSAGE_RECEIVED, + mqtt_topic_subscriber) # Future: track subscriber count and unsubscribe in remove MQTT_CLIENT.subscribe(topic, qos) - return remove + return async_remove def _setup_server(hass, config): From 7ab7edd81c5d4428113528b6e00a105434e24645 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Oct 2016 01:22:13 -0700 Subject: [PATCH 06/10] Make automation async --- .../components/automation/__init__.py | 208 +++++++----------- homeassistant/components/automation/event.py | 4 +- homeassistant/components/automation/mqtt.py | 4 +- .../components/automation/numeric_state.py | 6 +- homeassistant/components/automation/state.py | 9 +- homeassistant/components/automation/sun.py | 8 +- .../components/automation/template.py | 8 +- homeassistant/components/automation/time.py | 8 +- homeassistant/components/automation/zone.py | 8 +- homeassistant/helpers/condition.py | 2 + homeassistant/helpers/entity.py | 9 +- homeassistant/helpers/event.py | 107 +++++---- tests/components/automation/test_init.py | 140 ++---------- .../automation/test_numeric_state.py | 2 +- tests/components/automation/test_state.py | 4 +- tests/components/automation/test_sun.py | 12 +- tests/components/automation/test_template.py | 2 +- tests/components/automation/test_time.py | 8 +- tests/components/automation/test_zone.py | 2 +- 19 files changed, 205 insertions(+), 346 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b65516130d9..37102cd3863 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -4,6 +4,7 @@ Allow to setup simple automation rules via the config file. For more details about this component, please refer to the documentation at https://home-assistant.io/components/automation/ """ +import asyncio from functools import partial import logging import os @@ -23,6 +24,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import get_platform from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv +from homeassistant.util.async import run_coroutine_threadsafe DOMAIN = 'automation' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -44,9 +46,6 @@ CONDITION_TYPE_OR = 'or' DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND DEFAULT_HIDE_ENTITY = False -METHOD_TRIGGER = 'trigger' -METHOD_IF_ACTION = 'if_action' - ATTR_LAST_TRIGGERED = 'last_triggered' ATTR_VARIABLES = 'variables' SERVICE_TRIGGER = 'trigger' @@ -55,21 +54,14 @@ SERVICE_RELOAD = 'reload' _LOGGER = logging.getLogger(__name__) -def _platform_validator(method, schema): - """Generate platform validator for different steps.""" - def validator(config): - """Validate it is a valid platform.""" - platform = get_platform(DOMAIN, config[CONF_PLATFORM]) +def _platform_validator(config): + """Validate it is a valid platform.""" + platform = get_platform(DOMAIN, config[CONF_PLATFORM]) - if not hasattr(platform, method): - raise vol.Invalid('invalid method platform') + if not hasattr(platform, 'TRIGGER_SCHEMA'): + return config - if not hasattr(platform, schema): - return config - - return getattr(platform, schema)(config) - - return validator + return getattr(platform, 'TRIGGER_SCHEMA')(config) _TRIGGER_SCHEMA = vol.All( cv.ensure_list, @@ -78,33 +70,17 @@ _TRIGGER_SCHEMA = vol.All( vol.Schema({ vol.Required(CONF_PLATFORM): cv.platform_validator(DOMAIN) }, extra=vol.ALLOW_EXTRA), - _platform_validator(METHOD_TRIGGER, 'TRIGGER_SCHEMA') + _platform_validator ), ] ) -_CONDITION_SCHEMA = vol.Any( - CONDITION_USE_TRIGGER_VALUES, - vol.All( - cv.ensure_list, - [ - vol.All( - vol.Schema({ - CONF_PLATFORM: str, - CONF_CONDITION: str, - }, extra=vol.ALLOW_EXTRA), - cv.has_at_least_one_key(CONF_PLATFORM, CONF_CONDITION), - ), - ] - ) -) +_CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.Schema({ CONF_ALIAS: cv.string, vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, - vol.Required(CONF_CONDITION_TYPE, default=DEFAULT_CONDITION_TYPE): - vol.All(vol.Lower, vol.Any(CONDITION_TYPE_AND, CONDITION_TYPE_OR)), vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, }) @@ -165,7 +141,8 @@ def setup(hass, config): """Setup the automation.""" component = EntityComponent(_LOGGER, DOMAIN, hass) - success = _process_config(hass, config, component) + success = run_coroutine_threadsafe( + _async_process_config(hass, config, component), hass.loop).result() if not success: return False @@ -173,22 +150,27 @@ def setup(hass, config): descriptions = conf_util.load_yaml_config_file( os.path.join(os.path.dirname(__file__), 'services.yaml')) + @asyncio.coroutine def trigger_service_handler(service_call): """Handle automation triggers.""" for entity in component.extract_from_service(service_call): - entity.trigger(service_call.data.get(ATTR_VARIABLES)) + yield from entity.async_trigger( + service_call.data.get(ATTR_VARIABLES)) + @asyncio.coroutine def service_handler(service_call): """Handle automation service calls.""" + method = 'async_{}'.format(service_call.service) for entity in component.extract_from_service(service_call): - getattr(entity, service_call.service)() + yield from getattr(entity, method)() def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" conf = component.prepare_reload() if conf is None: return - _process_config(hass, conf, component) + run_coroutine_threadsafe( + _async_process_config(hass, conf, component), hass.loop).result() hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler, descriptions.get(SERVICE_TRIGGER), @@ -209,14 +191,16 @@ def setup(hass, config): class AutomationEntity(ToggleEntity): """Entity to show status of entity.""" + # pylint: disable=abstract-method # pylint: disable=too-many-arguments, too-many-instance-attributes - def __init__(self, name, attach_triggers, cond_func, action, hidden): + def __init__(self, name, async_attach_triggers, cond_func, async_action, + hidden): """Initialize an automation entity.""" self._name = name - self._attach_triggers = attach_triggers - self._detach_triggers = attach_triggers(self.trigger) + self._async_attach_triggers = async_attach_triggers + self._async_detach_triggers = async_attach_triggers(self.async_trigger) self._cond_func = cond_func - self._action = action + self._async_action = async_action self._enabled = True self._last_triggered = None self._hidden = hidden @@ -248,39 +232,53 @@ class AutomationEntity(ToggleEntity): """Return True if entity is on.""" return self._enabled - def turn_on(self, **kwargs) -> None: + @asyncio.coroutine + def async_turn_on(self, **kwargs) -> None: """Turn the entity on.""" if self._enabled: return - self._detach_triggers = self._attach_triggers(self.trigger) + self._async_detach_triggers = self._async_attach_triggers( + self.async_trigger) self._enabled = True - self.update_ha_state() + yield from self.async_update_ha_state() - def turn_off(self, **kwargs) -> None: + @asyncio.coroutine + def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" if not self._enabled: return - self._detach_triggers() - self._detach_triggers = None + self._async_detach_triggers() + self._async_detach_triggers = None self._enabled = False - self.update_ha_state() + yield from self.async_update_ha_state() - def trigger(self, variables): + @asyncio.coroutine + def async_toggle(self): + """Toggle the state of the entity.""" + if self._enabled: + yield from self.async_turn_off() + else: + yield from self.async_turn_on() + + @asyncio.coroutine + def async_trigger(self, variables): """Trigger automation.""" if self._cond_func(variables): - self._action(variables) + yield from self._async_action(variables) self._last_triggered = utcnow() - self.update_ha_state() + yield from self.async_update_ha_state() def remove(self): """Remove automation from HASS.""" - self.turn_off() + run_coroutine_threadsafe(self.async_turn_off(), + self.hass.loop).result() super().remove() -def _process_config(hass, config, component): +@asyncio.coroutine +def _async_process_config(hass, config, component): """Process config and add automations.""" success = False @@ -293,10 +291,11 @@ def _process_config(hass, config, component): hidden = config_block[CONF_HIDE_ENTITY] - action = _get_action(hass, config_block.get(CONF_ACTION, {}), name) + action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), + name) if CONF_CONDITION in config_block: - cond_func = _process_if(hass, config, config_block) + cond_func = _async_process_if(hass, config, config_block) if cond_func is None: continue @@ -305,101 +304,68 @@ def _process_config(hass, config, component): """Condition will always pass.""" return True - attach_triggers = partial(_process_trigger, hass, config, - config_block.get(CONF_TRIGGER, []), name) - entity = AutomationEntity(name, attach_triggers, cond_func, action, - hidden) - component.add_entities((entity,)) + async_attach_triggers = partial( + _async_process_trigger, hass, config, + config_block.get(CONF_TRIGGER, []), name) + entity = AutomationEntity(name, async_attach_triggers, cond_func, + action, hidden) + yield from hass.loop.run_in_executor( + None, component.add_entities, [entity]) success = True return success -def _get_action(hass, config, name): +def _async_get_action(hass, config, name): """Return an action based on a configuration.""" script_obj = script.Script(hass, config, name) + @asyncio.coroutine def action(variables=None): """Action to be executed.""" _LOGGER.info('Executing %s', name) - logbook.log_entry(hass, name, 'has been triggered', DOMAIN) - script_obj.run(variables) + logbook.async_log_entry(hass, name, 'has been triggered', DOMAIN) + yield from script_obj.async_run(variables) return action -def _process_if(hass, config, p_config): +def _async_process_if(hass, config, p_config): """Process if checks.""" - cond_type = p_config.get(CONF_CONDITION_TYPE, - DEFAULT_CONDITION_TYPE).lower() - - # Deprecated since 0.19 - 5/5/2016 - if cond_type != DEFAULT_CONDITION_TYPE: - _LOGGER.warning('Using condition_type: "or" is deprecated. Please use ' - '"condition: or" instead.') - if_configs = p_config.get(CONF_CONDITION) - use_trigger = if_configs == CONDITION_USE_TRIGGER_VALUES - - if use_trigger: - if_configs = p_config[CONF_TRIGGER] checks = [] for if_config in if_configs: - # Deprecated except for used by use_trigger_values - # since 0.19 - 5/5/2016 - if CONF_PLATFORM in if_config: - if not use_trigger: - _LOGGER.warning("Please switch your condition configuration " - "to use 'condition' instead of 'platform'.") - if_config = dict(if_config) - if_config[CONF_CONDITION] = if_config.pop(CONF_PLATFORM) - - # To support use_trigger_values with state trigger accepting - # multiple entity_ids to monitor. - if_entity_id = if_config.get(ATTR_ENTITY_ID) - if isinstance(if_entity_id, list) and len(if_entity_id) == 1: - if_config[ATTR_ENTITY_ID] = if_entity_id[0] - try: - checks.append(condition.from_config(if_config)) + checks.append(condition.async_from_config(if_config, False)) except HomeAssistantError as ex: - # Invalid conditions are allowed if we base it on trigger - if use_trigger: - _LOGGER.warning('Ignoring invalid condition: %s', ex) - else: - _LOGGER.warning('Invalid condition: %s', ex) - return None + _LOGGER.warning('Invalid condition: %s', ex) + return None - if cond_type == CONDITION_TYPE_AND: - def if_action(variables=None): - """AND all conditions.""" - return all(check(hass, variables) for check in checks) - else: - def if_action(variables=None): - """OR all conditions.""" - return any(check(hass, variables) for check in checks) + def if_action(variables=None): + """AND all conditions.""" + return all(check(hass, variables) for check in checks) return if_action -def _process_trigger(hass, config, trigger_configs, name, action): +def _async_process_trigger(hass, config, trigger_configs, name, action): """Setup the triggers.""" removes = [] for conf in trigger_configs: - platform = _resolve_platform(METHOD_TRIGGER, hass, config, - conf.get(CONF_PLATFORM)) + platform = prepare_setup_platform(hass, config, DOMAIN, + conf.get(CONF_PLATFORM)) if platform is None: - continue + return None - remove = platform.trigger(hass, conf, action) + remove = platform.async_trigger(hass, conf, action) if not remove: - _LOGGER.error("Error setting up rule %s", name) + _LOGGER.error("Error setting up trigger %s", name) continue - _LOGGER.info("Initialized rule %s", name) + _LOGGER.info("Initialized trigger %s", name) removes.append(remove) if not removes: @@ -411,17 +377,3 @@ def _process_trigger(hass, config, trigger_configs, name, action): remove() return remove_triggers - - -def _resolve_platform(method, hass, config, platform): - """Find the automation platform.""" - if platform is None: - return None - platform = prepare_setup_platform(hass, config, DOMAIN, platform) - - if platform is None or not hasattr(platform, method): - _LOGGER.error("Unknown automation platform specified for %s: %s", - method, platform) - return None - - return platform diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 7b77dd04627..ae0f219a01a 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) event_data = config.get(CONF_EVENT_DATA) @@ -41,4 +41,4 @@ def trigger(hass, config, action): }, }) - return hass.bus.listen(event_type, handle_event) + return hass.bus.async_listen(event_type, handle_event) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index f774991c547..7897a9bc221 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -22,7 +22,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) @@ -40,4 +40,4 @@ def trigger(hass, config, action): } }) - return mqtt.subscribe(hass, topic, mqtt_automation_listener) + return mqtt.async_subscribe(hass, topic, mqtt_automation_listener) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 780ab4400b0..4d6cdc21190 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, CONF_BELOW, CONF_ABOVE) -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ _LOGGER = logging.getLogger(__name__) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) @@ -66,4 +66,4 @@ def trigger(hass, config, action): hass.async_add_job(action, variables) - return track_state_change(hass, entity_id, state_automation_listener) + return async_track_state_change(hass, entity_id, state_automation_listener) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index dbe74479070..0649834ff33 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -12,7 +12,6 @@ from homeassistant.const import MATCH_ALL, CONF_PLATFORM from homeassistant.helpers.event import ( async_track_state_change, async_track_point_in_utc_time) import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import run_callback_threadsafe CONF_ENTITY_ID = "entity_id" CONF_FROM = "from" @@ -35,7 +34,7 @@ TRIGGER_SCHEMA = vol.All( ) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) @@ -98,8 +97,4 @@ def trigger(hass, config, action): if async_remove_state_for_listener is not None: async_remove_state_for_listener() - def remove(): - """Remove state listeners.""" - run_callback_threadsafe(hass.loop, async_remove).result() - - return remove + return async_remove diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index faa628f572a..9892707a139 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_EVENT, CONF_OFFSET, CONF_PLATFORM, SUN_EVENT_SUNRISE) -from homeassistant.helpers.event import track_sunrise, track_sunset +from homeassistant.helpers.event import async_track_sunrise, async_track_sunset import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['sun'] @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) @@ -44,6 +44,6 @@ def trigger(hass, config, action): # Do something to call action if event == SUN_EVENT_SUNRISE: - return track_sunrise(hass, call_action, offset) + return async_track_sunrise(hass, call_action, offset) else: - return track_sunset(hass, call_action, offset) + return async_track_sunset(hass, call_action, offset) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 1ca0c679424..94f57dbbc02 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM from homeassistant.helpers import condition -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change import homeassistant.helpers.config_validation as cv @@ -23,7 +23,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ }) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass @@ -51,5 +51,5 @@ def trigger(hass, config, action): elif not template_result: already_triggered = False - return track_state_change(hass, value_template.extract_entities(), - state_changed_listener) + return async_track_state_change(hass, value_template.extract_entities(), + state_changed_listener) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 91f196eaf3f..190a6519278 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_AFTER, CONF_PLATFORM from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import track_time_change +from homeassistant.helpers.event import async_track_time_change CONF_HOURS = "hours" CONF_MINUTES = "minutes" @@ -29,7 +29,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ CONF_SECONDS, CONF_AFTER)) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" if CONF_AFTER in config: after = config.get(CONF_AFTER) @@ -49,5 +49,5 @@ def trigger(hass, config, action): }, }) - return track_time_change(hass, time_automation_listener, - hour=hours, minute=minutes, second=seconds) + return async_track_time_change(hass, time_automation_listener, + hour=hours, minute=minutes, second=seconds) diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index 971257350e3..59812738692 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_EVENT, CONF_ENTITY_ID, CONF_ZONE, MATCH_ALL, CONF_PLATFORM) -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers import ( condition, config_validation as cv, location) @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -def trigger(hass, config, action): +def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) @@ -60,5 +60,5 @@ def trigger(hass, config, action): }, }) - return track_state_change(hass, entity_id, zone_automation_listener, - MATCH_ALL, MATCH_ALL) + return async_track_state_change(hass, entity_id, zone_automation_listener, + MATCH_ALL, MATCH_ALL) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index ae1dc471706..bfc637cf946 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1,5 +1,6 @@ """Offer reusable conditions.""" from datetime import timedelta +import functools as ft import logging import sys @@ -30,6 +31,7 @@ _LOGGER = logging.getLogger(__name__) def _threaded_factory(async_factory): """Helper method to create threaded versions of async factories.""" + @ft.wraps(async_factory) def factory(config, config_validation=True): """Threaded factory.""" async_check = async_factory(config, config_validation) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3c119eb456e..99384764b5b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -24,13 +24,20 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], current_ids: Optional[List[str]]=None, hass: Optional[HomeAssistant]=None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" - name = (name or DEVICE_DEFAULT_NAME).lower() if current_ids is None: if hass is None: raise ValueError("Missing required parameter currentids or hass") current_ids = hass.states.entity_ids() + return async_generate_entity_id(entity_id_format, name, current_ids) + + +def async_generate_entity_id(entity_id_format: str, name: Optional[str], + current_ids: Optional[List[str]]=None) -> str: + """Generate a unique entity ID based on given entity IDs or used IDs.""" + name = (name or DEVICE_DEFAULT_NAME).lower() + return ensure_unique_string( entity_id_format.format(slugify(name)), current_ids) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index e27f711afda..bf781e7e746 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -3,30 +3,36 @@ import asyncio import functools as ft from datetime import timedelta +from ..core import HomeAssistant from ..const import ( ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) from ..util import dt as dt_util from ..util.async import run_callback_threadsafe +# PyLint does not like the use of _threaded_factory +# pylint: disable=invalid-name -def track_state_change(hass, entity_ids, action, from_state=None, - to_state=None): - """Track specific state changes. - entity_ids, from_state and to_state can be string or list. - Use list to match multiple. +def _threaded_factory(async_factory): + """Convert an async event helper to a threaded one.""" + @ft.wraps(async_factory) + def factory(*args, **kwargs): + """Call async event helper safely.""" + hass = args[0] - Returns a function that can be called to remove the listener. - """ - async_unsub = run_callback_threadsafe( - hass.loop, async_track_state_change, hass, entity_ids, action, - from_state, to_state).result() + if not isinstance(hass, HomeAssistant): + raise TypeError('First parameter needs to be a hass instance') - def remove(): - """Remove listener.""" - run_callback_threadsafe(hass.loop, async_unsub).result() + async_remove = run_callback_threadsafe( + hass.loop, ft.partial(async_factory, *args, **kwargs)).result() - return remove + def remove(): + """Threadsafe removal.""" + run_callback_threadsafe(hass.loop, async_remove).result() + + return remove + + return factory def async_track_state_change(hass, entity_ids, action, from_state=None, @@ -77,7 +83,10 @@ def async_track_state_change(hass, entity_ids, action, from_state=None, return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) -def track_point_in_time(hass, action, point_in_time): +track_state_change = _threaded_factory(async_track_state_change) + + +def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a spefic point in time.""" utc_point_in_time = dt_util.as_utc(point_in_time) @@ -87,20 +96,11 @@ def track_point_in_time(hass, action, point_in_time): """Convert passed in UTC now to local now.""" hass.async_add_job(action, dt_util.as_local(utc_now)) - return track_point_in_utc_time(hass, utc_converter, utc_point_in_time) + return async_track_point_in_utc_time(hass, utc_converter, + utc_point_in_time) -def track_point_in_utc_time(hass, action, point_in_time): - """Add a listener that fires once after a specific point in UTC time.""" - async_unsub = run_callback_threadsafe( - hass.loop, async_track_point_in_utc_time, hass, action, point_in_time - ).result() - - def remove(): - """Remove listener.""" - run_callback_threadsafe(hass.loop, async_unsub).result() - - return remove +track_point_in_time = _threaded_factory(async_track_point_in_time) def async_track_point_in_utc_time(hass, action, point_in_time): @@ -133,7 +133,10 @@ def async_track_point_in_utc_time(hass, action, point_in_time): return async_unsub -def track_sunrise(hass, action, offset=None): +track_point_in_utc_time = _threaded_factory(async_track_point_in_utc_time) + + +def async_track_sunrise(hass, action, offset=None): """Add a listener that will fire a specified offset from sunrise daily.""" from homeassistant.components import sun offset = offset or timedelta() @@ -147,6 +150,7 @@ def track_sunrise(hass, action, offset=None): return next_time + @ft.wraps(action) @asyncio.coroutine def sunrise_automation_listener(now): """Called when it's time for action.""" @@ -155,18 +159,20 @@ def track_sunrise(hass, action, offset=None): hass, sunrise_automation_listener, next_rise()) hass.async_add_job(action) - remove = run_callback_threadsafe( - hass.loop, async_track_point_in_utc_time, hass, - sunrise_automation_listener, next_rise()).result() + remove = async_track_point_in_utc_time( + hass, sunrise_automation_listener, next_rise()) def remove_listener(): """Remove sunset listener.""" - run_callback_threadsafe(hass.loop, remove).result() + remove() return remove_listener -def track_sunset(hass, action, offset=None): +track_sunrise = _threaded_factory(async_track_sunrise) + + +def async_track_sunset(hass, action, offset=None): """Add a listener that will fire a specified offset from sunset daily.""" from homeassistant.components import sun offset = offset or timedelta() @@ -180,6 +186,7 @@ def track_sunset(hass, action, offset=None): return next_time + @ft.wraps(action) @asyncio.coroutine def sunset_automation_listener(now): """Called when it's time for action.""" @@ -188,20 +195,23 @@ def track_sunset(hass, action, offset=None): hass, sunset_automation_listener, next_set()) hass.async_add_job(action) - remove = run_callback_threadsafe( - hass.loop, async_track_point_in_utc_time, hass, - sunset_automation_listener, next_set()).result() + remove = async_track_point_in_utc_time( + hass, sunset_automation_listener, next_set()) def remove_listener(): """Remove sunset listener.""" - run_callback_threadsafe(hass.loop, remove).result() + remove() return remove_listener +track_sunset = _threaded_factory(async_track_sunset) + + # pylint: disable=too-many-arguments -def track_utc_time_change(hass, action, year=None, month=None, day=None, - hour=None, minute=None, second=None, local=False): +def async_track_utc_time_change(hass, action, year=None, month=None, day=None, + hour=None, minute=None, second=None, + local=False): """Add a listener that will fire if time matches a pattern.""" # We do not have to wrap the function with time pattern matching logic # if no pattern given @@ -211,7 +221,7 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, """Fire every time event that comes in.""" action(event.data[ATTR_NOW]) - return hass.bus.listen(EVENT_TIME_CHANGED, time_change_listener) + return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener) pmp = _process_time_match year, month, day = pmp(year), pmp(month), pmp(day) @@ -237,15 +247,22 @@ def track_utc_time_change(hass, action, year=None, month=None, day=None, hass.async_add_job(action, now) - return hass.bus.listen(EVENT_TIME_CHANGED, pattern_time_change_listener) + return hass.bus.async_listen(EVENT_TIME_CHANGED, + pattern_time_change_listener) + + +track_utc_time_change = _threaded_factory(async_track_utc_time_change) # pylint: disable=too-many-arguments -def track_time_change(hass, action, year=None, month=None, day=None, - hour=None, minute=None, second=None): +def async_track_time_change(hass, action, year=None, month=None, day=None, + hour=None, minute=None, second=None): """Add a listener that will fire if UTC time matches a pattern.""" - return track_utc_time_change(hass, action, year, month, day, hour, minute, - second, local=True) + return async_track_utc_time_change(hass, action, year, month, day, hour, + minute, second, local=True) + + +track_time_change = _threaded_factory(async_track_time_change) def _process_state_match(parameter): diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index cc10b134caf..b667436d9a6 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import patch -from homeassistant.bootstrap import _setup_component +from homeassistant.bootstrap import setup_component import homeassistant.components.automation as automation from homeassistant.const import ATTR_ENTITY_ID from homeassistant.exceptions import HomeAssistantError @@ -31,7 +31,7 @@ class TestAutomation(unittest.TestCase): def test_service_data_not_a_dict(self): """Test service data not dict.""" - assert not _setup_component(self.hass, automation.DOMAIN, { + assert not setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'event', @@ -46,7 +46,7 @@ class TestAutomation(unittest.TestCase): def test_service_specify_data(self): """Test service data.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -77,7 +77,7 @@ class TestAutomation(unittest.TestCase): def test_service_specify_entity_id(self): """Test service data.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'event', @@ -98,7 +98,7 @@ class TestAutomation(unittest.TestCase): def test_service_specify_entity_id_list(self): """Test service data.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'event', @@ -119,7 +119,7 @@ class TestAutomation(unittest.TestCase): def test_two_triggers(self): """Test triggers.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': [ { @@ -147,7 +147,7 @@ class TestAutomation(unittest.TestCase): def test_two_conditions_with_and(self): """Test two and conditions.""" entity_id = 'test.entity' - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': [ { @@ -188,123 +188,9 @@ class TestAutomation(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) - def test_two_conditions_with_or(self): - """Test two or conditions.""" - entity_id = 'test.entity' - assert _setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': [ - { - 'platform': 'event', - 'event_type': 'test_event', - }, - ], - 'condition_type': 'OR', - 'condition': [ - { - 'platform': 'state', - 'entity_id': entity_id, - 'state': '200' - }, - { - 'platform': 'numeric_state', - 'entity_id': entity_id, - 'below': 150 - } - ], - 'action': { - 'service': 'test.automation', - } - } - }) - - self.hass.states.set(entity_id, 200) - self.hass.bus.fire('test_event') - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - - self.hass.states.set(entity_id, 100) - self.hass.bus.fire('test_event') - self.hass.block_till_done() - self.assertEqual(2, len(self.calls)) - - self.hass.states.set(entity_id, 250) - self.hass.bus.fire('test_event') - self.hass.block_till_done() - self.assertEqual(2, len(self.calls)) - - def test_using_trigger_as_condition(self): - """Test triggers as condition.""" - entity_id = 'test.entity' - assert _setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': [ - { - 'platform': 'state', - 'entity_id': entity_id, - 'from': '120', - 'state': '100' - }, - { - 'platform': 'numeric_state', - 'entity_id': entity_id, - 'below': 150 - } - ], - 'condition': 'use_trigger_values', - 'action': { - 'service': 'test.automation', - } - } - }) - - self.hass.states.set(entity_id, 100) - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - - self.hass.states.set(entity_id, 120) - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - - self.hass.states.set(entity_id, 100) - self.hass.block_till_done() - self.assertEqual(2, len(self.calls)) - - self.hass.states.set(entity_id, 151) - self.hass.block_till_done() - self.assertEqual(2, len(self.calls)) - - def test_using_trigger_as_condition_with_invalid_condition(self): - """Event is not a valid condition.""" - entity_id = 'test.entity' - self.hass.states.set(entity_id, 100) - assert _setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': [ - { - 'platform': 'event', - 'event_type': 'test_event', - }, - { - 'platform': 'numeric_state', - 'entity_id': entity_id, - 'below': 150 - } - ], - 'condition': 'use_trigger_values', - 'action': { - 'service': 'test.automation', - } - } - }) - - self.hass.bus.fire('test_event') - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - def test_automation_list_setting(self): """Event is not a valid condition.""" - self.assertTrue(_setup_component(self.hass, automation.DOMAIN, { + self.assertTrue(setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: [{ 'trigger': { 'platform': 'event', @@ -335,7 +221,7 @@ class TestAutomation(unittest.TestCase): def test_automation_calling_two_actions(self): """Test if we can call two actions from automation definition.""" - self.assertTrue(_setup_component(self.hass, automation.DOMAIN, { + self.assertTrue(setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { 'platform': 'event', @@ -366,7 +252,7 @@ class TestAutomation(unittest.TestCase): assert self.hass.states.get(entity_id) is None assert not automation.is_on(self.hass, entity_id) - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -433,7 +319,7 @@ class TestAutomation(unittest.TestCase): }) def test_reload_config_service(self, mock_load_yaml): """Test the reload config service.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -483,7 +369,7 @@ class TestAutomation(unittest.TestCase): }) def test_reload_config_when_invalid_config(self, mock_load_yaml): """Test the reload config service handling invalid config.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { @@ -517,7 +403,7 @@ class TestAutomation(unittest.TestCase): def test_reload_config_handles_load_fails(self): """Test the reload config service.""" - assert _setup_component(self.hass, automation.DOMAIN, { + assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'alias': 'hello', 'trigger': { diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 01ce5e7c4e6..fecd8474763 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -499,7 +499,7 @@ class TestAutomationNumericState(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'numeric_state', + 'condition': 'numeric_state', 'entity_id': entity_id, 'above': test_state, 'below': test_state + 2 diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 7132bf9e7a2..d6fe56453ee 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -213,7 +213,7 @@ class TestAutomationState(unittest.TestCase): 'event_type': 'test_event', }, 'condition': [{ - 'platform': 'state', + 'condition': 'state', 'entity_id': entity_id, 'state': test_state }], @@ -360,7 +360,7 @@ class TestAutomationState(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'state', + 'condition': 'state', 'entity_id': 'test.entity', 'state': 'on', 'for': { diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index 8058854aaa9..815c540eb3e 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -172,7 +172,7 @@ class TestAutomationSun(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'sun', + 'condition': 'sun', 'before': 'sunrise', }, 'action': { @@ -208,7 +208,7 @@ class TestAutomationSun(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'sun', + 'condition': 'sun', 'after': 'sunrise', }, 'action': { @@ -244,7 +244,7 @@ class TestAutomationSun(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'sun', + 'condition': 'sun', 'before': 'sunrise', 'before_offset': '+1:00:00' }, @@ -281,7 +281,7 @@ class TestAutomationSun(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'sun', + 'condition': 'sun', 'after': 'sunrise', 'after_offset': '+1:00:00' }, @@ -319,7 +319,7 @@ class TestAutomationSun(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'sun', + 'condition': 'sun', 'after': 'sunrise', 'before': 'sunset' }, @@ -365,7 +365,7 @@ class TestAutomationSun(unittest.TestCase): 'event_type': 'test_event', }, 'condition': { - 'platform': 'sun', + 'condition': 'sun', 'after': 'sunset', }, 'action': { diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index 327bedb23f2..1334608ecdb 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -339,7 +339,7 @@ class TestAutomationTemplate(unittest.TestCase): 'event_type': 'test_event', }, 'condition': [{ - 'platform': 'template', + 'condition': 'template', 'value_template': '{{ is_state("test.entity", "world") }}' }], 'action': { diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index edc5fb4cdc1..afdab681460 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -250,7 +250,7 @@ class TestAutomationTime(unittest.TestCase): 'event_type': 'test_event' }, 'condition': { - 'platform': 'time', + 'condition': 'time', 'before': '10:00', }, 'action': { @@ -285,7 +285,7 @@ class TestAutomationTime(unittest.TestCase): 'event_type': 'test_event' }, 'condition': { - 'platform': 'time', + 'condition': 'time', 'after': '10:00', }, 'action': { @@ -320,7 +320,7 @@ class TestAutomationTime(unittest.TestCase): 'event_type': 'test_event' }, 'condition': { - 'platform': 'time', + 'condition': 'time', 'weekday': 'mon', }, 'action': { @@ -356,7 +356,7 @@ class TestAutomationTime(unittest.TestCase): 'event_type': 'test_event' }, 'condition': { - 'platform': 'time', + 'condition': 'time', 'weekday': ['mon', 'tue'], }, 'action': { diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index 8041eceaeb3..e454b8b5b8b 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -197,7 +197,7 @@ class TestAutomationZone(unittest.TestCase): 'event_type': 'test_event' }, 'condition': { - 'platform': 'zone', + 'condition': 'zone', 'entity_id': 'test.entity', 'zone': 'zone.test', }, From e18825ba201cf1440fd623efacf476fc24a8a458 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Oct 2016 09:19:20 -0700 Subject: [PATCH 07/10] Automation: only call executor once when processing config --- homeassistant/components/automation/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 37102cd3863..76543f6e9a7 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -280,7 +280,7 @@ class AutomationEntity(ToggleEntity): @asyncio.coroutine def _async_process_config(hass, config, component): """Process config and add automations.""" - success = False + entities = [] for config_key in extract_domain_configs(config, DOMAIN): conf = config[config_key] @@ -307,13 +307,13 @@ def _async_process_config(hass, config, component): async_attach_triggers = partial( _async_process_trigger, hass, config, config_block.get(CONF_TRIGGER, []), name) - entity = AutomationEntity(name, async_attach_triggers, cond_func, - action, hidden) - yield from hass.loop.run_in_executor( - None, component.add_entities, [entity]) - success = True + entities.append(AutomationEntity(name, async_attach_triggers, + cond_func, action, hidden)) - return success + yield from hass.loop.run_in_executor( + None, component.add_entities, entities) + + return len(entities) > 0 def _async_get_action(hass, config, name): From 56fdc2a62586de05c3c4c4089047f6c5b5428875 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Oct 2016 14:11:07 -0700 Subject: [PATCH 08/10] Automation: call prepare_setup_platform in executor --- .../components/automation/__init__.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 76543f6e9a7..3142d759926 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -198,10 +198,10 @@ class AutomationEntity(ToggleEntity): """Initialize an automation entity.""" self._name = name self._async_attach_triggers = async_attach_triggers - self._async_detach_triggers = async_attach_triggers(self.async_trigger) + self._async_detach_triggers = None self._cond_func = cond_func self._async_action = async_action - self._enabled = True + self._enabled = False self._last_triggered = None self._hidden = hidden @@ -234,13 +234,8 @@ class AutomationEntity(ToggleEntity): @asyncio.coroutine def async_turn_on(self, **kwargs) -> None: - """Turn the entity on.""" - if self._enabled: - return - - self._async_detach_triggers = self._async_attach_triggers( - self.async_trigger) - self._enabled = True + """Turn the entity on and update the state.""" + yield from self.async_enable() yield from self.async_update_ha_state() @asyncio.coroutine @@ -276,6 +271,16 @@ class AutomationEntity(ToggleEntity): self.hass.loop).result() super().remove() + @asyncio.coroutine + def async_enable(self): + """Enable this automation entity.""" + if self._enabled: + return + + self._async_detach_triggers = yield from self._async_attach_triggers( + self.async_trigger) + self._enabled = True + @asyncio.coroutine def _async_process_config(hass, config, component): @@ -307,8 +312,10 @@ def _async_process_config(hass, config, component): async_attach_triggers = partial( _async_process_trigger, hass, config, config_block.get(CONF_TRIGGER, []), name) - entities.append(AutomationEntity(name, async_attach_triggers, - cond_func, action, hidden)) + entity = AutomationEntity(name, async_attach_triggers, cond_func, + action, hidden) + yield from entity.async_enable() + entities.append(entity) yield from hass.loop.run_in_executor( None, component.add_entities, entities) @@ -349,13 +356,16 @@ def _async_process_if(hass, config, p_config): return if_action +@asyncio.coroutine def _async_process_trigger(hass, config, trigger_configs, name, action): """Setup the triggers.""" removes = [] for conf in trigger_configs: - platform = prepare_setup_platform(hass, config, DOMAIN, - conf.get(CONF_PLATFORM)) + platform = yield from hass.loop.run_in_executor( + None, prepare_setup_platform, hass, config, DOMAIN, + conf.get(CONF_PLATFORM)) + if platform is None: return None From 8f3e12c9b867fa435fa9c661b62afe5fa3ab5945 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Oct 2016 15:42:17 -0700 Subject: [PATCH 09/10] Make Automation.reload_service_handler async --- homeassistant/components/automation/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3142d759926..a677fe1da4e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -164,13 +164,14 @@ def setup(hass, config): for entity in component.extract_from_service(service_call): yield from getattr(entity, method)() + @asyncio.coroutine def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" - conf = component.prepare_reload() + conf = yield from hass.loop.run_in_executor( + None, component.prepare_reload) if conf is None: return - run_coroutine_threadsafe( - _async_process_config(hass, conf, component), hass.loop).result() + yield from _async_process_config(hass, conf, component) hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler, descriptions.get(SERVICE_TRIGGER), From c36d30f4fe11968d889c716690ca4dec7deb2755 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Oct 2016 15:43:33 -0700 Subject: [PATCH 10/10] Typo --- homeassistant/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 2a6372dbf6f..43c20f18b75 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -122,8 +122,8 @@ class HomeAssistant(object): def __init__(self, loop=None): """Initialize new Home Assistant object.""" self.loop = loop or asyncio.get_event_loop() - self.executer = ThreadPoolExecutor(max_workers=5) - self.loop.set_default_executor(self.executer) + self.executor = ThreadPoolExecutor(max_workers=5) + self.loop.set_default_executor(self.executor) self.pool = pool = create_worker_pool() self.bus = EventBus(pool, self.loop) self.services = ServiceRegistry(self.bus, self.add_job, self.loop) @@ -287,7 +287,7 @@ class HomeAssistant(object): self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) yield from self.loop.run_in_executor(None, self.pool.block_till_done) yield from self.loop.run_in_executor(None, self.pool.stop) - self.executer.shutdown() + self.executor.shutdown() self.state = CoreState.not_running self.loop.stop()