From 29c7987453720683d4165c4dbb1fdb45c638419b Mon Sep 17 00:00:00 2001 From: jumpkick Date: Tue, 14 Feb 2017 18:29:23 -0500 Subject: [PATCH 001/198] Improvements for WeMo Insight switches * Changes current power units to watts * Adds power on times and additional totals --- homeassistant/components/switch/wemo.py | 83 ++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 3af93d08fc8..985dd8418eb 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -10,6 +10,7 @@ from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN) from homeassistant.loader import get_component +from datetime import datetime, timedelta DEPENDENCIES = ['wemo'] @@ -20,6 +21,16 @@ ATTR_SWITCH_MODE = "switch_mode" ATTR_CURRENT_STATE_DETAIL = 'state_detail' ATTR_COFFEMAKER_MODE = "coffeemaker_mode" +# Wemo Insight +ATTR_POWER_CURRENT_W = 'power_current_w' +# ATTR_POWER_AVG_W = 'power_average_w' +ATTR_POWER_TODAY_MW_MIN = 'power_today_mW_min' +ATTR_POWER_TOTAL_MW_MIN = 'power_total_mW_min' +ATTR_ON_FOR_TIME = 'on_time_most_recent' +ATTR_ON_TODAY_TIME = 'on_time_today' +ATTR_ON_TOTAL_TIME = 'on_time_total' +ATTR_POWER_THRESHOLD = 'power_threshold_w' + MAKER_SWITCH_MOMENTARY = "momentary" MAKER_SWITCH_TOGGLE = "toggle" @@ -109,23 +120,81 @@ class WemoSwitch(SwitchDevice): if self.insight_params or (self.coffeemaker_mode is not None): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state + attr[ATTR_POWER_CURRENT_W] = self.power_current_watt + # attr[ATTR_POWER_AVG_W] = self.power_average_watt + attr[ATTR_POWER_TODAY_MW_MIN] = self.power_today_mw_min + attr[ATTR_POWER_TOTAL_MW_MIN] = self.power_total_mw_min + attr[ATTR_ON_FOR_TIME] = self.on_for + attr[ATTR_ON_TODAY_TIME] = self.on_today + attr[ATTR_ON_TOTAL_TIME] = self.on_total + attr[ATTR_POWER_THRESHOLD] = self.power_threshold if self.coffeemaker_mode is not None: attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode return attr - @property - def current_power_mwh(self): - """Current power usage in mWh.""" +# @property + def _current_power_mw(self): + """Current power usage in mW.""" if self.insight_params: - return self.insight_params['currentpower'] + return self.insight_params['currentpower'] @property - def today_power_mw(self): - """Today total power usage in mW.""" + def power_current_watt(self): + """Current power usage in W.""" if self.insight_params: - return self.insight_params['todaymw'] + try: + return self._current_power_mw() / 1000 + except: + return None + + @property + def power_threshold(self): + if self.insight_params: + return self.insight_params['powerthreshold'] / 1000 + + def _as_uptime(self, _seconds): + d = datetime(1,1,1) + timedelta(seconds=_seconds) + return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(d.day-1, + d.hour, + d.minute, d.second) + + @property + def on_for(self): + """On time in seconds.""" + if self.insight_params: + return self._as_uptime(self.insight_params['onfor']) + + @property + def on_today(self): + """On time in seconds.""" + if self.insight_params: + return self._as_uptime(self.insight_params['ontoday']) + + @property + def on_total(self): + """On time in seconds.""" + if self.insight_params: + return self._as_uptime(self.insight_params['ontotal']) + + @property + def power_total_mw_min(self): + """This is a total of average mW per minute.""" + if self.insight_params: + try: + return self.insight_params['totalmw'] + except: + return None + + @property + def power_today_mw_min(self): + """This is the total consumption today in mW per minute.""" + if self.insight_params: + try: + return self.insight_params['todaymw'] + except: + return None @property def detail_state(self): From c404fb7142e97be0176c9ea58bcc161fab6f6006 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 15:34:42 -0500 Subject: [PATCH 002/198] Update wemo.py * Reordered datetime import * Spaces by 4 --- homeassistant/components/switch/wemo.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 985dd8418eb..1576a203b35 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -5,12 +5,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.wemo/ """ import logging +from datetime import datetime, timedelta from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN) from homeassistant.loader import get_component -from datetime import datetime, timedelta DEPENDENCIES = ['wemo'] @@ -138,21 +138,21 @@ class WemoSwitch(SwitchDevice): def _current_power_mw(self): """Current power usage in mW.""" if self.insight_params: - return self.insight_params['currentpower'] + return self.insight_params['currentpower'] @property def power_current_watt(self): """Current power usage in W.""" if self.insight_params: try: - return self._current_power_mw() / 1000 + return self._current_power_mw() / 1000 except: - return None + return None @property def power_threshold(self): if self.insight_params: - return self.insight_params['powerthreshold'] / 1000 + return self.insight_params['powerthreshold'] / 1000 def _as_uptime(self, _seconds): d = datetime(1,1,1) + timedelta(seconds=_seconds) @@ -183,18 +183,18 @@ class WemoSwitch(SwitchDevice): """This is a total of average mW per minute.""" if self.insight_params: try: - return self.insight_params['totalmw'] + return self.insight_params['totalmw'] except: - return None + return None @property def power_today_mw_min(self): """This is the total consumption today in mW per minute.""" if self.insight_params: try: - return self.insight_params['todaymw'] + return self.insight_params['todaymw'] except: - return None + return None @property def detail_state(self): From 44d274e4286516a48f6d61c5ed5a42d7dd1ef4fc Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 15:38:41 -0500 Subject: [PATCH 003/198] Update wemo.py * continuation line under-indented for visual indent --- homeassistant/components/switch/wemo.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 1576a203b35..b4d619c9882 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -157,9 +157,10 @@ class WemoSwitch(SwitchDevice): def _as_uptime(self, _seconds): d = datetime(1,1,1) + timedelta(seconds=_seconds) return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(d.day-1, - d.hour, - d.minute, d.second) - + d.hour, + d.minute, + d.second) + @property def on_for(self): """On time in seconds.""" From a718e92708e6d80d03645087e5ebeb4bd376280b Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 15:40:02 -0500 Subject: [PATCH 004/198] Update wemo.py trailing whitespace... (argh... the bot should just trim it) --- homeassistant/components/switch/wemo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index b4d619c9882..4d436aa348e 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -158,7 +158,7 @@ class WemoSwitch(SwitchDevice): d = datetime(1,1,1) + timedelta(seconds=_seconds) return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(d.day-1, d.hour, - d.minute, + d.minute, d.second) @property From b163544e3c5dca8370afebf2b9a11992c4761a44 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 16:47:02 -0500 Subject: [PATCH 005/198] Back to you travis.... --- homeassistant/components/switch/wemo.py | 41 +++++++++++++------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 4d436aa348e..89bddb8ecc2 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -138,63 +138,66 @@ class WemoSwitch(SwitchDevice): def _current_power_mw(self): """Current power usage in mW.""" if self.insight_params: - return self.insight_params['currentpower'] + return self.insight_params['currentpower'] @property def power_current_watt(self): """Current power usage in W.""" if self.insight_params: try: - return self._current_power_mw() / 1000 - except: - return None + return self._current_power_mw() / 1000 + except Exception: + return None @property def power_threshold(self): + """Threshold of W at which Insight will indicate it's load is ON.""" if self.insight_params: - return self.insight_params['powerthreshold'] / 1000 + return self.insight_params['powerthreshold'] / 1000 + + @staticmethod + def as_uptime(_seconds): + """Format seconds in to uptime string in the format: 00d 00h 00m 00s """ + uptime = datetime(1, 1, 1) + timedelta(seconds=_seconds) + return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(uptime.day-1, + uptime.hour, + uptime.minute, + uptime.second) - def _as_uptime(self, _seconds): - d = datetime(1,1,1) + timedelta(seconds=_seconds) - return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(d.day-1, - d.hour, - d.minute, - d.second) - @property def on_for(self): """On time in seconds.""" if self.insight_params: - return self._as_uptime(self.insight_params['onfor']) + return as_uptime(self.insight_params['onfor']) @property def on_today(self): """On time in seconds.""" if self.insight_params: - return self._as_uptime(self.insight_params['ontoday']) + return as_uptime(self.insight_params['ontoday']) @property def on_total(self): """On time in seconds.""" if self.insight_params: - return self._as_uptime(self.insight_params['ontotal']) + return as_uptime(self.insight_params['ontotal']) @property def power_total_mw_min(self): - """This is a total of average mW per minute.""" + """Total of average mW per minute.""" if self.insight_params: try: return self.insight_params['totalmw'] - except: + except Exception: return None @property def power_today_mw_min(self): - """This is the total consumption today in mW per minute.""" + """Total consumption today in mW per minute.""" if self.insight_params: try: return self.insight_params['todaymw'] - except: + except Exception: return None @property From e221c8a37d5f13a14adf4ddd925ee65126d307b8 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 16:57:16 -0500 Subject: [PATCH 006/198] Update wemo.py --- homeassistant/components/switch/wemo.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 89bddb8ecc2..7fe8de7b699 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -168,19 +168,19 @@ class WemoSwitch(SwitchDevice): def on_for(self): """On time in seconds.""" if self.insight_params: - return as_uptime(self.insight_params['onfor']) + return WemoSwitch.as_uptime(self.insight_params['onfor']) @property def on_today(self): """On time in seconds.""" if self.insight_params: - return as_uptime(self.insight_params['ontoday']) + return WemoSwitch.as_uptime(self.insight_params['ontoday']) @property def on_total(self): """On time in seconds.""" if self.insight_params: - return as_uptime(self.insight_params['ontotal']) + return WemoSwitch.as_uptime(self.insight_params['ontotal']) @property def power_total_mw_min(self): From e9cf5f6f42680f5ef4105b5a37eb62a45835ccad Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 16:58:11 -0500 Subject: [PATCH 007/198] Update wemo.py --- homeassistant/components/switch/wemo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 7fe8de7b699..3c7a3c565c0 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -157,7 +157,7 @@ class WemoSwitch(SwitchDevice): @staticmethod def as_uptime(_seconds): - """Format seconds in to uptime string in the format: 00d 00h 00m 00s """ + """Format seconds into uptime string in the format: 00d 00h 00m 00s""" uptime = datetime(1, 1, 1) + timedelta(seconds=_seconds) return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(uptime.day-1, uptime.hour, From f6e46aecf54a6f81dd254e9e61d0d1b3ebb65f46 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Wed, 15 Feb 2017 17:32:45 -0500 Subject: [PATCH 008/198] Update wemo.py --- homeassistant/components/switch/wemo.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 3c7a3c565c0..afa3a8a0237 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -138,7 +138,10 @@ class WemoSwitch(SwitchDevice): def _current_power_mw(self): """Current power usage in mW.""" if self.insight_params: - return self.insight_params['currentpower'] + try: + return self.insight_params['currentpower'] + except KeyError: + return None @property def power_current_watt(self): @@ -146,7 +149,7 @@ class WemoSwitch(SwitchDevice): if self.insight_params: try: return self._current_power_mw() / 1000 - except Exception: + except TypeError: return None @property @@ -157,7 +160,7 @@ class WemoSwitch(SwitchDevice): @staticmethod def as_uptime(_seconds): - """Format seconds into uptime string in the format: 00d 00h 00m 00s""" + """Format seconds into uptime string in the format: 00d 00h 00m 00s.""" uptime = datetime(1, 1, 1) + timedelta(seconds=_seconds) return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(uptime.day-1, uptime.hour, @@ -188,7 +191,7 @@ class WemoSwitch(SwitchDevice): if self.insight_params: try: return self.insight_params['totalmw'] - except Exception: + except KeyError: return None @property @@ -197,7 +200,7 @@ class WemoSwitch(SwitchDevice): if self.insight_params: try: return self.insight_params['todaymw'] - except Exception: + except KeyError: return None @property From ef87d4dad47404d7c251ca617e127c5d7f2688aa Mon Sep 17 00:00:00 2001 From: jumpkick Date: Thu, 23 Feb 2017 04:54:09 -0500 Subject: [PATCH 009/198] Update device_state_attributes only This gets rid of the other stuff and just updates device_state_attributes, leaving the default properties alone. --- homeassistant/components/switch/wemo.py | 90 +++++-------------------- 1 file changed, 18 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index afa3a8a0237..0768b62062d 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -21,16 +21,6 @@ ATTR_SWITCH_MODE = "switch_mode" ATTR_CURRENT_STATE_DETAIL = 'state_detail' ATTR_COFFEMAKER_MODE = "coffeemaker_mode" -# Wemo Insight -ATTR_POWER_CURRENT_W = 'power_current_w' -# ATTR_POWER_AVG_W = 'power_average_w' -ATTR_POWER_TODAY_MW_MIN = 'power_today_mW_min' -ATTR_POWER_TOTAL_MW_MIN = 'power_total_mW_min' -ATTR_ON_FOR_TIME = 'on_time_most_recent' -ATTR_ON_TODAY_TIME = 'on_time_today' -ATTR_ON_TOTAL_TIME = 'on_time_total' -ATTR_POWER_THRESHOLD = 'power_threshold_w' - MAKER_SWITCH_MOMENTARY = "momentary" MAKER_SWITCH_TOGGLE = "toggle" @@ -120,44 +110,24 @@ class WemoSwitch(SwitchDevice): if self.insight_params or (self.coffeemaker_mode is not None): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state - attr[ATTR_POWER_CURRENT_W] = self.power_current_watt - # attr[ATTR_POWER_AVG_W] = self.power_average_watt - attr[ATTR_POWER_TODAY_MW_MIN] = self.power_today_mw_min - attr[ATTR_POWER_TOTAL_MW_MIN] = self.power_total_mw_min - attr[ATTR_ON_FOR_TIME] = self.on_for - attr[ATTR_ON_TODAY_TIME] = self.on_today - attr[ATTR_ON_TOTAL_TIME] = self.on_total - attr[ATTR_POWER_THRESHOLD] = self.power_threshold + attr['current_power_w'] = \ + self.insight_params['currentpower'] / 1000 + attr['today_power_mW_min'] = self.insight_params['todaymw'] + attr['total_power_mW_min'] = self.insight_params['totalmw'] + attr['on_time_most_recent'] = \ + WemoSwitch.as_uptime(self.insight_params['onfor']) + attr['on_time_today'] = \ + WemoSwitch.as_uptime(self.insight_params['ontoday']) + attr['on_time_total'] = \ + WemoSwitch.as_uptime(self.insight_params['ontotal']) + attr['power_threshold_w'] = \ + self.insight_params['powerthreshold'] / 1000 if self.coffeemaker_mode is not None: attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode return attr -# @property - def _current_power_mw(self): - """Current power usage in mW.""" - if self.insight_params: - try: - return self.insight_params['currentpower'] - except KeyError: - return None - - @property - def power_current_watt(self): - """Current power usage in W.""" - if self.insight_params: - try: - return self._current_power_mw() / 1000 - except TypeError: - return None - - @property - def power_threshold(self): - """Threshold of W at which Insight will indicate it's load is ON.""" - if self.insight_params: - return self.insight_params['powerthreshold'] / 1000 - @staticmethod def as_uptime(_seconds): """Format seconds into uptime string in the format: 00d 00h 00m 00s.""" @@ -168,40 +138,16 @@ class WemoSwitch(SwitchDevice): uptime.second) @property - def on_for(self): - """On time in seconds.""" + def current_power_mwh(self): + """Current power usage in mWh.""" if self.insight_params: - return WemoSwitch.as_uptime(self.insight_params['onfor']) + return self.insight_params['currentpower'] @property - def on_today(self): - """On time in seconds.""" + def today_power_mw(self): + """Today total power usage in mW.""" if self.insight_params: - return WemoSwitch.as_uptime(self.insight_params['ontoday']) - - @property - def on_total(self): - """On time in seconds.""" - if self.insight_params: - return WemoSwitch.as_uptime(self.insight_params['ontotal']) - - @property - def power_total_mw_min(self): - """Total of average mW per minute.""" - if self.insight_params: - try: - return self.insight_params['totalmw'] - except KeyError: - return None - - @property - def power_today_mw_min(self): - """Total consumption today in mW per minute.""" - if self.insight_params: - try: - return self.insight_params['todaymw'] - except KeyError: - return None + return self.insight_params['todaymw'] @property def detail_state(self): From 106b7a9d8f1915d832250cb8675d4ea1e05a8bcc Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 23 Feb 2017 21:57:25 +0100 Subject: [PATCH 010/198] Cleanup run_callback_threadsafe (#6187) * Cleanup run_callback_threadsafe * fix spell * Revert image_processing, they need to wait for update --- homeassistant/components/alert.py | 9 +++------ homeassistant/components/group.py | 7 +++---- .../image_processing/microsoft_face_identify.py | 3 +-- homeassistant/components/light/__init__.py | 11 ++++------- homeassistant/components/logbook.py | 5 +---- homeassistant/components/persistent_notification.py | 5 +---- homeassistant/components/switch/__init__.py | 7 ++----- 7 files changed, 15 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 40c91784a42..24c14e7c9a8 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -18,7 +18,6 @@ from homeassistant.const import ( SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers import service, event -from homeassistant.util.async import run_callback_threadsafe import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -62,8 +61,7 @@ def is_on(hass, entity_id): def turn_on(hass, entity_id): """Reset the alert.""" - run_callback_threadsafe( - hass.loop, async_turn_on, hass, entity_id).result() + hass.add_job(async_turn_on, hass, entity_id) @callback @@ -76,8 +74,7 @@ def async_turn_on(hass, entity_id): def turn_off(hass, entity_id): """Acknowledge alert.""" - run_callback_threadsafe( - hass.loop, async_turn_off, hass, entity_id).result() + hass.add_job(async_turn_off, hass, entity_id) @callback @@ -90,7 +87,7 @@ def async_turn_off(hass, entity_id): def toggle(hass, entity_id): """Toggle acknowledgement of alert.""" - run_callback_threadsafe(hass.loop, async_toggle, hass, entity_id) + hass.add_job(async_toggle, hass, entity_id) @callback diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 230e0e4567f..06e029ffd8c 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -20,8 +20,7 @@ from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change import homeassistant.helpers.config_validation as cv -from homeassistant.util.async import ( - run_callback_threadsafe, run_coroutine_threadsafe) +from homeassistant.util.async import run_coroutine_threadsafe DOMAIN = 'group' @@ -98,7 +97,7 @@ def is_on(hass, entity_id): def reload(hass): """Reload the automation from config.""" - hass.services.call(DOMAIN, SERVICE_RELOAD) + hass.add_job(async_reload, hass) @asyncio.coroutine @@ -365,7 +364,7 @@ class Group(Entity): def start(self): """Start tracking members.""" - run_callback_threadsafe(self.hass.loop, self.async_start).result() + self.hass.add_job(self.async_start) @callback def async_start(self): diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py index 8d716bea0d5..97d210d584a 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -108,8 +108,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): def process_faces(self, faces, total): """Send event with detected faces and store data.""" run_callback_threadsafe( - self.hass.loop, self.async_process_faces, faces, total - ).result() + self.hass.loop, self.async_process_faces, faces, total).result() @callback def async_process_faces(self, faces, total): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 05002788207..8b25e2a726b 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -24,8 +24,6 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import async_restore_state import homeassistant.util.color as color_util -from homeassistant.util.async import run_callback_threadsafe - DOMAIN = "light" SCAN_INTERVAL = timedelta(seconds=30) @@ -145,10 +143,10 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, rgb_color=None, xy_color=None, color_temp=None, white_value=None, profile=None, flash=None, effect=None, color_name=None): """Turn all or specified light on.""" - run_callback_threadsafe( - hass.loop, async_turn_on, hass, entity_id, transition, brightness, + hass.add_job( + async_turn_on, hass, entity_id, transition, brightness, rgb_color, xy_color, color_temp, white_value, - profile, flash, effect, color_name).result() + profile, flash, effect, color_name) @callback @@ -178,8 +176,7 @@ def async_turn_on(hass, entity_id=None, transition=None, brightness=None, def turn_off(hass, entity_id=None, transition=None): """Turn all or specified light off.""" - run_callback_threadsafe( - hass.loop, async_turn_off, hass, entity_id, transition).result() + hass.add_job(async_turn_off, hass, entity_id, transition) @callback diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index b69289db989..30d52303099 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -22,7 +22,6 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_START, STATE_NOT_HOME, STATE_OFF, STATE_ON, ATTR_HIDDEN, HTTP_BAD_REQUEST) from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN -from homeassistant.util.async import run_callback_threadsafe DOMAIN = "logbook" DEPENDENCIES = ['recorder', 'frontend'] @@ -68,9 +67,7 @@ LOG_MESSAGE_SCHEMA = vol.Schema({ def log_entry(hass, name, message, domain=None, entity_id=None): """Add an entry to the logbook.""" - run_callback_threadsafe( - hass.loop, async_log_entry, hass, name, message, domain, entity_id - ).result() + hass.add_job(async_log_entry, hass, name, message, domain, entity_id) def async_log_entry(hass, name, message, domain=None, entity_id=None): diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification.py index b4dde02baff..d7eef848679 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification.py @@ -16,7 +16,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify from homeassistant.config import load_yaml_config_file -from homeassistant.util.async import run_callback_threadsafe DOMAIN = 'persistent_notification' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -39,9 +38,7 @@ _LOGGER = logging.getLogger(__name__) def create(hass, message, title=None, notification_id=None): """Generate a notification.""" - run_callback_threadsafe( - hass.loop, async_create, hass, message, title, notification_id - ).result() + hass.add_job(async_create, hass, message, title, notification_id) @callback diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index a5712fcbcbe..01943bc9c69 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -21,7 +21,6 @@ from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) from homeassistant.components import group -from homeassistant.util.async import run_callback_threadsafe DOMAIN = 'switch' SCAN_INTERVAL = timedelta(seconds=30) @@ -59,8 +58,7 @@ def is_on(hass, entity_id=None): def turn_on(hass, entity_id=None): """Turn all or specified switch on.""" - run_callback_threadsafe( - hass.loop, async_turn_on, hass, entity_id).result() + hass.add_job(async_turn_on, hass, entity_id) @callback @@ -72,8 +70,7 @@ def async_turn_on(hass, entity_id=None): def turn_off(hass, entity_id=None): """Turn all or specified switch off.""" - run_callback_threadsafe( - hass.loop, async_turn_off, hass, entity_id).result() + hass.add_job(async_turn_off, hass, entity_id) @callback From 4f990ce48868e45bc2a30a8a25f4ab42d18016a1 Mon Sep 17 00:00:00 2001 From: Colin O'Dell Date: Thu, 23 Feb 2017 15:58:18 -0500 Subject: [PATCH 011/198] Use H2 headers to split up the different sections (#6183) Using headers makes it easier to visually differentiate between the different sections --- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6131662dc5f..dd030c73d1a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,16 +1,16 @@ -**Description:** +## Description: **Related issue (if applicable):** fixes # **Pull request in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) with documentation (if applicable):** home-assistant/home-assistant.github.io# -**Example entry for `configuration.yaml` (if applicable):** +## Example entry for `configuration.yaml` (if applicable): ```yaml ``` -**Checklist:** +## Checklist: If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) From f2a2d6bfa1578ab0e04aa2821be297e366b0780c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 23 Feb 2017 22:02:56 +0100 Subject: [PATCH 012/198] Refactory of envisalink (#6160) * Refactory of envisalink * remove event buss * init dispatcher from hass. * Move platform to new dispatcher * fix lint * add unittest & threadded functions * fix copy & past error --- .../alarm_control_panel/envisalink.py | 153 +++++++++-------- .../components/binary_sensor/envisalink.py | 55 +++--- homeassistant/components/envisalink.py | 158 +++++++++--------- homeassistant/components/sensor/envisalink.py | 65 +++---- homeassistant/helpers/dispatcher.py | 42 +++++ requirements_all.txt | 1 - tests/helpers/test_dispatcher.py | 103 ++++++++++++ 7 files changed, 371 insertions(+), 206 deletions(-) create mode 100644 homeassistant/helpers/dispatcher.py create mode 100644 tests/helpers/test_dispatcher.py diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index 96b0fc83ea7..cd5bddbad49 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -4,16 +4,20 @@ Support for Envisalink-based alarm control panels (Honeywell/DSC). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.envisalink/ """ -from os import path +import asyncio import logging +import os + import voluptuous as vol +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.components.alarm_control_panel as alarm import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file from homeassistant.components.envisalink import ( - EVL_CONTROLLER, EnvisalinkDevice, PARTITION_SCHEMA, CONF_CODE, CONF_PANIC, - CONF_PARTITIONNAME, SIGNAL_PARTITION_UPDATE, SIGNAL_KEYPAD_UPDATE) + DATA_EVL, EnvisalinkDevice, PARTITION_SCHEMA, CONF_CODE, CONF_PANIC, + CONF_PARTITIONNAME, SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN, STATE_ALARM_TRIGGERED, STATE_ALARM_PENDING, ATTR_ENTITY_ID) @@ -22,8 +26,6 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['envisalink'] -DEVICES = [] - SERVICE_ALARM_KEYPRESS = 'envisalink_alarm_keypress' ATTR_KEYPRESS = 'keypress' ALARM_KEYPRESS_SCHEMA = vol.Schema({ @@ -32,68 +34,72 @@ ALARM_KEYPRESS_SCHEMA = vol.Schema({ }) -def alarm_keypress_handler(service): - """Map services to methods on Alarm.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - keypress = service.data.get(ATTR_KEYPRESS) - - _target_devices = [device for device in DEVICES - if device.entity_id in entity_ids] - - for device in _target_devices: - EnvisalinkAlarm.alarm_keypress(device, keypress) - - -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Perform the setup for Envisalink alarm panels.""" - _configured_partitions = discovery_info['partitions'] - _code = discovery_info[CONF_CODE] - _panic_type = discovery_info[CONF_PANIC] - for part_num in _configured_partitions: - _device_config_data = PARTITION_SCHEMA( - _configured_partitions[part_num]) - _device = EnvisalinkAlarm( - part_num, - _device_config_data[CONF_PARTITIONNAME], - _code, - _panic_type, - EVL_CONTROLLER.alarm_state['partition'][part_num], - EVL_CONTROLLER) - DEVICES.append(_device) + configured_partitions = discovery_info['partitions'] + code = discovery_info[CONF_CODE] + panic_type = discovery_info[CONF_PANIC] - add_devices(DEVICES) + devices = [] + for part_num in configured_partitions: + device_config_data = PARTITION_SCHEMA(configured_partitions[part_num]) + device = EnvisalinkAlarm( + hass, + part_num, + device_config_data[CONF_PARTITIONNAME], + code, + panic_type, + hass.data[DATA_EVL].alarm_state['partition'][part_num], + hass.data[DATA_EVL] + ) + devices.append(device) + + yield from async_add_devices(devices) + + @callback + def alarm_keypress_handler(service): + """Map services to methods on Alarm.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + keypress = service.data.get(ATTR_KEYPRESS) + + target_devices = [device for device in devices + if device.entity_id in entity_ids] + + for device in target_devices: + device.async_alarm_keypress(keypress) # Register Envisalink specific services - descriptions = load_yaml_config_file( - path.join(path.dirname(__file__), 'services.yaml')) + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + hass.services.async_register( + alarm.DOMAIN, SERVICE_ALARM_KEYPRESS, alarm_keypress_handler, + descriptions.get(SERVICE_ALARM_KEYPRESS), schema=ALARM_KEYPRESS_SCHEMA) - hass.services.register(alarm.DOMAIN, SERVICE_ALARM_KEYPRESS, - alarm_keypress_handler, - descriptions.get(SERVICE_ALARM_KEYPRESS), - schema=ALARM_KEYPRESS_SCHEMA) return True class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): """Representation of an Envisalink-based alarm panel.""" - def __init__(self, partition_number, alarm_name, code, panic_type, info, - controller): + def __init__(self, hass, partition_number, alarm_name, code, panic_type, + info, controller): """Initialize the alarm panel.""" - from pydispatch import dispatcher self._partition_number = partition_number self._code = code self._panic_type = panic_type - _LOGGER.debug("Setting up alarm: %s", alarm_name) - EnvisalinkDevice.__init__(self, alarm_name, info, controller) - dispatcher.connect( - self._update_callback, signal=SIGNAL_PARTITION_UPDATE, - sender=dispatcher.Any) - dispatcher.connect( - self._update_callback, signal=SIGNAL_KEYPAD_UPDATE, - sender=dispatcher.Any) + _LOGGER.debug("Setting up alarm: %s", alarm_name) + super().__init__(alarm_name, info, controller) + + async_dispatcher_connect( + hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) + async_dispatcher_connect( + hass, SIGNAL_PARTITION_UPDATE, self._update_callback) + + @callback def _update_callback(self, partition): """Update HA state, if needed.""" if partition is None or int(partition) == self._partition_number: @@ -126,39 +132,44 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): state = STATE_ALARM_DISARMED return state - def alarm_disarm(self, code=None): + @asyncio.coroutine + def async_alarm_disarm(self, code=None): """Send disarm command.""" if code: - EVL_CONTROLLER.disarm_partition(str(code), - self._partition_number) + self.hass.data[DATA_EVL].disarm_partition( + str(code), self._partition_number) else: - EVL_CONTROLLER.disarm_partition(str(self._code), - self._partition_number) + self.hass.data[DATA_EVL].disarm_partition( + str(self._code), self._partition_number) - def alarm_arm_home(self, code=None): + @asyncio.coroutine + def async_alarm_arm_home(self, code=None): """Send arm home command.""" if code: - EVL_CONTROLLER.arm_stay_partition(str(code), - self._partition_number) + self.hass.data[DATA_EVL].arm_stay_partition( + str(code), self._partition_number) else: - EVL_CONTROLLER.arm_stay_partition(str(self._code), - self._partition_number) + self.hass.data[DATA_EVL].arm_stay_partition( + str(self._code), self._partition_number) - def alarm_arm_away(self, code=None): + @asyncio.coroutine + def async_alarm_arm_away(self, code=None): """Send arm away command.""" if code: - EVL_CONTROLLER.arm_away_partition(str(code), - self._partition_number) + self.hass.data[DATA_EVL].arm_away_partition( + str(code), self._partition_number) else: - EVL_CONTROLLER.arm_away_partition(str(self._code), - self._partition_number) + self.hass.data[DATA_EVL].arm_away_partition( + str(self._code), self._partition_number) - def alarm_trigger(self, code=None): + @asyncio.coroutine + def async_alarm_trigger(self, code=None): """Alarm trigger command. Will be used to trigger a panic alarm.""" - EVL_CONTROLLER.panic_alarm(self._panic_type) + self.hass.data[DATA_EVL].panic_alarm(self._panic_type) - def alarm_keypress(self, keypress=None): + @callback + def async_alarm_keypress(self, keypress=None): """Send custom keypress.""" if keypress: - EVL_CONTROLLER.keypresses_to_partition(self._partition_number, - keypress) + self.hass.data[DATA_EVL].keypresses_to_partition( + self._partition_number, keypress) diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 3d10736c9ee..279dadf120f 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -4,48 +4,56 @@ Support for Envisalink zone states- represented as binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.envisalink/ """ +import asyncio import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.envisalink import (EVL_CONTROLLER, - ZONE_SCHEMA, - CONF_ZONENAME, - CONF_ZONETYPE, - EnvisalinkDevice, - SIGNAL_ZONE_UPDATE) +from homeassistant.components.envisalink import ( + DATA_EVL, ZONE_SCHEMA, CONF_ZONENAME, CONF_ZONETYPE, EnvisalinkDevice, + SIGNAL_ZONE_UPDATE) from homeassistant.const import ATTR_LAST_TRIP_TIME DEPENDENCIES = ['envisalink'] _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup Envisalink binary sensor devices.""" - _configured_zones = discovery_info['zones'] - for zone_num in _configured_zones: - _device_config_data = ZONE_SCHEMA(_configured_zones[zone_num]) - _device = EnvisalinkBinarySensor( + configured_zones = discovery_info['zones'] + + devices = [] + for zone_num in configured_zones: + device_config_data = ZONE_SCHEMA(configured_zones[zone_num]) + device = EnvisalinkBinarySensor( + hass, zone_num, - _device_config_data[CONF_ZONENAME], - _device_config_data[CONF_ZONETYPE], - EVL_CONTROLLER.alarm_state['zone'][zone_num], - EVL_CONTROLLER) - add_devices_callback([_device]) + device_config_data[CONF_ZONENAME], + device_config_data[CONF_ZONETYPE], + hass.data[DATA_EVL].alarm_state['zone'][zone_num], + hass.data[DATA_EVL] + ) + devices.append(device) + + yield from async_add_devices(devices) class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): """Representation of an Envisalink binary sensor.""" - def __init__(self, zone_number, zone_name, zone_type, info, controller): + def __init__(self, hass, zone_number, zone_name, zone_type, info, + controller): """Initialize the binary_sensor.""" - from pydispatch import dispatcher self._zone_type = zone_type self._zone_number = zone_number _LOGGER.debug('Setting up zone: ' + zone_name) - EnvisalinkDevice.__init__(self, zone_name, info, controller) - dispatcher.connect(self._update_callback, - signal=SIGNAL_ZONE_UPDATE, - sender=dispatcher.Any) + super().__init__(zone_name, info, controller) + + async_dispatcher_connect( + hass, SIGNAL_ZONE_UPDATE, self._update_callback) @property def device_state_attributes(self): @@ -64,7 +72,8 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): """Return the class of this sensor, from DEVICE_CLASSES.""" return self._zone_type + @callback def _update_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self.hass.schedule_update_ha_state() + self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py index 2c101a227cf..05439213284 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -4,20 +4,24 @@ Support for Envisalink devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/envisalink/ """ +import asyncio import logging -import time + import voluptuous as vol + +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.entity import Entity -from homeassistant.components.discovery import load_platform +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['pyenvisalink==2.0', 'pydispatcher==2.0.5'] +REQUIREMENTS = ['pyenvisalink==2.0'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'envisalink' -EVL_CONTROLLER = None +DATA_EVL = 'envisalink' CONF_EVL_HOST = 'host' CONF_EVL_PORT = 'port' @@ -43,9 +47,9 @@ DEFAULT_ZONEDUMP_INTERVAL = 30 DEFAULT_ZONETYPE = 'opening' DEFAULT_PANIC = 'Police' -SIGNAL_ZONE_UPDATE = 'zones_updated' -SIGNAL_PARTITION_UPDATE = 'partition_updated' -SIGNAL_KEYPAD_UPDATE = 'keypad_updated' +SIGNAL_ZONE_UPDATE = 'envisalink.zones_updated' +SIGNAL_PARTITION_UPDATE = 'envisalink.partition_updated' +SIGNAL_KEYPAD_UPDATE = 'envisalink.keypad_updated' ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONENAME): cv.string, @@ -77,119 +81,111 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=unused-argument -def setup(hass, base_config): +@asyncio.coroutine +def async_setup(hass, config): """Common setup for Envisalink devices.""" from pyenvisalink import EnvisalinkAlarmPanel - from pydispatch import dispatcher - global EVL_CONTROLLER + conf = config.get(DOMAIN) - config = base_config.get(DOMAIN) + host = conf.get(CONF_EVL_HOST) + port = conf.get(CONF_EVL_PORT) + code = conf.get(CONF_CODE) + panel_type = conf.get(CONF_PANEL_TYPE) + panic_type = conf.get(CONF_PANIC) + version = conf.get(CONF_EVL_VERSION) + user = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASS) + keep_alive = conf.get(CONF_EVL_KEEPALIVE) + zone_dump = conf.get(CONF_ZONEDUMP_INTERVAL) + zones = conf.get(CONF_ZONES) + partitions = conf.get(CONF_PARTITIONS) + sync_connect = asyncio.Future(loop=hass.loop) - _host = config.get(CONF_EVL_HOST) - _port = config.get(CONF_EVL_PORT) - _code = config.get(CONF_CODE) - _panel_type = config.get(CONF_PANEL_TYPE) - _panic_type = config.get(CONF_PANIC) - _version = config.get(CONF_EVL_VERSION) - _user = config.get(CONF_USERNAME) - _pass = config.get(CONF_PASS) - _keep_alive = config.get(CONF_EVL_KEEPALIVE) - _zone_dump = config.get(CONF_ZONEDUMP_INTERVAL) - _zones = config.get(CONF_ZONES) - _partitions = config.get(CONF_PARTITIONS) - _connect_status = {} - EVL_CONTROLLER = EnvisalinkAlarmPanel(_host, - _port, - _panel_type, - _version, - _user, - _pass, - _zone_dump, - _keep_alive, - hass.loop) + controller = EnvisalinkAlarmPanel( + host, port, panel_type, version, user, password, zone_dump, + keep_alive, hass.loop) + hass.data[DATA_EVL] = controller + @callback def login_fail_callback(data): """Callback for when the evl rejects our login.""" _LOGGER.error("The envisalink rejected your credentials.") - _connect_status['fail'] = 1 + sync_connect.set_result(False) + @callback def connection_fail_callback(data): """Network failure callback.""" _LOGGER.error("Could not establish a connection with the envisalink.") - _connect_status['fail'] = 1 + sync_connect.set_result(False) + @callback def connection_success_callback(data): """Callback for a successful connection.""" _LOGGER.info("Established a connection with the envisalink.") - _connect_status['success'] = 1 + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) + sync_connect.set_result(True) + @callback def zones_updated_callback(data): """Handle zone timer updates.""" _LOGGER.info("Envisalink sent a zone update event. Updating zones...") - dispatcher.send(signal=SIGNAL_ZONE_UPDATE, - sender=None, - zone=data) + async_dispatcher_send(hass, SIGNAL_ZONE_UPDATE, data) + @callback def alarm_data_updated_callback(data): """Handle non-alarm based info updates.""" _LOGGER.info("Envisalink sent new alarm info. Updating alarms...") - dispatcher.send(signal=SIGNAL_KEYPAD_UPDATE, - sender=None, - partition=data) + async_dispatcher_send(hass, SIGNAL_KEYPAD_UPDATE, data) + @callback def partition_updated_callback(data): """Handle partition changes thrown by evl (including alarms).""" _LOGGER.info("The envisalink sent a partition update event.") - dispatcher.send(signal=SIGNAL_PARTITION_UPDATE, - sender=None, - partition=data) + async_dispatcher_send(hass, SIGNAL_PARTITION_UPDATE, data) + @callback def stop_envisalink(event): """Shutdown envisalink connection and thread on exit.""" _LOGGER.info("Shutting down envisalink.") - EVL_CONTROLLER.stop() + controller.stop() - def start_envisalink(event): - """Startup process for the Envisalink.""" - hass.loop.call_soon_threadsafe(EVL_CONTROLLER.start) - for _ in range(10): - if 'success' in _connect_status: - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) - return True - elif 'fail' in _connect_status: - return False - else: - time.sleep(1) + controller.callback_zone_timer_dump = zones_updated_callback + controller.callback_zone_state_change = zones_updated_callback + controller.callback_partition_state_change = partition_updated_callback + controller.callback_keypad_update = alarm_data_updated_callback + controller.callback_login_failure = login_fail_callback + controller.callback_login_timeout = connection_fail_callback + controller.callback_login_success = connection_success_callback - _LOGGER.error("Timeout occurred while establishing evl connection.") - return False + _LOGGER.info("Start envisalink.") + controller.start() - EVL_CONTROLLER.callback_zone_timer_dump = zones_updated_callback - EVL_CONTROLLER.callback_zone_state_change = zones_updated_callback - EVL_CONTROLLER.callback_partition_state_change = partition_updated_callback - EVL_CONTROLLER.callback_keypad_update = alarm_data_updated_callback - EVL_CONTROLLER.callback_login_failure = login_fail_callback - EVL_CONTROLLER.callback_login_timeout = connection_fail_callback - EVL_CONTROLLER.callback_login_success = connection_success_callback - - _result = start_envisalink(None) - if not _result: + result = yield from sync_connect + if not result: return False # Load sub-components for Envisalink - if _partitions: - load_platform(hass, 'alarm_control_panel', 'envisalink', - {CONF_PARTITIONS: _partitions, - CONF_CODE: _code, - CONF_PANIC: _panic_type}, base_config) - load_platform(hass, 'sensor', 'envisalink', - {CONF_PARTITIONS: _partitions, - CONF_CODE: _code}, base_config) - if _zones: - load_platform(hass, 'binary_sensor', 'envisalink', - {CONF_ZONES: _zones}, base_config) + if partitions: + hass.async_add_job(async_load_platform( + hass, 'alarm_control_panel', 'envisalink', { + CONF_PARTITIONS: partitions, + CONF_CODE: code, + CONF_PANIC: panic_type + }, config + )) + hass.async_add_job(async_load_platform( + hass, 'sensor', 'envisalink', { + CONF_PARTITIONS: partitions, + CONF_CODE: code + }, config + )) + if zones: + hass.async_add_job(async_load_platform( + hass, 'binary_sensor', 'envisalink', { + CONF_ZONES: zones + }, config + )) return True diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index a29179598a8..20142c13c3b 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -4,51 +4,55 @@ Support for Envisalink sensors (shows panel info). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.envisalink/ """ +import asyncio import logging -from homeassistant.components.envisalink import (EVL_CONTROLLER, - PARTITION_SCHEMA, - CONF_PARTITIONNAME, - EnvisalinkDevice, - SIGNAL_PARTITION_UPDATE, - SIGNAL_KEYPAD_UPDATE) + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.envisalink import ( + DATA_EVL, PARTITION_SCHEMA, CONF_PARTITIONNAME, EnvisalinkDevice, + SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE) +from homeassistant.helpers.entity import Entity DEPENDENCIES = ['envisalink'] _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Perform the setup for Envisalink sensor devices.""" - _configured_partitions = discovery_info['partitions'] - for part_num in _configured_partitions: - _device_config_data = PARTITION_SCHEMA( - _configured_partitions[part_num]) - _device = EnvisalinkSensor( - _device_config_data[CONF_PARTITIONNAME], + configured_partitions = discovery_info['partitions'] + + devices = [] + for part_num in configured_partitions: + device_config_data = PARTITION_SCHEMA(configured_partitions[part_num]) + device = EnvisalinkSensor( + hass, + device_config_data[CONF_PARTITIONNAME], part_num, - EVL_CONTROLLER.alarm_state['partition'][part_num], - EVL_CONTROLLER) - add_devices_callback([_device]) + hass.data[DATA_EVL].alarm_state['partition'][part_num], + hass.data[DATA_EVL]) + devices.append(device) + + yield from async_add_devices(devices) -class EnvisalinkSensor(EnvisalinkDevice): +class EnvisalinkSensor(EnvisalinkDevice, Entity): """Representation of an Envisalink keypad.""" - def __init__(self, partition_name, partition_number, info, controller): + def __init__(self, hass, partition_name, partition_number, info, + controller): """Initialize the sensor.""" - from pydispatch import dispatcher self._icon = 'mdi:alarm' self._partition_number = partition_number + _LOGGER.debug('Setting up sensor for partition: ' + partition_name) - EnvisalinkDevice.__init__(self, - partition_name + ' Keypad', - info, - controller) - dispatcher.connect(self._update_callback, - signal=SIGNAL_PARTITION_UPDATE, - sender=dispatcher.Any) - dispatcher.connect(self._update_callback, - signal=SIGNAL_KEYPAD_UPDATE, - sender=dispatcher.Any) + super().__init__(partition_name + ' Keypad', info, controller) + + async_dispatcher_connect( + hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) + async_dispatcher_connect( + hass, SIGNAL_PARTITION_UPDATE, self._update_callback) @property def icon(self): @@ -65,7 +69,8 @@ class EnvisalinkSensor(EnvisalinkDevice): """Return the state attributes.""" return self._info['status'] + @callback def _update_callback(self, partition): """Update the partition state in HA, if needed.""" if partition is None or int(partition) == self._partition_number: - self.hass.schedule_update_ha_state() + self.hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py new file mode 100644 index 00000000000..324d4ccc621 --- /dev/null +++ b/homeassistant/helpers/dispatcher.py @@ -0,0 +1,42 @@ +"""Helpers for hass dispatcher & internal component / platform.""" + +from homeassistant.core import callback + +DATA_DISPATCHER = 'dispatcher' + + +def dispatcher_connect(hass, signal, target): + """Connect a callable function to a singal.""" + hass.add_job(async_dispatcher_connect, hass, signal, target) + + +@callback +def async_dispatcher_connect(hass, signal, target): + """Connect a callable function to a singal. + + This method must be run in the event loop. + """ + if DATA_DISPATCHER not in hass.data: + hass.data[DATA_DISPATCHER] = {} + + if signal not in hass.data[DATA_DISPATCHER]: + hass.data[DATA_DISPATCHER][signal] = [] + + hass.data[DATA_DISPATCHER][signal].append(target) + + +def dispatcher_send(hass, signal, *args): + """Send signal and data.""" + hass.add_job(async_dispatcher_send, hass, signal, *args) + + +@callback +def async_dispatcher_send(hass, signal, *args): + """Send signal and data. + + This method must be run in the event loop. + """ + target_list = hass.data.get(DATA_DISPATCHER, {}).get(signal, []) + + for target in target_list: + hass.async_add_job(target, *args) diff --git a/requirements_all.txt b/requirements_all.txt index 4577801a07d..35931779e87 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -457,7 +457,6 @@ pycmus==0.1.0 # homeassistant.components.sensor.cups # pycups==1.9.73 -# homeassistant.components.envisalink # homeassistant.components.zwave # homeassistant.components.binary_sensor.hikvision pydispatcher==2.0.5 diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py new file mode 100644 index 00000000000..fbac0689ff1 --- /dev/null +++ b/tests/helpers/test_dispatcher.py @@ -0,0 +1,103 @@ +"""Test dispatcher helpers.""" +import asyncio + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + dispatcher_send, dispatcher_connect) + +from tests.common import get_test_home_assistant + + +class TestHelpersDispatcher(object): + """Tests for discovery helper methods.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_simple_function(self): + """Test simple function (executor).""" + calls = [] + + def test_funct(data): + """Test function.""" + calls.append(data) + + dispatcher_connect(self.hass, 'test', test_funct) + self.hass.block_till_done() + + dispatcher_send(self.hass, 'test', 3) + self.hass.block_till_done() + + assert calls == [3] + + dispatcher_send(self.hass, 'test', 'bla') + self.hass.block_till_done() + + assert calls == [3, 'bla'] + + def test_simple_callback(self): + """Test simple callback (async).""" + calls = [] + + @callback + def test_funct(data): + """Test function.""" + calls.append(data) + + dispatcher_connect(self.hass, 'test', test_funct) + self.hass.block_till_done() + + dispatcher_send(self.hass, 'test', 3) + self.hass.block_till_done() + + assert calls == [3] + + dispatcher_send(self.hass, 'test', 'bla') + self.hass.block_till_done() + + assert calls == [3, 'bla'] + + def test_simple_coro(self): + """Test simple coro (async).""" + calls = [] + + @asyncio.coroutine + def test_funct(data): + """Test function.""" + calls.append(data) + + dispatcher_connect(self.hass, 'test', test_funct) + self.hass.block_till_done() + + dispatcher_send(self.hass, 'test', 3) + self.hass.block_till_done() + + assert calls == [3] + + dispatcher_send(self.hass, 'test', 'bla') + self.hass.block_till_done() + + assert calls == [3, 'bla'] + + def test_simple_function_multiargs(self): + """Test simple function (executor).""" + calls = [] + + def test_funct(data1, data2, data3): + """Test function.""" + calls.append(data1) + calls.append(data2) + calls.append(data3) + + dispatcher_connect(self.hass, 'test', test_funct) + self.hass.block_till_done() + + dispatcher_send(self.hass, 'test', 3, 2, 'bla') + self.hass.block_till_done() + + assert calls == [3, 2, 'bla'] From 1d32bced1c722207f0cc06109203736e76059e02 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 23 Feb 2017 23:06:28 +0200 Subject: [PATCH 013/198] Create zwave devices on OZW thread and only add them during discovery (#6096) * Create zwave devices on OZW thread and only add them during discovery. * Read and write devices dict from loop thread. * More async * replace callback with coroutine * import common function instead of callin git --- .../components/binary_sensor/zwave.py | 37 +++++--------- homeassistant/components/climate/zwave.py | 15 ++---- homeassistant/components/cover/zwave.py | 20 +++----- homeassistant/components/light/zwave.py | 20 ++++---- homeassistant/components/lock/zwave.py | 20 +++----- homeassistant/components/sensor/zwave.py | 34 ++++--------- homeassistant/components/switch/zwave.py | 20 +++----- homeassistant/components/zwave/__init__.py | 48 +++++++++++++++---- homeassistant/components/zwave/const.py | 2 + tests/components/zwave/test_init.py | 28 ++++++----- 10 files changed, 114 insertions(+), 130 deletions(-) diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index 3a8144d9188..71c64a017f7 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -10,6 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_point_in_time from homeassistant.components import zwave from homeassistant.components.zwave import workaround +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDevice) @@ -18,31 +19,22 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = [] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Z-Wave platform for binary sensors.""" - if discovery_info is None or zwave.NETWORK is None: - return - - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] +def get_device(value, **kwargs): + """Create zwave entity device.""" value.set_change_verified(False) device_mapping = workaround.get_device_mapping(value) if device_mapping == workaround.WORKAROUND_NO_OFF_EVENT: # Default the multiplier to 4 re_arm_multiplier = (zwave.get_config_value(value.node, 9) or 4) - add_devices([ - ZWaveTriggerSensor(value, "motion", - hass, re_arm_multiplier * 8) - ]) - return + return ZWaveTriggerSensor(value, "motion", re_arm_multiplier * 8) if workaround.get_device_component_mapping(value) == DOMAIN: - add_devices([ZWaveBinarySensor(value, None)]) - return + return ZWaveBinarySensor(value, None) if value.command_class == zwave.const.COMMAND_CLASS_SENSOR_BINARY: - add_devices([ZWaveBinarySensor(value, None)]) + return ZWaveBinarySensor(value, None) + return None class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): @@ -77,26 +69,23 @@ class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): class ZWaveTriggerSensor(ZWaveBinarySensor): """Representation of a stateless sensor within Z-Wave.""" - def __init__(self, value, device_class, hass, re_arm_sec=60): + def __init__(self, value, device_class, re_arm_sec=60): """Initialize the sensor.""" super(ZWaveTriggerSensor, self).__init__(value, device_class) - self._hass = hass self.re_arm_sec = re_arm_sec - self.invalidate_after = dt_util.utcnow() + datetime.timedelta( - seconds=self.re_arm_sec) - # If it's active make sure that we set the timeout tracker - track_point_in_time( - self._hass, self.async_update_ha_state, - self.invalidate_after) + self.invalidate_after = None def update_properties(self): """Called when a value for this entity's node has changed.""" self._state = self._value.data # only allow this value to be true for re_arm secs + if not self.hass: + return + self.invalidate_after = dt_util.utcnow() + datetime.timedelta( seconds=self.re_arm_sec) track_point_in_time( - self._hass, self.async_update_ha_state, + self.hass, self.async_update_ha_state, self.invalidate_after) @property diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index e069c5a1e17..a9524729a9f 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import DOMAIN from homeassistant.components.climate import ClimateDevice from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) @@ -32,19 +33,11 @@ DEVICE_MAPPINGS = { } -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Z-Wave Climate devices.""" - if discovery_info is None or zwave.NETWORK is None: - _LOGGER.debug("No discovery_info=%s or no NETWORK=%s", - discovery_info, zwave.NETWORK) - return +def get_device(hass, value, **kwargs): + """Create zwave entity device.""" temp_unit = hass.config.units.temperature_unit - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] value.set_change_verified(False) - add_devices([ZWaveClimate(value, temp_unit)]) - _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", - discovery_info, zwave.NETWORK) + return ZWaveClimate(value, temp_unit) class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 2d995ca7aca..131ce795d93 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -11,6 +11,7 @@ from homeassistant.components.cover import ( DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.components.zwave import workaround from homeassistant.components.cover import CoverDevice @@ -19,27 +20,20 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE -def setup_platform(hass, config, add_devices, discovery_info=None): - """Find and return Z-Wave covers.""" - if discovery_info is None or zwave.NETWORK is None: - return - - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] - +def get_device(value, **kwargs): + """Create zwave entity device.""" if (value.command_class == zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.index == 0): value.set_change_verified(False) - add_devices([ZwaveRollershutter(value)]) + return ZwaveRollershutter(value) elif (value.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY or value.command_class == zwave.const.COMMAND_CLASS_BARRIER_OPERATOR): if (value.type != zwave.const.TYPE_BOOL and value.genre != zwave.const.GENRE_USER): - return + return None value.set_change_verified(False) - add_devices([ZwaveGarageDoor(value)]) - else: - return + return ZwaveGarageDoor(value) + return None class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 0c5cf1d081e..36ef7eca21d 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -13,6 +13,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, \ ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, \ SUPPORT_RGB_COLOR, DOMAIN, Light from homeassistant.components import zwave +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.util.color import HASS_COLOR_MAX, HASS_COLOR_MIN, \ color_temperature_mired_to_kelvin, color_temperature_to_rgb, \ @@ -48,32 +49,27 @@ SUPPORT_ZWAVE_COLORTEMP = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR | SUPPORT_COLOR_TEMP) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Find and add Z-Wave lights.""" - if discovery_info is None or zwave.NETWORK is None: - return - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] +def get_device(node, value, node_config, **kwargs): + """Create zwave entity device.""" name = '{}.{}'.format(DOMAIN, zwave.object_id(value)) - node_config = hass.data[zwave.DATA_DEVICE_CONFIG].get(name) refresh = node_config.get(zwave.CONF_REFRESH_VALUE) delay = node_config.get(zwave.CONF_REFRESH_DELAY) _LOGGER.debug('name=%s node_config=%s CONF_REFRESH_VALUE=%s' ' CONF_REFRESH_DELAY=%s', name, node_config, refresh, delay) if value.command_class != zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL: - return + return None if value.type != zwave.const.TYPE_BYTE: - return + return None if value.genre != zwave.const.GENRE_USER: - return + return None value.set_change_verified(False) if node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_COLOR): - add_devices([ZwaveColorLight(value, refresh, delay)]) + return ZwaveColorLight(value, refresh, delay) else: - add_devices([ZwaveDimmer(value, refresh, delay)]) + return ZwaveDimmer(value, refresh, delay) def brightness_state(value): diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 3b01138ccb2..86ded53bae9 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.lock import DOMAIN, LockDevice from homeassistant.components import zwave +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv @@ -119,15 +120,8 @@ CLEAR_USERCODE_SCHEMA = vol.Schema({ }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Find and return Z-Wave locks.""" - if discovery_info is None or zwave.NETWORK is None: - return - - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] - +def get_device(hass, node, value, **kwargs): + """Create zwave entity device.""" descriptions = load_yaml_config_file( path.join(path.dirname(__file__), 'services.yaml')) @@ -182,11 +176,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): break if value.command_class != zwave.const.COMMAND_CLASS_DOOR_LOCK: - return + return None if value.type != zwave.const.TYPE_BOOL: - return + return None if value.genre != zwave.const.GENRE_USER: - return + return None if node.has_command_class(zwave.const.COMMAND_CLASS_USER_CODE): hass.services.register(DOMAIN, SERVICE_SET_USERCODE, @@ -204,7 +198,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): descriptions.get(SERVICE_CLEAR_USERCODE), schema=CLEAR_USERCODE_SCHEMA) value.set_change_verified(False) - add_devices([ZwaveLock(value)]) + return ZwaveLock(value) class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index e220825d526..03f85ddbda4 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -10,41 +10,25 @@ import logging from homeassistant.components.sensor import DOMAIN from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup Z-Wave sensors.""" - # Return on empty `discovery_info`. Given you configure HA with: - # - # sensor: - # platform: zwave - # - # `setup_platform` will be called without `discovery_info`. - if discovery_info is None or zwave.NETWORK is None: - return - - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] - +def get_device(node, value, **kwargs): + """Create zwave entity device.""" value.set_change_verified(False) - # if 1 in groups and (NETWORK.controller.node_id not in - # groups[1].associations): - # node.groups[1].add_association(NETWORK.controller.node_id) - # Generic Device mappings if node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_MULTILEVEL): - add_devices([ZWaveMultilevelSensor(value)]) - - elif node.has_command_class(zwave.const.COMMAND_CLASS_METER) and \ + return ZWaveMultilevelSensor(value) + if node.has_command_class(zwave.const.COMMAND_CLASS_METER) and \ value.type == zwave.const.TYPE_DECIMAL: - add_devices([ZWaveMultilevelSensor(value)]) - - elif node.has_command_class(zwave.const.COMMAND_CLASS_ALARM) or \ + return ZWaveMultilevelSensor(value) + if node.has_command_class(zwave.const.COMMAND_CLASS_ALARM) or \ node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_ALARM): - add_devices([ZWaveAlarmSensor(value)]) + return ZWaveAlarmSensor(value) + return None class ZWaveSensor(zwave.ZWaveDeviceEntity): diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index 1a844ebcfe0..9942743d326 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -9,27 +9,21 @@ import logging # pylint: disable=import-error from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components import zwave +from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Z-Wave platform.""" - if discovery_info is None or zwave.NETWORK is None: - return - - node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]] - value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]] - +def get_device(node, value, **kwargs): + """Create zwave entity device.""" if not node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_BINARY): - return + return None if value.type != zwave.const.TYPE_BOOL or value.genre != \ - zwave.const.GENRE_USER: - return - + zwave.const.GENRE_USER: + return None value.set_change_verified(False) - add_devices([ZwaveSwitch(value)]) + return ZwaveSwitch(value) class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice): diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index f0c5e54bae0..033cedac705 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -4,6 +4,7 @@ Support for Z-Wave. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zwave/ """ +import asyncio import logging import os.path import time @@ -11,6 +12,7 @@ from pprint import pprint import voluptuous as vol +from homeassistant.loader import get_platform from homeassistant.helpers import discovery from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_LOCATION, ATTR_ENTITY_ID, ATTR_WAKEUP, @@ -54,8 +56,10 @@ DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 DOMAIN = 'zwave' +DATA_ZWAVE_DICT = 'zwave_devices' + NETWORK = None -DATA_DEVICE_CONFIG = 'zwave_device_config' + # List of tuple (DOMAIN, discovered service, supported command classes, # value type, genre type, specific device class). @@ -264,6 +268,20 @@ def get_config_value(node, value_index, tries=5): return None +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Generic Z-Wave platform setup.""" + if discovery_info is None or NETWORK is None: + return False + device = hass.data[DATA_ZWAVE_DICT].pop( + discovery_info[const.DISCOVERY_DEVICE]) + if device: + yield from async_add_devices([device]) + return True + else: + return False + + # pylint: disable=R0914 def setup(hass, config): """Setup Z-Wave. @@ -294,7 +312,7 @@ def setup(hass, config): # Load configuration use_debug = config[DOMAIN].get(CONF_DEBUG) autoheal = config[DOMAIN].get(CONF_AUTOHEAL) - hass.data[DATA_DEVICE_CONFIG] = EntityValues( + device_config = EntityValues( config[DOMAIN][CONF_DEVICE_CONFIG], config[DOMAIN][CONF_DEVICE_CONFIG_DOMAIN], config[DOMAIN][CONF_DEVICE_CONFIG_GLOB]) @@ -310,6 +328,7 @@ def setup(hass, config): options.lock() NETWORK = ZWaveNetwork(options, autostart=False) + hass.data[DATA_ZWAVE_DICT] = {} if use_debug: def log_all(signal, value=None): @@ -386,7 +405,7 @@ def setup(hass, config): component = workaround_component name = "{}.{}".format(component, object_id(value)) - node_config = hass.data[DATA_DEVICE_CONFIG].get(name) + node_config = device_config.get(name) if node_config.get(CONF_IGNORED): _LOGGER.info( @@ -399,11 +418,21 @@ def setup(hass, config): value.enable_poll(polling_intensity) else: value.disable_poll() + platform = get_platform(component, DOMAIN) + device = platform.get_device( + node=node, value=value, node_config=node_config, hass=hass) + if not device: + continue + dict_id = value.value_id - discovery.load_platform(hass, component, DOMAIN, { - const.ATTR_NODE_ID: node.node_id, - const.ATTR_VALUE_ID: value.value_id, - }, config) + @asyncio.coroutine + def discover_device(component, device, dict_id): + """Put device in a dictionary and call discovery on it.""" + hass.data[DATA_ZWAVE_DICT][dict_id] = device + yield from discovery.async_load_platform( + hass, component, DOMAIN, + {const.DISCOVERY_DEVICE: dict_id}, config) + hass.add_job(discover_device, component, device, dict_id) def scene_activated(node, scene_id): """Called when a scene is activated on any node in the network.""" @@ -694,7 +723,10 @@ class ZWaveDeviceEntity(Entity): """Called when a value for this entity's node has changed.""" self._update_attributes() self.update_properties() - self.schedule_update_ha_state() + # If value changed after device was created but before setup_platform + # was called - skip updating state. + if self.hass: + self.schedule_update_ha_state() def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index e9a17395735..881f20cd0fc 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -15,6 +15,8 @@ ATTR_CONFIG_SIZE = "size" ATTR_CONFIG_VALUE = "value" NETWORK_READY_WAIT_SECS = 30 +DISCOVERY_DEVICE = 'device' + SERVICE_CHANGE_ASSOCIATION = "change_association" SERVICE_ADD_NODE = "add_node" SERVICE_ADD_NODE_SECURE = "add_node_secure" diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 71f5a258cdc..bf46fd33619 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -5,8 +5,6 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.bootstrap import async_setup_component -from homeassistant.components.zwave import ( - DATA_DEVICE_CONFIG, DEVICE_CONFIG_SCHEMA_ENTRY) @pytest.fixture(autouse=True) @@ -24,24 +22,32 @@ def mock_openzwave(): @asyncio.coroutine -def test_device_config(hass): - """Test device config stored in hass.""" +def test_valid_device_config(hass): + """Test valid device config.""" device_config = { 'light.kitchen': { 'ignored': 'true' } } - yield from async_setup_component(hass, 'zwave', { + result = yield from async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - assert DATA_DEVICE_CONFIG in hass.data + assert result - test_data = { - key: DEVICE_CONFIG_SCHEMA_ENTRY(value) - for key, value in device_config.items() + +@asyncio.coroutine +def test_invalid_device_config(hass): + """Test invalid device config.""" + device_config = { + 'light.kitchen': { + 'some_ignored': 'true' + } } + result = yield from async_setup_component(hass, 'zwave', { + 'zwave': { + 'device_config': device_config + }}) - assert hass.data[DATA_DEVICE_CONFIG].get('light.kitchen') == \ - test_data.get('light.kitchen') + assert not result From fc5e25a07bab728452936833053f7b96a25e6df7 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Thu, 23 Feb 2017 18:03:49 -0500 Subject: [PATCH 014/198] Incorporate comment suggestions - Separate attribs from coffeemaker condition - Set power units for threshold to mW to be consistent with others - Adjust on-time labels to be more clear --- homeassistant/components/switch/wemo.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 0768b62062d..1c9cbe15a33 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -110,18 +110,16 @@ class WemoSwitch(SwitchDevice): if self.insight_params or (self.coffeemaker_mode is not None): attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state - attr['current_power_w'] = \ - self.insight_params['currentpower'] / 1000 - attr['today_power_mW_min'] = self.insight_params['todaymw'] - attr['total_power_mW_min'] = self.insight_params['totalmw'] - attr['on_time_most_recent'] = \ + + if self.insight_params: + attr['on_latest_time'] = \ WemoSwitch.as_uptime(self.insight_params['onfor']) - attr['on_time_today'] = \ + attr['on_today_time'] = \ WemoSwitch.as_uptime(self.insight_params['ontoday']) - attr['on_time_total'] = \ + attr['on_total_time'] = \ WemoSwitch.as_uptime(self.insight_params['ontotal']) - attr['power_threshold_w'] = \ - self.insight_params['powerthreshold'] / 1000 + attr['power_threshold_mw'] = \ + self.insight_params['powerthreshold'] if self.coffeemaker_mode is not None: attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode From c940d26f07ce32d762774cf5095beca61346f8ac Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 24 Feb 2017 06:06:21 +0200 Subject: [PATCH 015/198] Bugfix restore startup state (#6189) --- homeassistant/components/history.py | 1 + homeassistant/components/input_boolean.py | 2 +- homeassistant/helpers/restore_state.py | 12 +++++++----- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index c4eada498da..254115c55b1 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -36,6 +36,7 @@ IGNORE_DOMAINS = ('zone', 'scene',) def last_recorder_run(): """Retireve the last closed recorder run from the DB.""" + recorder.get_instance() rec_runs = recorder.get_model('RecorderRuns') with recorder.session_scope() as session: res = recorder.query(rec_runs).order_by(rec_runs.end.desc()).first() diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 1817181b184..290820f3bd4 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -146,7 +146,7 @@ class InputBoolean(ToggleEntity): state = yield from async_get_last_state(self.hass, self.entity_id) if not state: return - self._state = state.state == 'on' + self._state = state.state == STATE_ON @asyncio.coroutine def async_turn_on(self, **kwargs): diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index dfed0f52413..1e463d316d4 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -48,13 +48,15 @@ def _load_restore_cache(hass: HomeAssistant): @asyncio.coroutine def async_get_last_state(hass, entity_id: str): """Helper to restore state.""" - if (_RECORDER not in hass.config.components or - hass.state != CoreState.starting): - return None - if DATA_RESTORE_CACHE in hass.data: return hass.data[DATA_RESTORE_CACHE].get(entity_id) + if (_RECORDER not in hass.config.components or + hass.state not in (CoreState.starting, CoreState.not_running)): + _LOGGER.error("Cache can only be loaded during startup, not %s", + hass.state) + return None + if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) @@ -63,7 +65,7 @@ def async_get_last_state(hass, entity_id: str): yield from hass.loop.run_in_executor( None, _load_restore_cache, hass) - return hass.data[DATA_RESTORE_CACHE].get(entity_id) + return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) @asyncio.coroutine From 58eb32bce450914a34c7b28a3289cd28f849390d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Feb 2017 21:44:47 -0800 Subject: [PATCH 016/198] Random test fixes (#6195) * Store persistent errors in hass (speeds up tests) * Fix sleepiq test dependency on test order * Fix sleepiq validation --- homeassistant/bootstrap.py | 11 ++++++++--- homeassistant/components/sleepiq.py | 2 +- tests/components/sensor/test_sleepiq.py | 8 ++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c6709aea7cc..cb32fc887c9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = 'component' ERROR_LOG_FILENAME = 'home-assistant.log' -_PERSISTENT_ERRORS = {} +DATA_PERSISTENT_ERRORS = 'bootstrap_persistent_errors' HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)' @@ -601,9 +601,14 @@ def _async_persistent_notification(hass: core.HomeAssistant, component: str, This method must be run in the event loop. """ - _PERSISTENT_ERRORS[component] = _PERSISTENT_ERRORS.get(component) or link + errors = hass.data.get(DATA_PERSISTENT_ERRORS) + + if errors is None: + errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + + errors[component] = errors.get(component) or link _lst = [HA_COMPONENT_URL.format(name.replace('_', '-'), name) - if link else name for name, link in _PERSISTENT_ERRORS.items()] + if link else name for name, link in errors.items()] message = ('The following components and platforms could not be set up:\n' '* ' + '\n* '.join(list(_lst)) + '\nPlease check your config') persistent_notification.async_create( diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py index 7016cd72492..610f4e79bb2 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) DATA = None CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ + vol.Required(DOMAIN): vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, }), diff --git a/tests/components/sensor/test_sleepiq.py b/tests/components/sensor/test_sleepiq.py index 765acb56ec9..2d754daa6d8 100644 --- a/tests/components/sensor/test_sleepiq.py +++ b/tests/components/sensor/test_sleepiq.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import requests_mock +from homeassistant.bootstrap import setup_component from homeassistant.components.sensor import sleepiq from tests.components.test_sleepiq import mock_responses @@ -39,6 +40,13 @@ class TestSleepIQSensorSetup(unittest.TestCase): """Test for successfully setting up the SleepIQ platform.""" mock_responses(mock) + assert setup_component(self.hass, 'sleepiq', { + 'sleepiq': { + 'username': '', + 'password': '', + } + }) + sleepiq.setup_platform(self.hass, self.config, self.add_devices, From 34a7aa237669e672b23cee9bde78b184f2847285 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Feb 2017 21:57:48 -0800 Subject: [PATCH 017/198] Extend test for group config --- tests/components/config/test_group.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py index dc9b7f06c1f..223b556dce3 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -80,6 +80,7 @@ def test_update_device_config(hass, test_client): resp = yield from client.post( '/api/config/group/config/hello_beer', data=json.dumps({ 'name': 'Beer', + 'entities': ['light.top', 'light.bottom'], })) assert resp.status == 200 @@ -87,6 +88,7 @@ def test_update_device_config(hass, test_client): assert result == {'result': 'ok'} orig_data['hello_beer']['name'] = 'Beer' + orig_data['hello_beer']['entities'] = ['light.top', 'light.bottom'] assert written[0] == orig_data From 3a35642dc1b8648f179f6933f406eb97c4ba3698 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Feb 2017 22:40:21 -0800 Subject: [PATCH 018/198] Remove automatically reloading group config (#6197) --- homeassistant/components/config/group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/config/group.py b/homeassistant/components/config/group.py index 3c719440001..5c0fd23300e 100644 --- a/homeassistant/components/config/group.py +++ b/homeassistant/components/config/group.py @@ -2,7 +2,7 @@ import asyncio from homeassistant.components.config import EditKeyBasedConfigView -from homeassistant.components.group import GROUP_SCHEMA, async_reload +from homeassistant.components.group import GROUP_SCHEMA import homeassistant.helpers.config_validation as cv @@ -14,6 +14,6 @@ def async_setup(hass): """Setup the Group config API.""" hass.http.register_view(EditKeyBasedConfigView( 'group', 'config', CONFIG_PATH, cv.slug, - GROUP_SCHEMA, post_write_hook=async_reload + GROUP_SCHEMA )) return True From e2e8b4390252b999a9c07368d9f1d8a323b2de21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Feb 2017 22:53:16 -0800 Subject: [PATCH 019/198] Default config to setup group editor (#6198) --- homeassistant/config.py | 8 ++++++++ tests/test_config.py | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index f4cb1e5248b..d6b1151a14f 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -89,6 +89,7 @@ sensor: tts: platform: google +group: !include groups.yaml """ @@ -147,8 +148,12 @@ def create_default_config(config_dir, detect_location=True): Return path to new config file if success, None if failed. This method needs to run in an executor. """ + from homeassistant.components.config.group import ( + CONFIG_PATH as GROUP_CONFIG_PATH) + config_path = os.path.join(config_dir, YAML_CONFIG_FILE) version_path = os.path.join(config_dir, VERSION_FILE) + group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} @@ -187,6 +192,9 @@ def create_default_config(config_dir, detect_location=True): with open(version_path, 'wt') as version_file: version_file.write(__version__) + with open(group_yaml_path, 'w'): + pass + return config_path except IOError: diff --git a/tests/test_config.py b/tests/test_config.py index 748c5b5cc2d..18b69f81a9d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,8 @@ from homeassistant.const import ( from homeassistant.util import location as location_util, dt as dt_util from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.helpers.entity import Entity +from homeassistant.components.config.group import ( + CONFIG_PATH as GROUP_CONFIG_PATH) from tests.common import ( get_test_config_dir, get_test_home_assistant, mock_coro) @@ -23,6 +25,7 @@ from tests.common import ( CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) +GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -51,13 +54,18 @@ class TestConfig(unittest.TestCase): if os.path.isfile(VERSION_PATH): os.remove(VERSION_PATH) + if os.path.isfile(GROUP_PATH): + os.remove(GROUP_PATH) + self.hass.stop() def test_create_default_config(self): """Test creation of default config.""" config_util.create_default_config(CONFIG_DIR, False) - self.assertTrue(os.path.isfile(YAML_PATH)) + assert os.path.isfile(YAML_PATH) + assert os.path.isfile(VERSION_PATH) + assert os.path.isfile(GROUP_PATH) def test_find_config_file_yaml(self): """Test if it finds a YAML config file.""" From c4f4a9a158d42d87858af0f7d47ee4f69f724710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 24 Feb 2017 09:49:42 +0100 Subject: [PATCH 020/198] minor broadlink fix (#6202) --- homeassistant/components/sensor/broadlink.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 6c628f4920e..76dae8df4c7 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -112,9 +112,9 @@ class BroadlinkData(object): self._device.timeout = timeout self.update = Throttle(interval)(self._update) if not self._auth(): - _LOGGER.error("Failed to connect to device.") + _LOGGER.warning("Failed to connect to device.") - def _update(self, retry=2): + def _update(self, retry=3): try: data = self._device.check_sensors_raw() if (data is not None and data.get('humidity', 0) <= 100 and @@ -127,11 +127,10 @@ class BroadlinkData(object): if retry < 1: _LOGGER.error(error) return - if retry < 1 or not self._auth(): - return - self._update(retry-1) + if retry > 0 and self._auth(): + self._update(retry-1) - def _auth(self, retry=2): + def _auth(self, retry=3): try: auth = self._device.auth() except socket.timeout: From 9f04b555729f79525d5b19611dc55f6488506c1b Mon Sep 17 00:00:00 2001 From: Lindsay Ward Date: Fri, 24 Feb 2017 23:13:55 +1000 Subject: [PATCH 021/198] Update Yeelight Sunflower light platform to 0.0.6 (#6208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- homeassistant/components/light/yeelightsunflower.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/yeelightsunflower.py b/homeassistant/components/light/yeelightsunflower.py index df24f41edbe..ead00d97f64 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -17,7 +17,7 @@ from homeassistant.components.light import (Light, from homeassistant.const import CONF_HOST import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yeelightsunflower==0.0.5'] +REQUIREMENTS = ['yeelightsunflower==0.0.6'] SUPPORT_YEELIGHT_SUNFLOWER = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 35931779e87..3931a14c5fb 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -760,7 +760,7 @@ yahooweather==0.8 yeelight==0.2.2 # homeassistant.components.light.yeelightsunflower -yeelightsunflower==0.0.5 +yeelightsunflower==0.0.6 # homeassistant.components.light.zengge zengge==0.2 From b27ba9660b960375efb7fa0f466351e81a890650 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 24 Feb 2017 16:17:27 +0200 Subject: [PATCH 022/198] Some zwave cleanup (#6203) --- homeassistant/components/binary_sensor/zwave.py | 2 -- homeassistant/components/climate/zwave.py | 1 - homeassistant/components/cover/zwave.py | 5 ----- homeassistant/components/light/zwave.py | 8 -------- homeassistant/components/lock/zwave.py | 7 ------- homeassistant/components/sensor/zwave.py | 2 -- homeassistant/components/switch/zwave.py | 9 +-------- homeassistant/components/zwave/__init__.py | 1 + 8 files changed, 2 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index 71c64a017f7..48ef1479eec 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -21,8 +21,6 @@ DEPENDENCIES = [] def get_device(value, **kwargs): """Create zwave entity device.""" - value.set_change_verified(False) - device_mapping = workaround.get_device_mapping(value) if device_mapping == workaround.WORKAROUND_NO_OFF_EVENT: # Default the multiplier to 4 diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index a9524729a9f..ad6c89bcea1 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -36,7 +36,6 @@ DEVICE_MAPPINGS = { def get_device(hass, value, **kwargs): """Create zwave entity device.""" temp_unit = hass.config.units.temperature_unit - value.set_change_verified(False) return ZWaveClimate(value, temp_unit) diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 131ce795d93..aa2cdf858fd 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -24,14 +24,9 @@ def get_device(value, **kwargs): """Create zwave entity device.""" if (value.command_class == zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.index == 0): - value.set_change_verified(False) return ZwaveRollershutter(value) elif (value.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY or value.command_class == zwave.const.COMMAND_CLASS_BARRIER_OPERATOR): - if (value.type != zwave.const.TYPE_BOOL and - value.genre != zwave.const.GENRE_USER): - return None - value.set_change_verified(False) return ZwaveGarageDoor(value) return None diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 36ef7eca21d..84aebffab0e 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -57,14 +57,6 @@ def get_device(node, value, node_config, **kwargs): _LOGGER.debug('name=%s node_config=%s CONF_REFRESH_VALUE=%s' ' CONF_REFRESH_DELAY=%s', name, node_config, refresh, delay) - if value.command_class != zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL: - return None - if value.type != zwave.const.TYPE_BYTE: - return None - if value.genre != zwave.const.GENRE_USER: - return None - - value.set_change_verified(False) if node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_COLOR): return ZwaveColorLight(value, refresh, delay) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 86ded53bae9..ba1df32130d 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -175,12 +175,6 @@ def get_device(hass, node, value, **kwargs): _LOGGER.info('Usercode at slot %s is cleared', value.index) break - if value.command_class != zwave.const.COMMAND_CLASS_DOOR_LOCK: - return None - if value.type != zwave.const.TYPE_BOOL: - return None - if value.genre != zwave.const.GENRE_USER: - return None if node.has_command_class(zwave.const.COMMAND_CLASS_USER_CODE): hass.services.register(DOMAIN, SERVICE_SET_USERCODE, @@ -197,7 +191,6 @@ def get_device(hass, node, value, **kwargs): clear_usercode, descriptions.get(SERVICE_CLEAR_USERCODE), schema=CLEAR_USERCODE_SCHEMA) - value.set_change_verified(False) return ZwaveLock(value) diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index 03f85ddbda4..0d10a470b07 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -17,8 +17,6 @@ _LOGGER = logging.getLogger(__name__) def get_device(node, value, **kwargs): """Create zwave entity device.""" - value.set_change_verified(False) - # Generic Device mappings if node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_MULTILEVEL): return ZWaveMultilevelSensor(value) diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index 9942743d326..a9166c8352f 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -14,15 +14,8 @@ from homeassistant.components.zwave import async_setup_platform # noqa # pylint _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument -def get_device(node, value, **kwargs): +def get_device(value, **kwargs): """Create zwave entity device.""" - if not node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_BINARY): - return None - if value.type != zwave.const.TYPE_BOOL or value.genre != \ - zwave.const.GENRE_USER: - return None - value.set_change_verified(False) return ZwaveSwitch(value) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 033cedac705..c18a87710fe 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -706,6 +706,7 @@ class ZWaveDeviceEntity(Entity): from openzwave.network import ZWaveNetwork from pydispatch import dispatcher self._value = value + self._value.set_change_verified(False) self.entity_id = "{}.{}".format(domain, self._object_id()) self._update_attributes() From 8aa3124aa68adda194cd934ab135e6ba0980d449 Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Fri, 24 Feb 2017 18:40:52 +0100 Subject: [PATCH 023/198] sensor.speedtest: provide a default icon (#6207) --- homeassistant/components/sensor/speedtest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 72fed725c05..00d8d24853e 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -34,6 +34,8 @@ CONF_DAY = 'day' CONF_SERVER_ID = 'server_id' CONF_MANUAL = 'manual' +ICON = 'mdi:speedometer' + SENSOR_TYPES = { 'ping': ['Ping', 'ms'], 'download': ['Download', 'Mbit/s'], @@ -103,6 +105,11 @@ class SpeedtestSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + @property + def icon(self): + """Return icon.""" + return ICON + def update(self): """Get the latest data and update the states.""" data = self.speedtest_client.data From c7fcd98cadb4e2e6929161b886eb6f942553ce81 Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Fri, 24 Feb 2017 21:54:31 +0200 Subject: [PATCH 024/198] Test the temperature returned by RM2 (#6205) * Test the temperature returned by RM2 * Validate fields via voluptuous * Fixed range for humidity --- homeassistant/components/sensor/broadlink.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 76dae8df4c7..38806959f55 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -32,7 +32,7 @@ SENSOR_TYPES = { 'air_quality': ['Air Quality', ' '], 'humidity': ['Humidity', '%'], 'light': ['Light', ' '], - 'noise': ['Noise', ' '] + 'noise': ['Noise', ' '], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -110,6 +110,13 @@ class BroadlinkData(object): self.data = None self._device = broadlink.a1((ip_addr, 80), mac_addr) self._device.timeout = timeout + self._schema = vol.Schema({ + vol.Optional('temperature'): vol.Range(min=-50, max=150), + vol.Optional('humidity'): vol.Range(min=0, max=100), + vol.Optional('light'): vol.Any(0, 1, 2, 3), + vol.Optional('air_quality'): vol.Any(0, 1, 2, 3), + vol.Optional('noise'): vol.Any(0, 1, 2), + }) self.update = Throttle(interval)(self._update) if not self._auth(): _LOGGER.warning("Failed to connect to device.") @@ -117,16 +124,15 @@ class BroadlinkData(object): def _update(self, retry=3): try: data = self._device.check_sensors_raw() - if (data is not None and data.get('humidity', 0) <= 100 and - data.get('light', 0) in [0, 1, 2, 3] and - data.get('air_quality', 0) in [0, 1, 2, 3] and - data.get('noise', 0) in [0, 1, 2]): - self.data = data + if data is not None: + self.data = self._schema(data) return except socket.timeout as error: if retry < 1: _LOGGER.error(error) return + except vol.Invalid: + pass # Continue quietly if device returned malformed data if retry > 0 and self._auth(): self._update(retry-1) From 8ca897da5717d72fedb98e085c8c994867531960 Mon Sep 17 00:00:00 2001 From: Zac Hatfield Dodds Date: Sat, 25 Feb 2017 08:45:46 +1100 Subject: [PATCH 025/198] Zamg weather (#5894) * Fast & efficient updates for ZAMG weather data ZAMG updates on the hour, so instead of checking every half-hour we can check each minute - only after the observations are taken until receiving them. * sensor.zamg: test instead of whitelist for station_id * Autodetect closest ZAMG station if not given * ZAMG weather component, based on the sensor * Review improvements * Update to new ZAMG schema, add logging Turns out it wasn't a typo, but rather an upstream schema change. Added better error handling to ease diagnosis in case it happens again. * No hardcoded name --- .coveragerc | 1 + homeassistant/components/sensor/zamg.py | 165 +++++++++++++++-------- homeassistant/components/weather/zamg.py | 107 +++++++++++++++ 3 files changed, 220 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/weather/zamg.py diff --git a/.coveragerc b/.coveragerc index 43de8df4088..50bf08b0279 100644 --- a/.coveragerc +++ b/.coveragerc @@ -411,6 +411,7 @@ omit = homeassistant/components/upnp.py homeassistant/components/weather/bom.py homeassistant/components/weather/openweathermap.py + homeassistant/components/weather/zamg.py homeassistant/components/zeroconf.py diff --git a/homeassistant/components/sensor/zamg.py b/homeassistant/components/sensor/zamg.py index 6b500460d7b..3d5f6146a39 100644 --- a/homeassistant/components/sensor/zamg.py +++ b/homeassistant/components/sensor/zamg.py @@ -5,9 +5,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.zamg/ """ import csv +from datetime import datetime, timedelta +import gzip +import json import logging -from datetime import timedelta +import os +import pytz import requests import voluptuous as vol @@ -17,7 +21,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED) from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, CONF_NAME, __version__) + CONF_MONITORED_CONDITIONS, CONF_NAME, __version__, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -29,15 +34,8 @@ CONF_STATION_ID = 'station_id' DEFAULT_NAME = 'zamg' -# Data source only updates once per hour, so throttle to 30 min to have -# reasonably recent data -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) - -VALID_STATION_IDS = ( - '11010', '11012', '11022', '11035', '11036', '11101', '11121', '11126', - '11130', '11150', '11155', '11157', '11171', '11190', '11204', '11240', - '11244', '11265', '11331', '11343', '11389' -) +# Data source updates once per hour, so we do nothing if it's been less time +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { ATTR_WEATHER_PRESSURE: ('Pressure', 'hPa', 'LDstat hPa', float), @@ -62,24 +60,33 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Required(CONF_STATION_ID): - vol.All(cv.string, vol.In(VALID_STATION_IDS)), + vol.Optional(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the ZAMG sensor platform.""" - station_id = config.get(CONF_STATION_ID) - name = config.get(CONF_NAME) - logger = logging.getLogger(__name__) + + station_id = config.get(CONF_STATION_ID) or closest_station( + config.get(CONF_LATITUDE), + config.get(CONF_LONGITUDE), + hass.config.config_dir) + if station_id not in zamg_stations(hass.config.config_dir): + logger.error("Configured ZAMG %s (%s) is not a known station", + CONF_STATION_ID, station_id) + return False + probe = ZamgData(station_id=station_id, logger=logger) + try: + probe.update() + except ValueError as err: + logger.error("Received error from ZAMG: %s", err) + return False - sensors = [ZamgSensor(probe, variable, name) - for variable in config[CONF_MONITORED_CONDITIONS]] - - add_devices(sensors, True) + add_devices([ZamgSensor(probe, variable, config.get(CONF_NAME)) + for variable in config[CONF_MONITORED_CONDITIONS]], True) class ZamgSensor(Entity): @@ -117,8 +124,7 @@ class ZamgSensor(Entity): return { ATTR_WEATHER_ATTRIBUTION: ATTRIBUTION, ATTR_STATION: self.probe.get_data('station_name'), - ATTR_UPDATED: '{} {}'.format(self.probe.get_data('update_date'), - self.probe.get_data('update_time')), + ATTR_UPDATED: self.probe.last_update.isoformat(), } @@ -126,10 +132,6 @@ class ZamgData(object): """The class for handling the data retrieval.""" API_URL = 'http://www.zamg.ac.at/ogd/' - API_FIELDS = { - v[2]: (k, v[3]) - for k, v in SENSOR_TYPES.items() - } API_HEADERS = { 'User-Agent': '{} {}'.format('home-assistant.zamg/', __version__), } @@ -140,40 +142,97 @@ class ZamgData(object): self._station_id = station_id self.data = {} + @property + def last_update(self): + """Return the timestamp of the most recent data.""" + date, time = self.data.get('update_date'), self.data.get('update_time') + if date is not None and time is not None: + return datetime.strptime(date + time, '%d-%m-%Y%H:%M').replace( + tzinfo=pytz.timezone('Europe/Vienna')) + + @classmethod + def current_observations(cls): + """Fetch the latest CSV data.""" + try: + response = requests.get( + cls.API_URL, headers=cls.API_HEADERS, timeout=15) + response.raise_for_status() + return csv.DictReader(response.text.splitlines(), + delimiter=';', quotechar='"') + except Exception: # pylint:disable=broad-except + logging.getLogger(__name__).exception("While fetching data") + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from ZAMG.""" - try: - response = requests.get( - self.API_URL, headers=self.API_HEADERS, timeout=15) - except requests.exceptions.RequestException: - self._logger.exception("While fetching data from server") - return + if self.last_update and (self.last_update + timedelta(hours=1) > + datetime.utcnow().replace(tzinfo=pytz.utc)): + return # Not time to update yet; data is only hourly - if response.status_code != 200: - self._logger.error("API call returned with status %s", - response.status_code) - return - - content_type = response.headers.get('Content-Type', 'whatever') - if content_type != 'text/csv': - self._logger.error("Expected text/csv but got %s", content_type) - return - - response.encoding = 'UTF8' - content = response.text - data = (line for line in content.split('\n')) - reader = csv.DictReader(data, delimiter=';', quotechar='"') - for row in reader: - if row.get("Station", None) == self._station_id: + for row in self.current_observations(): + if row.get('Station') == self._station_id: + api_fields = {col_heading: (standard_name, dtype) + for standard_name, (_, _, col_heading, dtype) + in SENSOR_TYPES.items()} self.data = { - self.API_FIELDS.get(k)[0]: - self.API_FIELDS.get(k)[1](v.replace(',', '.')) - for k, v in row.items() - if v and k in self.API_FIELDS - } + api_fields.get(col_heading)[0]: + api_fields.get(col_heading)[1](v.replace(',', '.')) + for col_heading, v in row.items() + if col_heading in api_fields and v} break + else: + raise ValueError('No weather data for station {}' + .format(self._station_id)) def get_data(self, variable): """Generic accessor for data.""" return self.data.get(variable) + + +def _get_zamg_stations(): + """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.""" + capital_stations = {r['Station'] for r in ZamgData.current_observations()} + req = requests.get('https://www.zamg.ac.at/cms/en/documents/climate/' + 'doc_metnetwork/zamg-observation-points', timeout=15) + stations = {} + for row in csv.DictReader(req.text.splitlines(), + delimiter=';', quotechar='"'): + if row.get('synnr') in capital_stations: + try: + stations[row['synnr']] = tuple( + float(row[coord].replace(',', '.')) + for coord in ['breite_dezi', 'länge_dezi']) + except KeyError: + logging.getLogger(__name__).exception( + 'ZAMG schema changed again, cannot autodetect station.') + return stations + + +def zamg_stations(cache_dir): + """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. + + Results from internet requests are cached as compressed json, making + subsequent calls very much faster. + """ + cache_file = os.path.join(cache_dir, '.zamg-stations.json.gz') + if not os.path.isfile(cache_file): + stations = _get_zamg_stations() + with gzip.open(cache_file, 'wt') as cache: + json.dump(stations, cache, sort_keys=True) + return stations + with gzip.open(cache_file, 'rt') as cache: + return {k: tuple(v) for k, v in json.load(cache).items()} + + +def closest_station(lat, lon, cache_dir): + """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon.""" + if lat is None or lon is None or not os.path.isdir(cache_dir): + return + stations = zamg_stations(cache_dir) + + def comparable_dist(zamg_id): + """A fast key function for psudeo-distance from lat/lon.""" + station_lat, station_lon = stations[zamg_id] + return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 + + return min(stations, key=comparable_dist) diff --git a/homeassistant/components/weather/zamg.py b/homeassistant/components/weather/zamg.py new file mode 100644 index 00000000000..4a0e092a5ad --- /dev/null +++ b/homeassistant/components/weather/zamg.py @@ -0,0 +1,107 @@ +""" +Sensor for data from Austrian "Zentralanstalt für Meteorologie und Geodynamik". + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/weather.zamg/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.weather import ( + WeatherEntity, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, PLATFORM_SCHEMA) +from homeassistant.const import \ + CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv +# Reuse data and API logic from the sensor implementation +from homeassistant.components.sensor.zamg import ( + ATTRIBUTION, closest_station, CONF_STATION_ID, zamg_stations, ZamgData) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION_ID): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the ZAMG sensor platform.""" + station_id = config.get(CONF_STATION_ID) or closest_station( + config.get(CONF_LATITUDE), + config.get(CONF_LONGITUDE), + hass.config.config_dir) + if station_id not in zamg_stations(hass.config.config_dir): + _LOGGER.error("Configured ZAMG %s (%s) is not a known station", + CONF_STATION_ID, station_id) + return False + + probe = ZamgData(station_id=station_id, logger=_LOGGER) + try: + probe.update() + except ValueError as err: + _LOGGER.error("Received error from ZAMG: %s", err) + return False + + add_devices([ZamgWeather(probe, config.get(CONF_NAME))], True) + + +class ZamgWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, zamg_data, stationname=None): + """Initialise the platform with a data instance and station name.""" + self.zamg_data = zamg_data + self.stationname = stationname + + def update(self): + """Update current conditions.""" + self.zamg_data.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self.stationname or 'ZAMG {}'.format( + self.zamg_data.data.get('Name') or '(unknown station)') + + @property + def condition(self): + """Return the current condition.""" + return None + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def temperature(self): + """Return the platform temperature.""" + return self.zamg_data.get_data(ATTR_WEATHER_TEMPERATURE) + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def pressure(self): + """Return the pressure.""" + return self.zamg_data.get_data(ATTR_WEATHER_PRESSURE) + + @property + def humidity(self): + """Return the humidity.""" + return self.zamg_data.get_data(ATTR_WEATHER_HUMIDITY) + + @property + def wind_speed(self): + """Return the wind speed.""" + return self.zamg_data.get_data(ATTR_WEATHER_WIND_SPEED) + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self.zamg_data.get_data(ATTR_WEATHER_WIND_BEARING) From 34ee2b1ae962c39c90fb62e12e6048463463337f Mon Sep 17 00:00:00 2001 From: pavoni Date: Fri, 24 Feb 2017 22:02:39 +0000 Subject: [PATCH 026/198] Bump pyloopenergy - catch socketIO exceptions. --- homeassistant/components/sensor/loopenergy.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index d537d8067cb..06d1fd954f2 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -17,7 +17,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyloopenergy==0.0.16'] +REQUIREMENTS = ['pyloopenergy==0.0.17'] CONF_ELEC = 'electricity' CONF_GAS = 'gas' diff --git a/requirements_all.txt b/requirements_all.txt index 3931a14c5fb..5f89193bf59 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -507,7 +507,7 @@ pylast==1.8.0 pylitejet==0.1 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.16 +pyloopenergy==0.0.17 # homeassistant.components.mochad pymochad==0.1.1 From d6818c70156efe6a7425638916aba9f64037b918 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 24 Feb 2017 16:33:58 -0800 Subject: [PATCH 027/198] Fix reporting on bad login (#6201) --- homeassistant/components/http/__init__.py | 12 +----------- homeassistant/components/http/ban.py | 20 ++++++++++++++++---- homeassistant/components/websocket_api.py | 3 ++- tests/components/http/test_ban.py | 1 - tests/components/test_websocket_api.py | 15 +++++++++------ 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 2bb35dd8f3f..d6e03e76619 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -19,7 +19,6 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util -from homeassistant.components import persistent_notification from homeassistant.const import ( SERVER_PORT, CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) @@ -27,7 +26,7 @@ from homeassistant.core import is_callback from homeassistant.util.logging import HideSensitiveDataFilter from .auth import auth_middleware -from .ban import ban_middleware, process_wrong_login +from .ban import ban_middleware from .const import ( KEY_USE_X_FORWARDED_FOR, KEY_TRUSTED_NETWORKS, KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, @@ -51,8 +50,6 @@ CONF_TRUSTED_NETWORKS = 'trusted_networks' CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold' CONF_IP_BAN_ENABLED = 'ip_ban_enabled' -NOTIFICATION_ID_LOGIN = 'http-login' - # TLS configuation follows the best-practice guidelines specified here: # https://wiki.mozilla.org/Security/Server_Side_TLS # Intermediate guidelines are followed. @@ -409,13 +406,6 @@ def request_handler_factory(view, handler): authenticated = request.get(KEY_AUTHENTICATED, False) if view.requires_auth and not authenticated: - yield from process_wrong_login(request) - _LOGGER.warning('Login attempt or request with an invalid ' - 'password from %s', remote_addr) - persistent_notification.async_create( - request.app['hass'], - 'Invalid password used from {}'.format(remote_addr), - 'Login attempt failed', NOTIFICATION_ID_LOGIN) raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index b3f17c1dd57..96a32d1ae6e 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -5,7 +5,7 @@ from datetime import datetime from ipaddress import ip_address import logging -from aiohttp.web_exceptions import HTTPForbidden +from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol from homeassistant.components import persistent_notification @@ -19,6 +19,7 @@ from .const import ( from .util import get_real_ip NOTIFICATION_ID_BAN = 'ip-ban' +NOTIFICATION_ID_LOGIN = 'http-login' IP_BANS_FILE = 'ip_bans.yaml' ATTR_BANNED_AT = "banned_at" @@ -52,7 +53,11 @@ def ban_middleware(app, handler): if is_banned: raise HTTPForbidden() - return handler(request) + try: + return (yield from handler(request)) + except HTTPUnauthorized: + yield from process_wrong_login(request) + raise return ban_middleware_handler @@ -60,6 +65,15 @@ def ban_middleware(app, handler): @asyncio.coroutine def process_wrong_login(request): """Process a wrong login attempt.""" + remote_addr = get_real_ip(request) + + msg = ('Login attempt or request with invalid authentication ' + 'from {}'.format(remote_addr)) + _LOGGER.warning(msg) + persistent_notification.async_create( + request.app['hass'], msg, 'Login attempt failed', + NOTIFICATION_ID_LOGIN) + if (not request.app[KEY_BANS_ENABLED] or request.app[KEY_LOGIN_THRESHOLD] < 1): return @@ -67,8 +81,6 @@ def process_wrong_login(request): if KEY_FAILED_LOGIN_ATTEMPTS not in request.app: request.app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) - remote_addr = get_real_ip(request) - request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 70b35e00247..a3557a301c5 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -18,6 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.auth import validate_password from homeassistant.components.http.const import KEY_AUTHENTICATED +from homeassistant.components.http.ban import process_wrong_login DOMAIN = 'websocket_api' @@ -256,9 +257,9 @@ class ActiveConnection: else: self.debug('Invalid password') self.send_message(auth_invalid_message('Invalid password')) - return wsock if not authenticated: + yield from process_wrong_login(self.request) return wsock self.send_message(auth_ok_message()) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c210bc3f0e0..b01535206ff 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -97,7 +97,6 @@ class TestHttp: with patch('homeassistant.components.http.' 'ban.get_real_ip', return_value=ip_address("200.201.202.204")): - print("GETTING API") return requests.get( _url(const.URL_API), headers={const.HTTP_HEADER_HA_AUTH: 'Wrong password'}) diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 3cdc77414ee..658a5e0be53 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -9,7 +9,7 @@ import pytest from homeassistant.core import callback from homeassistant.components import websocket_api as wapi, frontend -from tests.common import mock_http_component_app +from tests.common import mock_http_component_app, mock_coro API_PASSWORD = 'test1234' @@ -66,13 +66,16 @@ def test_auth_via_msg(no_auth_websocket_client): @asyncio.coroutine def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): """Test authenticating.""" - no_auth_websocket_client.send_json({ - 'type': wapi.TYPE_AUTH, - 'api_password': API_PASSWORD + 'wrong' - }) + with patch('homeassistant.components.websocket_api.process_wrong_login', + return_value=mock_coro()) as mock_process_wrong_login: + no_auth_websocket_client.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + 'wrong' + }) - msg = yield from no_auth_websocket_client.receive_json() + msg = yield from no_auth_websocket_client.receive_json() + assert mock_process_wrong_login.called assert msg['type'] == wapi.TYPE_AUTH_INVALID assert msg['message'] == 'Invalid password' From 81ca978413186d84b80fa9087967aefe82908c65 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 25 Feb 2017 02:11:50 +0100 Subject: [PATCH 028/198] Move mqtt from eventbus to dispatcher / add unsub for dispatcher (#6206) * Move mqtt from eventbus to dispatcher / add unsub for dispatcher * Fix lint * Fix test * Fix lint v2 * fix dispatcher_send --- homeassistant/components/mqtt/__init__.py | 27 ++++++----- homeassistant/components/mqtt_eventstream.py | 10 ---- homeassistant/helpers/dispatcher.py | 26 ++++++++++- tests/common.py | 8 ++-- tests/components/mqtt/test_init.py | 32 +++++++++---- tests/components/test_mqtt_eventstream.py | 44 ------------------ tests/helpers/test_dispatcher.py | 49 ++++++++++++++++---- 7 files changed, 105 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 57ea0351168..78311623258 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -17,6 +17,8 @@ from homeassistant.bootstrap import async_prepare_setup_platform from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import template, config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) from homeassistant.const import ( @@ -31,7 +33,7 @@ DOMAIN = 'mqtt' DATA_MQTT = 'mqtt' SERVICE_PUBLISH = 'publish' -EVENT_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received' +SIGNAL_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received' REQUIREMENTS = ['paho-mqtt==1.2'] @@ -195,16 +197,15 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None): def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS): """Subscribe to an MQTT topic.""" @callback - def async_mqtt_topic_subscriber(event): + def async_mqtt_topic_subscriber(dp_topic, dp_payload, dp_qos): """Match subscribed MQTT topic.""" - if not _match_topic(topic, event.data[ATTR_TOPIC]): + if not _match_topic(topic, dp_topic): return - hass.async_run_job(msg_callback, event.data[ATTR_TOPIC], - event.data[ATTR_PAYLOAD], event.data[ATTR_QOS]) + hass.async_run_job(msg_callback, dp_topic, dp_payload, dp_qos) - async_remove = hass.bus.async_listen( - EVENT_MQTT_MESSAGE_RECEIVED, async_mqtt_topic_subscriber) + async_remove = async_dispatcher_connect( + hass, SIGNAL_MQTT_MESSAGE_RECEIVED, async_mqtt_topic_subscriber) yield from hass.data[DATA_MQTT].async_subscribe(topic, qos) return async_remove @@ -551,13 +552,11 @@ class MQTT(object): "MQTT topic: %s, Payload: %s", msg.topic, msg.payload) else: - _LOGGER.debug("Received message on %s: %s", - msg.topic, payload) - self.hass.bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, { - ATTR_TOPIC: msg.topic, - ATTR_QOS: msg.qos, - ATTR_PAYLOAD: payload, - }) + _LOGGER.info("Received message on %s: %s", msg.topic, payload) + dispatcher_send( + self.hass, SIGNAL_MQTT_MESSAGE_RECEIVED, msg.topic, payload, + msg.qos + ) def _mqtt_on_unsubscribe(self, _mqttc, _userdata, mid, granted_qos): """Unsubscribe successful callback.""" diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index c4a4b7bc4ab..bd149b6397d 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -19,7 +19,6 @@ from homeassistant.const import ( from homeassistant.core import EventOrigin, State import homeassistant.helpers.config_validation as cv from homeassistant.remote import JSONEncoder -from .mqtt import EVENT_MQTT_MESSAGE_RECEIVED DOMAIN = "mqtt_eventstream" DEPENDENCIES = ['mqtt'] @@ -54,15 +53,6 @@ def async_setup(hass, config): if event.event_type == EVENT_TIME_CHANGED: return - # MQTT fires a bus event for every incoming message, also messages from - # eventstream. Disable publishing these messages to other HA instances - # and possibly creating an infinite loop if these instances publish - # back to this one. - if all([not conf.get(CONF_PUBLISH_EVENTSTREAM_RECEIVED), - event.event_type == EVENT_MQTT_MESSAGE_RECEIVED, - event.data.get('topic') == sub_topic]): - return - # Filter out the events that were triggered by publishing # to the MQTT topic, or you will end up in an infinite loop. if event.event_type == EVENT_CALL_SERVICE: diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 324d4ccc621..3a1d7d075aa 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -1,13 +1,24 @@ """Helpers for hass dispatcher & internal component / platform.""" +import logging from homeassistant.core import callback +from homeassistant.util.async import run_callback_threadsafe + +_LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = 'dispatcher' def dispatcher_connect(hass, signal, target): """Connect a callable function to a singal.""" - hass.add_job(async_dispatcher_connect, hass, signal, target) + async_unsub = run_callback_threadsafe( + hass.loop, async_dispatcher_connect, hass, signal, target).result() + + def remove_dispatcher(): + """Remove signal listener.""" + run_callback_threadsafe(hass.loop, async_unsub).result() + + return remove_dispatcher @callback @@ -24,6 +35,19 @@ def async_dispatcher_connect(hass, signal, target): hass.data[DATA_DISPATCHER][signal].append(target) + @callback + def async_remove_dispatcher(): + """Remove signal listener.""" + try: + hass.data[DATA_DISPATCHER][signal].remove(target) + except (KeyError, ValueError): + # KeyError is key target listener did not exist + # ValueError if listener did not exist within signal + _LOGGER.warning( + "Unable to remove unknown dispatcher %s", target) + + return async_remove_dispatcher + def dispatcher_send(hass, signal, *args): """Send signal and data.""" diff --git a/tests/common.py b/tests/common.py index 762531752ca..82623dd0e2d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,6 +14,7 @@ from aiohttp import web from homeassistant import core as ha, loader from homeassistant.bootstrap import ( setup_component, async_prepare_setup_component) +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from homeassistant.util.unit_system import METRIC_SYSTEM @@ -158,11 +159,8 @@ def mock_service(hass, domain, service): @ha.callback def async_fire_mqtt_message(hass, topic, payload, qos=0): """Fire the MQTT message.""" - hass.bus.async_fire(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, { - mqtt.ATTR_TOPIC: topic, - mqtt.ATTR_PAYLOAD: payload, - mqtt.ATTR_QOS: qos, - }) + async_dispatcher_send( + hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic, payload, qos) def fire_mqtt_message(hass, topic, payload, qos=0): diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 18510dd2ff3..255d5f6a96c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -13,6 +13,7 @@ import homeassistant.components.mqtt as mqtt from homeassistant.const import ( EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, mock_coro) @@ -237,11 +238,17 @@ class TestMQTTCallbacks(unittest.TestCase): calls = [] @callback - def record(event): + def record(topic, payload, qos): """Helper to record calls.""" - calls.append(event) + data = { + 'topic': topic, + 'payload': payload, + 'qos': qos, + } + calls.append(data) - self.hass.bus.listen_once(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, record) + async_dispatcher_connect( + self.hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, record) MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload']) message = MQTTMessage('test_topic', 1, 'Hello World!'.encode('utf-8')) @@ -252,9 +259,9 @@ class TestMQTTCallbacks(unittest.TestCase): self.assertEqual(1, len(calls)) last_event = calls[0] - self.assertEqual('Hello World!', last_event.data['payload']) - self.assertEqual(message.topic, last_event.data['topic']) - self.assertEqual(message.qos, last_event.data['qos']) + self.assertEqual('Hello World!', last_event['payload']) + self.assertEqual(message.topic, last_event['topic']) + self.assertEqual(message.qos, last_event['qos']) def test_mqtt_failed_connection_results_in_disconnect(self): """Test if connection failure leads to disconnect.""" @@ -300,13 +307,20 @@ class TestMQTTCallbacks(unittest.TestCase): calls = [] @callback - def record(event): + def record(topic, payload, qos): """Helper to record calls.""" - calls.append(event) + data = { + 'topic': topic, + 'payload': payload, + 'qos': qos, + } + calls.append(data) + + async_dispatcher_connect( + self.hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, record) payload = 0x9a topic = 'test_topic' - self.hass.bus.listen_once(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, record) MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload']) message = MQTTMessage(topic, 1, payload) with self.assertLogs(level='ERROR') as test_handle: diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py index c4e7f7fd673..dd08904a8e1 100644 --- a/tests/components/test_mqtt_eventstream.py +++ b/tests/components/test_mqtt_eventstream.py @@ -1,11 +1,9 @@ """The tests for the MQTT eventstream component.""" -from collections import namedtuple import json from unittest.mock import ANY, patch from homeassistant.bootstrap import setup_component import homeassistant.components.mqtt_eventstream as eventstream -import homeassistant.components.mqtt as mqtt from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import State, callback from homeassistant.remote import JSONEncoder @@ -146,45 +144,3 @@ class TestMqttEventStream(object): self.hass.block_till_done() assert 1 == len(calls) - - @patch('homeassistant.components.mqtt.async_publish') - def test_mqtt_received_event(self, mock_pub): - """Don't filter events from the mqtt component about received message. - - Mqtt component sends an event if a message is received. Also - messages that originate from an incoming eventstream. - Broadcasting these messages result in an infinite loop if two HA - instances are crossconfigured for the same mqtt topics. - - """ - SUB_TOPIC = 'from_slaves' - assert self.add_eventstream( - pub_topic='bar', - sub_topic=SUB_TOPIC) - self.hass.block_till_done() - - # Reset the mock because it will have already gotten calls for the - # mqtt_eventstream state change on initialization, etc. - mock_pub.reset_mock() - - # Use MQTT component message handler to simulate firing message - # received event. - MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload']) - message = MQTTMessage( - SUB_TOPIC, 1, '{"test": "Hello World!"}'.encode('utf-8')) - mqtt.MQTT._mqtt_on_message(self, None, {'hass': self.hass}, message) - - self.hass.block_till_done() - - # 'normal' incoming mqtt messages should be broadcasted - assert mock_pub.call_count == 0 - - MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload']) - message = MQTTMessage( - 'test_topic', 1, '{"test": "Hello World!"}'.encode('utf-8')) - mqtt.MQTT._mqtt_on_message(self, None, {'hass': self.hass}, message) - - self.hass.block_till_done() - - # but event from the event stream not - assert mock_pub.call_count == 1 diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index fbac0689ff1..066e7386c6e 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -28,8 +28,6 @@ class TestHelpersDispatcher(object): calls.append(data) dispatcher_connect(self.hass, 'test', test_funct) - self.hass.block_till_done() - dispatcher_send(self.hass, 'test', 3) self.hass.block_till_done() @@ -40,6 +38,47 @@ class TestHelpersDispatcher(object): assert calls == [3, 'bla'] + def test_simple_function_unsub(self): + """Test simple function (executor) and unsub.""" + calls1 = [] + calls2 = [] + + def test_funct1(data): + """Test function.""" + calls1.append(data) + + def test_funct2(data): + """Test function.""" + calls2.append(data) + + dispatcher_connect(self.hass, 'test1', test_funct1) + unsub = dispatcher_connect(self.hass, 'test2', test_funct2) + dispatcher_send(self.hass, 'test1', 3) + dispatcher_send(self.hass, 'test2', 4) + self.hass.block_till_done() + + assert calls1 == [3] + assert calls2 == [4] + + unsub() + + dispatcher_send(self.hass, 'test1', 5) + dispatcher_send(self.hass, 'test2', 6) + self.hass.block_till_done() + + assert calls1 == [3, 5] + assert calls2 == [4] + + # check don't kill the flow + unsub() + + dispatcher_send(self.hass, 'test1', 7) + dispatcher_send(self.hass, 'test2', 8) + self.hass.block_till_done() + + assert calls1 == [3, 5, 7] + assert calls2 == [4] + def test_simple_callback(self): """Test simple callback (async).""" calls = [] @@ -50,8 +89,6 @@ class TestHelpersDispatcher(object): calls.append(data) dispatcher_connect(self.hass, 'test', test_funct) - self.hass.block_till_done() - dispatcher_send(self.hass, 'test', 3) self.hass.block_till_done() @@ -72,8 +109,6 @@ class TestHelpersDispatcher(object): calls.append(data) dispatcher_connect(self.hass, 'test', test_funct) - self.hass.block_till_done() - dispatcher_send(self.hass, 'test', 3) self.hass.block_till_done() @@ -95,8 +130,6 @@ class TestHelpersDispatcher(object): calls.append(data3) dispatcher_connect(self.hass, 'test', test_funct) - self.hass.block_till_done() - dispatcher_send(self.hass, 'test', 3, 2, 'bla') self.hass.block_till_done() From c5a8372f13788781bfbf5eec3169ba34fb82eeaa Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 25 Feb 2017 10:44:22 +0200 Subject: [PATCH 029/198] Update flake8 and pylint to latest (#6217) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3ce07cff7ef..07f8e192839 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,8 +1,8 @@ # linters such as flake8 and pylint should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version -flake8==3.2.1 -pylint==1.6.4 +flake8==3.3 +pylint==1.6.5 mypy-lang==0.4.5 pydocstyle==1.1.1 coveralls>=1.1 From be7162a0df06b34f6131945493e7df7e834e1620 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Feb 2017 12:50:10 +0100 Subject: [PATCH 030/198] sensor.dovado: Upgraded library version --- homeassistant/components/sensor/dovado.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/dovado.py b/homeassistant/components/sensor/dovado.py index 8a1e42b61bc..8182c8ccf39 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -22,7 +22,7 @@ from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['dovado==0.4.0'] +REQUIREMENTS = ['dovado==0.4.1'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) diff --git a/requirements_all.txt b/requirements_all.txt index 938d4b6943f..8d55759f7b6 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,7 +112,7 @@ dlipower==0.7.165 dnspython3==1.15.0 # homeassistant.components.sensor.dovado -dovado==0.4.0 +dovado==0.4.1 # homeassistant.components.sensor.dsmr dsmr_parser==0.6 From 2487d27c456724963168b6bfd564c06bc6e98012 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Feb 2017 12:51:48 +0100 Subject: [PATCH 031/198] sensor.eliqonline: Change icon --- homeassistant/components/sensor/eliqonline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index a2f3d5702a8..dad15361ba4 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -23,7 +23,7 @@ CONF_CHANNEL_ID = 'channel_id' DEFAULT_NAME = 'ELIQ Online' -ICON = 'mdi:speedometer' +ICON = 'mdi:gauge' SCAN_INTERVAL = timedelta(seconds=60) From a80fd2f243356140a61d08c079e98356c7ebbd62 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 25 Feb 2017 13:24:43 +0100 Subject: [PATCH 032/198] Fix link (#6219) --- .../components/light/yeelightsunflower.py | 17 +++++------------ homeassistant/components/sensor/fedex.py | 2 +- homeassistant/components/sensor/ups.py | 2 +- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/light/yeelightsunflower.py b/homeassistant/components/light/yeelightsunflower.py index ead00d97f64..6d132f8a1fc 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -2,9 +2,7 @@ Support for Yeelight Sunflower color bulbs (not Yeelight Blue or WiFi). For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.yeelight-sunflower -Uses the yeelightsunflower library: -https://github.com/lindsaymarkward/python-yeelight-sunflower +https://home-assistant.io/components/light.yeelightsunflower """ import logging import voluptuous as vol @@ -18,33 +16,28 @@ from homeassistant.const import CONF_HOST import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['yeelightsunflower==0.0.6'] -SUPPORT_YEELIGHT_SUNFLOWER = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) _LOGGER = logging.getLogger(__name__) -# Validate the user's configuration + +SUPPORT_YEELIGHT_SUNFLOWER = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the Yeelight Sunflower Light platform.""" + """Set up the Yeelight Sunflower Light platform.""" import yeelightsunflower - # Assign configuration variables. - # The configuration check takes care they are present. host = config.get(CONF_HOST) - - # Setup connection with Yeelight Sunflower hub hub = yeelightsunflower.Hub(host) - # Verify that hub is responsive if not hub.available: _LOGGER.error('Could not connect to Yeelight Sunflower hub') return False - # Add devices add_devices(SunflowerBulb(light) for light in hub.get_lights()) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index a0b5bbf5a0a..9cbeb753b2b 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -2,7 +2,7 @@ Sensor for Fedex packages. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.usps/ +https://home-assistant.io/components/sensor.fedex/ """ from collections import defaultdict import logging diff --git a/homeassistant/components/sensor/ups.py b/homeassistant/components/sensor/ups.py index 0e358a6abbb..4d4e0601ca5 100644 --- a/homeassistant/components/sensor/ups.py +++ b/homeassistant/components/sensor/ups.py @@ -2,7 +2,7 @@ Sensor for UPS packages. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.usps/ +https://home-assistant.io/components/sensor.ups/ """ from collections import defaultdict import logging From 7cd6f9038cc2196ef65435451c5d868d454de35b Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Wed, 22 Feb 2017 22:30:09 +0200 Subject: [PATCH 033/198] Allow 4.5min startup time for recorder --- homeassistant/components/recorder/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 0c743c44984..fbd6d9b0806 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -239,7 +239,7 @@ class Recorder(threading.Thread): async_track_time_interval( self.hass, self._purge_old_data, timedelta(days=2)) - _wait(self.start_recording, "Waiting to start recording") + _wait(self.start_recording, "Waiting to start recording", 90) while True: event = self.queue.get() @@ -499,13 +499,13 @@ class Recorder(threading.Thread): return False -def _wait(event, message): +def _wait(event, message, interval=15): """Event wait helper.""" - for retry in (10, 20, 30): - event.wait(10) + for mult in range(1, 4): + event.wait(interval) if event.is_set(): return - msg = message + " ({} seconds)".format(retry) + msg = "{} ({} seconds)".format(message, interval*mult) _LOGGER.warning(msg) if not event.is_set(): raise HomeAssistantError(msg) From 5d007e636b16161cffaee2dd3c913978269819d1 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 25 Feb 2017 17:14:04 +0200 Subject: [PATCH 034/198] No wait for start and more async --- homeassistant/components/recorder/__init__.py | 37 ++++++++++--------- homeassistant/helpers/restore_state.py | 5 ++- tests/helpers/test_restore_state.py | 7 +++- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index fbd6d9b0806..0e301f2a87c 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -7,6 +7,7 @@ to query this database. For more details about this component, please refer to the documentation at https://home-assistant.io/components/recorder/ """ +import asyncio import logging import queue import threading @@ -20,7 +21,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITIES, CONF_EXCLUDE, CONF_DOMAINS, - CONF_INCLUDE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + CONF_INCLUDE, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -83,7 +84,18 @@ def session_scope(): session.close() -def get_instance() -> None: +@asyncio.coroutine +def async_get_instance(): + """Throw error if recorder not initialized.""" + if _INSTANCE is None: + raise RuntimeError("Recorder not initialized.") + + yield from _INSTANCE.async_db_ready.wait() + + return _INSTANCE + + +def get_instance(): """Throw error if recorder not initialized.""" if _INSTANCE is None: raise RuntimeError("Recorder not initialized.") @@ -200,7 +212,7 @@ class Recorder(threading.Thread): self.recording_start = dt_util.utcnow() self.db_url = uri self.db_ready = threading.Event() - self.start_recording = threading.Event() + self.async_db_ready = asyncio.Event(loop=hass.loop) self.engine = None # type: Any self._run = None # type: Any @@ -209,11 +221,6 @@ class Recorder(threading.Thread): self.exclude = exclude.get(CONF_ENTITIES, []) + \ exclude.get(CONF_DOMAINS, []) - def start_recording(event): - """Start recording.""" - self.start_recording.set() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_recording) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) hass.bus.listen(MATCH_ALL, self.event_listener) @@ -229,6 +236,7 @@ class Recorder(threading.Thread): self._setup_connection() self._setup_run() self.db_ready.set() + self.async_db_ready.set() break except SQLAlchemyError as err: _LOGGER.error("Error during connection setup: %s (retrying " @@ -239,8 +247,6 @@ class Recorder(threading.Thread): async_track_time_interval( self.hass, self._purge_old_data, timedelta(days=2)) - _wait(self.start_recording, "Waiting to start recording", 90) - while True: event = self.queue.get() @@ -297,9 +303,6 @@ class Recorder(threading.Thread): """Tell the recorder to shut down.""" global _INSTANCE # pylint: disable=global-statement self.queue.put(None) - if not self.start_recording.is_set(): - _LOGGER.warning("Recorder never started correctly") - self.start_recording.set() self.join() _INSTANCE = None @@ -499,13 +502,13 @@ class Recorder(threading.Thread): return False -def _wait(event, message, interval=15): +def _wait(event, message): """Event wait helper.""" - for mult in range(1, 4): - event.wait(interval) + for retry in (10, 20, 30): + event.wait(10) if event.is_set(): return - msg = "{} ({} seconds)".format(message, interval*mult) + msg = "{} ({} seconds)".format(message, retry) _LOGGER.warning(msg) if not event.is_set(): raise HomeAssistantError(msg) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 1e463d316d4..86cd3e7037f 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -6,7 +6,8 @@ from datetime import timedelta from homeassistant.core import HomeAssistant, CoreState, callback from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.components.history import get_states, last_recorder_run -from homeassistant.components.recorder import DOMAIN as _RECORDER +from homeassistant.components.recorder import ( + async_get_instance, DOMAIN as _RECORDER) import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -57,6 +58,8 @@ def async_get_last_state(hass, entity_id: str): hass.state) return None + yield from async_get_instance() # Ensure recorder ready + if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index d411ef2073a..3a4c058f853 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -11,7 +11,8 @@ from homeassistant.components import input_boolean, recorder from homeassistant.helpers.restore_state import ( async_get_last_state, DATA_RESTORE_CACHE) -from tests.common import get_test_home_assistant, init_recorder_component +from tests.common import ( + get_test_home_assistant, mock_coro, init_recorder_component) @asyncio.coroutine @@ -29,7 +30,9 @@ def test_caching_data(hass): with patch('homeassistant.helpers.restore_state.last_recorder_run', return_value=MagicMock(end=dt_util.utcnow())), \ patch('homeassistant.helpers.restore_state.get_states', - return_value=states): + return_value=states), \ + patch('homeassistant.helpers.restore_state.async_get_instance', + return_value=mock_coro()): state = yield from async_get_last_state(hass, 'input_boolean.b1') assert DATA_RESTORE_CACHE in hass.data From 85d0f2e8615197c04647b67d2f224d2e4679e6a5 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 25 Feb 2017 22:54:04 +0200 Subject: [PATCH 035/198] Make glob preserve order (#6224) --- homeassistant/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index d6b1151a14f..852151e83f5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -309,7 +309,7 @@ def async_process_ha_core_config(hass, config): # Customize cust_exact = dict(config[CONF_CUSTOMIZE]) cust_domain = dict(config[CONF_CUSTOMIZE_DOMAIN]) - cust_glob = dict(config[CONF_CUSTOMIZE_GLOB]) + cust_glob = OrderedDict(config[CONF_CUSTOMIZE_GLOB]) for name, pkg in config[CONF_PACKAGES].items(): pkg_cust = pkg.get(CONF_CORE) From 9f2719bb1f95fd985e81063343621e8e5d0bec51 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 25 Feb 2017 21:55:01 +0100 Subject: [PATCH 036/198] Update regex (#6216) --- homeassistant/components/mqtt/discovery.py | 5 +++-- tests/components/mqtt/test_discovery.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a3b120410c5..d01fb848eab 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -18,7 +18,7 @@ from homeassistant.components.mqtt import CONF_STATE_TOPIC _LOGGER = logging.getLogger(__name__) TOPIC_MATCHER = re.compile( - r'homeassistant/(?P\w+)/(?P\w+)/config') + r'(?P\w+)/(?P\w+)/(?P\w+)/config') SUPPORTED_COMPONENTS = ['binary_sensor', 'sensor'] @@ -26,6 +26,7 @@ SUPPORTED_COMPONENTS = ['binary_sensor', 'sensor'] @asyncio.coroutine def async_start(hass, discovery_topic, hass_config): """Initialization of MQTT Discovery.""" + # pylint: disable=unused-variable @asyncio.coroutine def async_device_message_received(topic, payload, qos): """Process the received message.""" @@ -34,7 +35,7 @@ def async_start(hass, discovery_topic, hass_config): if not match: return - component, object_id = match.groups() + prefix_topic, component, object_id = match.groups() try: payload = json.loads(payload) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 389fb37b489..134b679daea 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1,4 +1,4 @@ -"""The tests for the MQTT component.""" +"""The tests for the MQTT discovery.""" import asyncio from unittest.mock import patch @@ -23,7 +23,7 @@ def test_subscribing_config_topic(hass, mqtt_mock): @asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') def test_invalid_topic(mock_load_platform, hass, mqtt_mock): - """Test sending in invalid JSON.""" + """Test sending to invalid topic.""" mock_load_platform.return_value = mock_coro() yield from async_start(hass, 'homeassistant', {}) @@ -50,7 +50,7 @@ def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): @asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog): - """Test sending in invalid JSON.""" + """Test for a valid component.""" mock_load_platform.return_value = mock_coro() yield from async_start(hass, 'homeassistant', {}) @@ -62,7 +62,7 @@ def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog): @asyncio.coroutine def test_correct_config_discovery(hass, mqtt_mock, caplog): - """Test sending in invalid JSON.""" + """Test sending in correct JSON.""" yield from async_start(hass, 'homeassistant', {}) async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', From 3a7cc9bb45c59a4c6d9788b53c07137d26c92fcd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Feb 2017 15:03:14 -0800 Subject: [PATCH 037/198] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../frontend/www_static/frontend.html | 4 ++-- .../frontend/www_static/frontend.html.gz | Bin 139423 -> 139416 bytes .../www_static/home-assistant-polymer | 2 +- .../frontend/www_static/service_worker.js | 2 +- .../frontend/www_static/service_worker.js.gz | Bin 2389 -> 2392 bytes 6 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 9a2cc400d5c..3a91e972a70 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,7 +3,7 @@ FINGERPRINTS = { "compatibility.js": "83d9c77748dafa9db49ae77d7f3d8fb0", "core.js": "1f7f88d8f5dada08bce1d935cfa5f33e", - "frontend.html": "be258a53166b82f4ebd5232037e1cbd5", + "frontend.html": "ca9efa7e4506aa6b1a668703c8d0f800", "mdi.html": "c1dde43ccf5667f687c418fc8daf9668", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", "panels/ha-panel-config.html": "412b3e24515ffa1ee8074ce974cf4057", diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 83d93603f21..0de54ad7c77 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -73,7 +73,7 @@ window.hassUtil.dynamicContentUpdater = function (root, newElementTag, attribute var rootEl = Polymer.dom(root); var customEl; - if (rootEl.lastChild && rootEl.lastChild.tagName.toLowerCase() === newElementTag) { + if (rootEl.lastChild && rootEl.lastChild.tagName === newElementTag) { customEl = rootEl.lastChild; } else { if (rootEl.lastChild) { @@ -722,4 +722,4 @@ this.currentTarget=t,this.defaultPrevented=!1,this.eventPhase=Event.AT_TARGET,th this.hass.callService('media_player', service, serviceData); }, }); -}()); \ No newline at end of file +}()); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 3335cdcb43690347c5b747d195be48a49984c068..396ea6fa8a1282f164f101a67d1dfc80990112d9 100644 GIT binary patch delta 94028 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

^dsc%uL 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≶7ai!wKIhJAJPk)E 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 delta 94051 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

ZovQUlOdDXjxtm>UOa3wsunx+T{y7M^LBJaYkEQ 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 S$-&v{;Qs@HqHtl@tOx+H6D7C+ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 5d223c8da4b..e509ed07a08 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 5d223c8da4b4380bf79d8f0285ce7824063e89ef +Subproject commit e509ed07a08d35152b9eea6e263411dfc027867b diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index beae637f6ea..0bc47086c6b 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","98cfb1f23c1a1b783c1afe58d3f57bc9"],["/frontend/panels/dev-event-91347dedf3b4fa9b49ccf4c0a28a03c4.html","f74c44ab9bfbdc81badb56518ef8113d"],["/frontend/panels/dev-info-61610e015a411cfc84edd2c4d489e71d.html","6568377ee31cbd78fedc003b317f7faf"],["/frontend/panels/dev-service-a9247f255174b084fad2c04bdb9ec7a9.html","4d5f34f8ebc6c5fc4bdcff1ef7b4eb35"],["/frontend/panels/dev-state-90f3bede9602241552ef7bb7958198c6.html","277716ed9b76fa4313a1653dc757741b"],["/frontend/panels/dev-template-c249a4fc18a3a6994de3d6330cfe6cbb.html","8d7eaec6389ea1417cec667798740399"],["/frontend/panels/map-e10704a3469e44d1714eac9ed8e4b6a0.html","b9528c06194ad4b8b22e369fe4211500"],["/static/compatibility-83d9c77748dafa9db49ae77d7f3d8fb0.js","5f05c83be2b028d577962f9625904806"],["/static/core-1f7f88d8f5dada08bce1d935cfa5f33e.js","8a58624e6ea5958e817bf6cd5658e3a2"],["/static/frontend-be258a53166b82f4ebd5232037e1cbd5.html","d1beaa80677d302b41c7eb1ffff49296"],["/static/mdi-c1dde43ccf5667f687c418fc8daf9668.html","6a3c9317736ca26e3390316335be9ba5"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","32b5a9b7ada86304bec6b43d3f2194f0"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin",redirect:"follow"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e.delete(a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,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 H2o(STxA&M& From 9afcbaed1d72a64710532b1a5c3e0e1056f172b6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 Feb 2017 15:21:40 -0800 Subject: [PATCH 038/198] Fix recorder async (#6228) --- homeassistant/components/recorder/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 0e301f2a87c..0f8d7b48fe2 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -236,7 +236,7 @@ class Recorder(threading.Thread): self._setup_connection() self._setup_run() self.db_ready.set() - self.async_db_ready.set() + self.hass.loop.call_soon_threadsafe(self.async_db_ready.set) break except SQLAlchemyError as err: _LOGGER.error("Error during connection setup: %s (retrying " From 7b3b755aaf28b23494feb9430e55139111eed27e Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Sun, 26 Feb 2017 23:04:22 +0100 Subject: [PATCH 039/198] Fix livebox-play interactions for Python < 3.6 (#6243) --- homeassistant/components/media_player/liveboxplaytv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 093a53786be..52a37eb8faa 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_PAUSED, STATE_UNKNOWN, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['liveboxplaytv==1.4.8'] +REQUIREMENTS = ['liveboxplaytv==1.4.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5f89193bf59..5b0c020b316 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -328,7 +328,7 @@ liffylights==0.9.4 limitlessled==1.0.4 # homeassistant.components.media_player.liveboxplaytv -liveboxplaytv==1.4.8 +liveboxplaytv==1.4.9 # homeassistant.components.notify.matrix matrix-client==0.0.5 From 86d4d101764b454423b6d622d840943c724f1bd3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2017 14:05:18 -0800 Subject: [PATCH 040/198] Ensure we properly close HASS instances. (#6234) --- tests/common.py | 12 +++++++++++- tests/conftest.py | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index 82623dd0e2d..93ddc7c2f65 100644 --- a/tests/common.py +++ b/tests/common.py @@ -23,7 +23,7 @@ import homeassistant.util.yaml as yaml from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, - ATTR_DISCOVERED, SERVER_PORT) + ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.components import sun, mqtt, recorder from homeassistant.components.http.auth import auth_middleware from homeassistant.components.http.const import ( @@ -32,6 +32,7 @@ from homeassistant.util.async import run_callback_threadsafe _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) +INST_COUNT = 0 def get_test_config_dir(*add_path): @@ -85,6 +86,8 @@ def get_test_home_assistant(): @asyncio.coroutine def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" + global INST_COUNT + INST_COUNT += 1 loop._thread_ident = threading.get_ident() hass = ha.HomeAssistant(loop) @@ -122,6 +125,13 @@ def async_test_home_assistant(loop): hass.async_start = mock_async_start + @ha.callback + def clear_instance(event): + global INST_COUNT + INST_COUNT -= 1 + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, clear_instance) + return hass diff --git a/tests/conftest.py b/tests/conftest.py index 1e987a5f0a2..33c5d9f0917 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,15 @@ location.elevation = test_real(location.elevation) util.get_local_ip = lambda: '127.0.0.1' +@pytest.fixture(autouse=True) +def verify_cleanup(): + """Verify that the test has cleaned up resources correctly.""" + yield + + from tests import common + assert common.INST_COUNT < 2 + + @pytest.fixture def hass(loop): """Fixture to provide a test instance of HASS.""" From 9490cb4c8f33bd3da546ca86d2d1d34e8df7c5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Sun, 26 Feb 2017 23:15:44 +0100 Subject: [PATCH 041/198] Add service to change log levels (#6221) * Add service to change log levels * Fix review comments --- homeassistant/components/logger.py | 55 +++++++++++++++++---- homeassistant/components/services.yaml | 4 ++ tests/components/test_logger.py | 68 ++++++++++++++++++++------ 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index 4bf163ff9eb..8572bbc044a 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -4,15 +4,22 @@ Component that will help set the level of logging for components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/logger/ """ +import asyncio import logging +import os from collections import OrderedDict import voluptuous as vol +from homeassistant.config import load_yaml_config_file import homeassistant.helpers.config_validation as cv DOMAIN = 'logger' +DATA_LOGGER = 'logger' + +SERVICE_SET_LEVEL = 'set_level' + LOGSEVERITY = { 'CRITICAL': 50, 'FATAL': 50, @@ -29,6 +36,8 @@ LOGGER_LOGS = 'logs' _VALID_LOG_LEVEL = vol.All(vol.Upper, vol.In(LOGSEVERITY)) +SERVICE_SET_LEVEL_SCHEMA = vol.Schema({cv.string: _VALID_LOG_LEVEL}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(LOGGER_DEFAULT): _VALID_LOG_LEVEL, @@ -37,6 +46,11 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +def set_level(hass, logs): + """Set log level for components.""" + hass.services.call(DOMAIN, SERVICE_SET_LEVEL, logs) + + class HomeAssistantLogFilter(logging.Filter): """A log filter.""" @@ -61,7 +75,8 @@ class HomeAssistantLogFilter(logging.Filter): return record.levelno >= default -def setup(hass, config=None): +@asyncio.coroutine +def async_setup(hass, config): """Setup the logger component.""" logfilter = {} @@ -72,21 +87,26 @@ def setup(hass, config=None): config.get(DOMAIN)[LOGGER_DEFAULT] ] - # Compute log severity for components - if LOGGER_LOGS in config.get(DOMAIN): - for key, value in config.get(DOMAIN)[LOGGER_LOGS].items(): - config.get(DOMAIN)[LOGGER_LOGS][key] = LOGSEVERITY[value] + def set_log_levels(logpoints): + """Set the specified log levels.""" + logs = {} - logs = OrderedDict( + # Preserve existing logs + if LOGGER_LOGS in logfilter: + logs.update(logfilter[LOGGER_LOGS]) + + # Add new logpoints mapped to correc severity + for key, value in logpoints.items(): + logs[key] = LOGSEVERITY[value] + + logfilter[LOGGER_LOGS] = OrderedDict( sorted( - config.get(DOMAIN)[LOGGER_LOGS].items(), + logs.items(), key=lambda t: len(t[0]), reverse=True ) ) - logfilter[LOGGER_LOGS] = logs - logger = logging.getLogger('') logger.setLevel(logging.NOTSET) @@ -95,4 +115,21 @@ def setup(hass, config=None): handler.setLevel(logging.NOTSET) handler.addFilter(HomeAssistantLogFilter(logfilter)) + if LOGGER_LOGS in config.get(DOMAIN): + set_log_levels(config.get(DOMAIN)[LOGGER_LOGS]) + + @asyncio.coroutine + def async_service_handler(service): + """Handle logger services.""" + set_log_levels(service.data) + + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml')) + + hass.services.async_register( + DOMAIN, SERVICE_SET_LEVEL, async_service_handler, + descriptions[DOMAIN].get(SERVICE_SET_LEVEL), + schema=SERVICE_SET_LEVEL_SCHEMA) + return True diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 661f8be8dab..a28a95969fb 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -312,3 +312,7 @@ ffmpeg: entity_id: description: Name(s) of entites that will restart. Platform dependent. example: 'binary_sensor.ffmpeg_noise' + +logger: + set_level: + description: Set log level for components. diff --git a/tests/components/test_logger.py b/tests/components/test_logger.py index e4e8c75d1bd..099137bdf4b 100644 --- a/tests/components/test_logger.py +++ b/tests/components/test_logger.py @@ -10,6 +10,14 @@ from tests.common import get_test_home_assistant RECORD = namedtuple('record', ('name', 'levelno')) +NO_LOGS_CONFIG = {'logger': {'default': 'info'}} +TEST_CONFIG = { + 'logger': { + 'default': 'warning', + 'logs': {'test': 'info'} + } +} + class TestUpdater(unittest.TestCase): """Test logger component.""" @@ -17,17 +25,29 @@ class TestUpdater(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.log_config = {'logger': - {'default': 'warning', 'logs': {'test': 'info'}}} + self.log_filter = None def tearDown(self): """Stop everything that was started.""" del logging.root.handlers[-1] self.hass.stop() + def setup_logger(self, config): + """Setup logger and save log filter.""" + setup_component(self.hass, logger.DOMAIN, config) + self.log_filter = logging.root.handlers[-1].filters[0] + + def assert_logged(self, name, level): + """Assert that a certain record was logged.""" + self.assertTrue(self.log_filter.filter(RECORD(name, level))) + + def assert_not_logged(self, name, level): + """Assert that a certain record was not logged.""" + self.assertFalse(self.log_filter.filter(RECORD(name, level))) + def test_logger_setup(self): """Use logger to create a logging filter.""" - setup_component(self.hass, logger.DOMAIN, self.log_config) + self.setup_logger(TEST_CONFIG) self.assertTrue(len(logging.root.handlers) > 0) handler = logging.root.handlers[-1] @@ -40,22 +60,42 @@ class TestUpdater(unittest.TestCase): def test_logger_test_filters(self): """Test resulting filter operation.""" - setup_component(self.hass, logger.DOMAIN, self.log_config) - - log_filter = logging.root.handlers[-1].filters[0] + self.setup_logger(TEST_CONFIG) # Blocked default record - record = RECORD('asdf', logging.DEBUG) - self.assertFalse(log_filter.filter(record)) + self.assert_not_logged('asdf', logging.DEBUG) # Allowed default record - record = RECORD('asdf', logging.WARNING) - self.assertTrue(log_filter.filter(record)) + self.assert_logged('asdf', logging.WARNING) # Blocked named record - record = RECORD('test', logging.DEBUG) - self.assertFalse(log_filter.filter(record)) + self.assert_not_logged('test', logging.DEBUG) # Allowed named record - record = RECORD('test', logging.INFO) - self.assertTrue(log_filter.filter(record)) + self.assert_logged('test', logging.INFO) + + def test_set_filter_empty_config(self): + """Test change log level from empty configuration.""" + self.setup_logger(NO_LOGS_CONFIG) + + self.assert_not_logged('test', logging.DEBUG) + + self.hass.services.call( + logger.DOMAIN, 'set_level', {'test': 'debug'}) + self.hass.block_till_done() + + self.assert_logged('test', logging.DEBUG) + + def test_set_filter(self): + """Test change log level of existing filter.""" + self.setup_logger(TEST_CONFIG) + + self.assert_not_logged('asdf', logging.DEBUG) + self.assert_logged('dummy', logging.WARNING) + + self.hass.services.call(logger.DOMAIN, 'set_level', + {'asdf': 'debug', 'dummy': 'info'}) + self.hass.block_till_done() + + self.assert_logged('asdf', logging.DEBUG) + self.assert_logged('dummy', logging.WARNING) From 48cf7a4af92bddc3e27ed50923c77344e885c436 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 26 Feb 2017 23:31:46 +0100 Subject: [PATCH 042/198] Move ffmpeg to dispatcher from hass.data entity store. (#6211) * Move ffmpeg to dispatcher from hass.data entity store. * fix lint * address paulus comments * add more unittest for better coverage --- .../components/binary_sensor/ffmpeg_motion.py | 17 +- .../components/binary_sensor/ffmpeg_noise.py | 15 +- homeassistant/components/ffmpeg.py | 144 +++++------ tests/components/binary_sensor/test_ffmpeg.py | 55 ++++- tests/components/test_ffmpeg.py | 227 +++++++++--------- 5 files changed, 252 insertions(+), 206 deletions(-) diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 8c822c56361..3dd3f351227 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -57,16 +57,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # generate sensor object entity = FFmpegMotion(hass, manager, config) - - # add to system - manager.async_register_device(entity) yield from async_add_devices([entity]) class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): """A binary sensor which use ffmpeg for noise detection.""" - def __init__(self, hass, config): + def __init__(self, config): """Constructor for binary sensor noise detection.""" super().__init__(config.get(CONF_INITIAL_STATE)) @@ -98,15 +95,19 @@ class FFmpegMotion(FFmpegBinarySensor): """Initialize ffmpeg motion binary sensor.""" from haffmpeg import SensorMotion - super().__init__(hass, config) + super().__init__(config) self.ffmpeg = SensorMotion( manager.binary, hass.loop, self._async_callback) - def async_start_ffmpeg(self): + @asyncio.coroutine + def _async_start_ffmpeg(self, entity_ids): """Start a FFmpeg instance. - This method must be run in the event loop and returns a coroutine. + This method is a coroutine. """ + if entity_ids is not None and self.entity_id not in entity_ids: + return + # init config self.ffmpeg.set_options( time_reset=self._config.get(CONF_RESET), @@ -116,7 +117,7 @@ class FFmpegMotion(FFmpegBinarySensor): ) # run - return self.ffmpeg.open_sensor( + yield from self.ffmpeg.open_sensor( input_source=self._config.get(CONF_INPUT), extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), ) diff --git a/homeassistant/components/binary_sensor/ffmpeg_noise.py b/homeassistant/components/binary_sensor/ffmpeg_noise.py index 8db4691d743..af5c64186f6 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_noise.py +++ b/homeassistant/components/binary_sensor/ffmpeg_noise.py @@ -54,9 +54,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # generate sensor object entity = FFmpegNoise(hass, manager, config) - - # add to system - manager.async_register_device(entity) yield from async_add_devices([entity]) @@ -67,15 +64,19 @@ class FFmpegNoise(FFmpegBinarySensor): """Initialize ffmpeg noise binary sensor.""" from haffmpeg import SensorNoise - super().__init__(hass, config) + super().__init__(config) self.ffmpeg = SensorNoise( manager.binary, hass.loop, self._async_callback) - def async_start_ffmpeg(self): + @asyncio.coroutine + def _async_start_ffmpeg(self, entity_ids): """Start a FFmpeg instance. - This method must be run in the event loop and returns a coroutine. + This method is a coroutine. """ + if entity_ids is not None and self.entity_id not in entity_ids: + return + # init config self.ffmpeg.set_options( time_duration=self._config.get(CONF_DURATION), @@ -84,7 +85,7 @@ class FFmpegNoise(FFmpegBinarySensor): ) # run - return self.ffmpeg.open_sensor( + yield from self.ffmpeg.open_sensor( input_source=self._config.get(CONF_INPUT), output_dest=self._config.get(CONF_OUTPUT), extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS), diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index c98354662e2..5b012ffad4a 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -14,6 +14,8 @@ from homeassistant.core import callback from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.config import load_yaml_config_file +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -26,6 +28,10 @@ SERVICE_START = 'start' SERVICE_STOP = 'stop' SERVICE_RESTART = 'restart' +SIGNAL_FFMPEG_START = 'ffmpeg.start' +SIGNAL_FFMPEG_STOP = 'ffmpeg.stop' +SIGNAL_FFMPEG_RESTART = 'ffmpeg.restart' + DATA_FFMPEG = 'ffmpeg' CONF_INITIAL_STATE = 'initial_state' @@ -50,22 +56,25 @@ SERVICE_FFMPEG_SCHEMA = vol.Schema({ }) -def start(hass, entity_id=None): +@callback +def async_start(hass, entity_id=None): """Start a ffmpeg process on entity.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_START, data) + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_START, data)) -def stop(hass, entity_id=None): +@callback +def async_stop(hass, entity_id=None): """Stop a ffmpeg process on entity.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_STOP, data) + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_STOP, data)) -def restart(hass, entity_id=None): +@callback +def async_restart(hass, entity_id=None): """Restart a ffmpeg process on entity.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - hass.services.call(DOMAIN, SERVICE_RESTART, data) + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_RESTART, data)) @asyncio.coroutine @@ -89,30 +98,12 @@ def async_setup(hass, config): """Handle service ffmpeg process.""" entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - devices = [device for device in manager.entities - if device.entity_id in entity_ids] + if service.service == SERVICE_START: + async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids) + elif service.service == SERVICE_STOP: + async_dispatcher_send(hass, SIGNAL_FFMPEG_STOP, entity_ids) else: - devices = manager.entities - - tasks = [] - for device in devices: - if service.service == SERVICE_START: - tasks.append(device.async_start_ffmpeg()) - elif service.service == SERVICE_STOP: - tasks.append(device.async_stop_ffmpeg()) - else: - tasks.append(device.async_restart_ffmpeg()) - - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) - - tasks.clear() - for device in devices: - tasks.append(device.async_update_ha_state()) - - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + async_dispatcher_send(hass, SIGNAL_FFMPEG_RESTART, entity_ids) hass.services.async_register( DOMAIN, SERVICE_START, async_service_handle, @@ -140,42 +131,12 @@ class FFmpegManager(object): self._cache = {} self._bin = ffmpeg_bin self._run_test = run_test - self._entities = [] @property def binary(self): """Return ffmpeg binary from config.""" return self._bin - @property - def entities(self): - """Return ffmpeg entities for services.""" - return self._entities - - @callback - def async_register_device(self, device): - """Register a ffmpeg process/device.""" - self._entities.append(device) - - @asyncio.coroutine - def async_shutdown(event): - """Stop ffmpeg process.""" - yield from device.async_stop_ffmpeg() - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_shutdown) - - # start on startup - if device.initial_state: - @asyncio.coroutine - def async_start(event): - """Start ffmpeg process.""" - yield from device.async_start_ffmpeg() - yield from device.async_update_ha_state() - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_start) - @asyncio.coroutine def async_run_test(self, input_source): """Run test on this input. TRUE is deactivate or run correct. @@ -208,6 +169,22 @@ class FFmpegBase(Entity): self.ffmpeg = None self.initial_state = initial_state + @asyncio.coroutine + def async_added_to_hass(self): + """Register dispatcher & events. + + This method is a coroutine. + """ + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg) + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg) + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg) + + # register start/stop + self._async_register_events() + @property def available(self): """Return True if entity is available.""" @@ -218,22 +195,53 @@ class FFmpegBase(Entity): """Return True if entity has to be polled for state.""" return False - def async_start_ffmpeg(self): + @asyncio.coroutine + def _async_start_ffmpeg(self, entity_ids): """Start a ffmpeg process. - This method must be run in the event loop and returns a coroutine. + This method is a coroutine. """ raise NotImplementedError() - def async_stop_ffmpeg(self): + @asyncio.coroutine + def _async_stop_ffmpeg(self, entity_ids): """Stop a ffmpeg process. - This method must be run in the event loop and returns a coroutine. + This method is a coroutine. """ - return self.ffmpeg.close() + if entity_ids is None or self.entity_id in entity_ids: + yield from self.ffmpeg.close() @asyncio.coroutine - def async_restart_ffmpeg(self): - """Stop a ffmpeg process.""" - yield from self.async_stop_ffmpeg() - yield from self.async_start_ffmpeg() + def _async_restart_ffmpeg(self, entity_ids): + """Stop a ffmpeg process. + + This method is a coroutine. + """ + if entity_ids is None or self.entity_id in entity_ids: + yield from self._async_stop_ffmpeg(None) + yield from self._async_start_ffmpeg(None) + + @callback + def _async_register_events(self): + """Register a ffmpeg process/device.""" + @asyncio.coroutine + def async_shutdown_handle(event): + """Stop ffmpeg process.""" + yield from self._async_stop_ffmpeg(None) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_shutdown_handle) + + # start on startup + if not self.initial_state: + return + + @asyncio.coroutine + def async_start_handle(event): + """Start ffmpeg process.""" + yield from self._async_start_ffmpeg(None) + self.hass.async_add_job(self.async_update_ha_state()) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_start_handle) diff --git a/tests/components/binary_sensor/test_ffmpeg.py b/tests/components/binary_sensor/test_ffmpeg.py index d2b999d1255..ffeba1870a6 100644 --- a/tests/components/binary_sensor/test_ffmpeg.py +++ b/tests/components/binary_sensor/test_ffmpeg.py @@ -2,7 +2,6 @@ from unittest.mock import patch from homeassistant.bootstrap import setup_component -from homeassistant.util.async import run_callback_threadsafe from tests.common import ( get_test_home_assistant, assert_setup_component, mock_coro) @@ -35,7 +34,7 @@ class TestFFmpegNoiseSetup(object): setup_component(self.hass, 'binary_sensor', self.config) assert self.hass.data['ffmpeg'].binary == 'ffmpeg' - assert len(self.hass.data['ffmpeg'].entities) == 1 + assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None @patch('haffmpeg.SensorNoise.open_sensor', return_value=mock_coro()) def test_setup_component_start(self, mock_start): @@ -44,15 +43,32 @@ class TestFFmpegNoiseSetup(object): setup_component(self.hass, 'binary_sensor', self.config) assert self.hass.data['ffmpeg'].binary == 'ffmpeg' - assert len(self.hass.data['ffmpeg'].entities) == 1 + assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None - entity = self.hass.data['ffmpeg'].entities[0] self.hass.start() assert mock_start.called + entity = self.hass.states.get('binary_sensor.ffmpeg_noise') + assert entity.state == 'unavailable' + + @patch('haffmpeg.SensorNoise') + def test_setup_component_start_callback(self, mock_ffmpeg): + """Setup ffmpeg component.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config) + + assert self.hass.data['ffmpeg'].binary == 'ffmpeg' + assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.ffmpeg_noise') assert entity.state == 'off' - run_callback_threadsafe( - self.hass.loop, entity._async_callback, True).result() + + mock_ffmpeg.call_args[0][2](True) + self.hass.block_till_done() + + entity = self.hass.states.get('binary_sensor.ffmpeg_noise') assert entity.state == 'on' @@ -83,7 +99,7 @@ class TestFFmpegMotionSetup(object): setup_component(self.hass, 'binary_sensor', self.config) assert self.hass.data['ffmpeg'].binary == 'ffmpeg' - assert len(self.hass.data['ffmpeg'].entities) == 1 + assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None @patch('haffmpeg.SensorMotion.open_sensor', return_value=mock_coro()) def test_setup_component_start(self, mock_start): @@ -92,13 +108,30 @@ class TestFFmpegMotionSetup(object): setup_component(self.hass, 'binary_sensor', self.config) assert self.hass.data['ffmpeg'].binary == 'ffmpeg' - assert len(self.hass.data['ffmpeg'].entities) == 1 + assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None - entity = self.hass.data['ffmpeg'].entities[0] self.hass.start() assert mock_start.called + entity = self.hass.states.get('binary_sensor.ffmpeg_motion') + assert entity.state == 'unavailable' + + @patch('haffmpeg.SensorMotion') + def test_setup_component_start_callback(self, mock_ffmpeg): + """Setup ffmpeg component.""" + with assert_setup_component(1, 'binary_sensor'): + setup_component(self.hass, 'binary_sensor', self.config) + + assert self.hass.data['ffmpeg'].binary == 'ffmpeg' + assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None + + self.hass.start() + + entity = self.hass.states.get('binary_sensor.ffmpeg_motion') assert entity.state == 'off' - run_callback_threadsafe( - self.hass.loop, entity._async_callback, True).result() + + mock_ffmpeg.call_args[0][2](True) + self.hass.block_till_done() + + entity = self.hass.states.get('binary_sensor.ffmpeg_motion') assert entity.state == 'on' diff --git a/tests/components/test_ffmpeg.py b/tests/components/test_ffmpeg.py index abc69a627de..0af90ad7836 100644 --- a/tests/components/test_ffmpeg.py +++ b/tests/components/test_ffmpeg.py @@ -3,9 +3,7 @@ import asyncio from unittest.mock import patch, MagicMock import homeassistant.components.ffmpeg as ffmpeg -from homeassistant.bootstrap import setup_component -from homeassistant.util.async import ( - run_callback_threadsafe, run_coroutine_threadsafe) +from homeassistant.bootstrap import setup_component, async_setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_coro) @@ -14,30 +12,30 @@ from tests.common import ( class MockFFmpegDev(ffmpeg.FFmpegBase): """FFmpeg device mock.""" - def __init__(self, initial_state=True, entity_id='test.ffmpeg_device'): + def __init__(self, hass, initial_state=True, + entity_id='test.ffmpeg_device'): """Initialize mock.""" super().__init__(initial_state) + self.hass = hass self.entity_id = entity_id self.ffmpeg = MagicMock self.called_stop = False self.called_start = False self.called_restart = False + self.called_entities = None @asyncio.coroutine - def async_start_ffmpeg(self): + def _async_start_ffmpeg(self, entity_ids): """Mock start.""" self.called_start = True + self.called_entities = entity_ids @asyncio.coroutine - def async_stop_ffmpeg(self): + def _async_stop_ffmpeg(self, entity_ids): """Mock stop.""" self.called_stop = True - - @asyncio.coroutine - def async_restart_ffmpeg(self): - """Mock restart.""" - self.called_restart = True + self.called_entities = entity_ids class TestFFmpegSetup(object): @@ -67,160 +65,165 @@ class TestFFmpegSetup(object): assert self.hass.services.has_service(ffmpeg.DOMAIN, 'stop') assert self.hass.services.has_service(ffmpeg.DOMAIN, 'restart') - def test_setup_component_test_register(self): - """Setup ffmpeg component test register.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - self.hass.bus.async_listen_once = MagicMock() - ffmpeg_dev = MockFFmpegDev() +@asyncio.coroutine +def test_setup_component_test_register(hass): + """Setup ffmpeg component test register.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + hass.bus.async_listen_once = MagicMock() + ffmpeg_dev = MockFFmpegDev(hass) + yield from ffmpeg_dev.async_added_to_hass() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + assert hass.bus.async_listen_once.called + assert hass.bus.async_listen_once.call_count == 2 - assert self.hass.bus.async_listen_once.called - assert self.hass.bus.async_listen_once.call_count == 2 - assert len(manager.entities) == 1 - assert manager.entities[0] == ffmpeg_dev - def test_setup_component_test_register_no_startup(self): - """Setup ffmpeg component test register without startup.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) +@asyncio.coroutine +def test_setup_component_test_register_no_startup(hass): + """Setup ffmpeg component test register without startup.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - self.hass.bus.async_listen_once = MagicMock() - ffmpeg_dev = MockFFmpegDev(False) + hass.bus.async_listen_once = MagicMock() + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + assert hass.bus.async_listen_once.called + assert hass.bus.async_listen_once.call_count == 1 - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() - assert self.hass.bus.async_listen_once.called - assert self.hass.bus.async_listen_once.call_count == 1 - assert len(manager.entities) == 1 - assert manager.entities[0] == ffmpeg_dev +@asyncio.coroutine +def test_setup_component_test_servcie_start(hass): + """Setup ffmpeg component test service start.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - def test_setup_component_test_servcie_start(self): - """Setup ffmpeg component test service start.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg.async_start(hass) + yield from hass.async_block_till_done() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + assert ffmpeg_dev.called_start - ffmpeg.start(self.hass) - self.hass.block_till_done() - assert ffmpeg_dev.called_start +@asyncio.coroutine +def test_setup_component_test_servcie_stop(hass): + """Setup ffmpeg component test service stop.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - def test_setup_component_test_servcie_stop(self): - """Setup ffmpeg component test service stop.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg.async_stop(hass) + yield from hass.async_block_till_done() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + assert ffmpeg_dev.called_stop - ffmpeg.stop(self.hass) - self.hass.block_till_done() - assert ffmpeg_dev.called_stop +@asyncio.coroutine +def test_setup_component_test_servcie_restart(hass): + """Setup ffmpeg component test service restart.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - def test_setup_component_test_servcie_restart(self): - """Setup ffmpeg component test service restart.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg.async_restart(hass) + yield from hass.async_block_till_done() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + assert ffmpeg_dev.called_stop + assert ffmpeg_dev.called_start - ffmpeg.restart(self.hass) - self.hass.block_till_done() - assert ffmpeg_dev.called_restart +@asyncio.coroutine +def test_setup_component_test_servcie_start_with_entity(hass): + """Setup ffmpeg component test service start.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - def test_setup_component_test_servcie_start_with_entity(self): - """Setup ffmpeg component test service start.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg.async_start(hass, 'test.ffmpeg_device') + yield from hass.async_block_till_done() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + assert ffmpeg_dev.called_start + assert ffmpeg_dev.called_entities == ['test.ffmpeg_device'] - ffmpeg.start(self.hass, 'test.ffmpeg_device') - self.hass.block_till_done() - assert ffmpeg_dev.called_start - - def test_setup_component_test_run_test_false(self): - """Setup ffmpeg component test run_test false.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: { +@asyncio.coroutine +def test_setup_component_test_run_test_false(hass): + """Setup ffmpeg component test run_test false.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: { 'run_test': False, }}) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + manager = hass.data[ffmpeg.DATA_FFMPEG] + with patch('haffmpeg.Test.run_test', return_value=mock_coro(False)): + yield from manager.async_run_test("blabalblabla") - assert run_coroutine_threadsafe( - manager.async_run_test("blabalblabla"), self.hass.loop).result() - assert len(manager._cache) == 0 + assert len(manager._cache) == 0 - @patch('haffmpeg.Test.run_test', - return_value=mock_coro(True)) - def test_setup_component_test_run_test(self, mock_test): - """Setup ffmpeg component test run_test.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] +@asyncio.coroutine +def test_setup_component_test_run_test(hass): + """Setup ffmpeg component test run_test.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + manager = hass.data[ffmpeg.DATA_FFMPEG] + + with patch('haffmpeg.Test.run_test', return_value=mock_coro(True)) \ + as mock_test: + yield from manager.async_run_test("blabalblabla") - assert run_coroutine_threadsafe( - manager.async_run_test("blabalblabla"), self.hass.loop).result() assert mock_test.called assert mock_test.call_count == 1 assert len(manager._cache) == 1 assert manager._cache['blabalblabla'] - assert run_coroutine_threadsafe( - manager.async_run_test("blabalblabla"), self.hass.loop).result() + yield from manager.async_run_test("blabalblabla") + assert mock_test.called assert mock_test.call_count == 1 assert len(manager._cache) == 1 assert manager._cache['blabalblabla'] - @patch('haffmpeg.Test.run_test', - return_value=mock_coro(False)) - def test_setup_component_test_run_test_test_fail(self, mock_test): - """Setup ffmpeg component test run_test.""" - with assert_setup_component(2): - setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] +@asyncio.coroutine +def test_setup_component_test_run_test_test_fail(hass): + """Setup ffmpeg component test run_test.""" + with assert_setup_component(2): + yield from async_setup_component( + hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + + manager = hass.data[ffmpeg.DATA_FFMPEG] + + with patch('haffmpeg.Test.run_test', return_value=mock_coro(False)) \ + as mock_test: + yield from manager.async_run_test("blabalblabla") - assert not run_coroutine_threadsafe( - manager.async_run_test("blabalblabla"), self.hass.loop).result() assert mock_test.called assert mock_test.call_count == 1 assert len(manager._cache) == 1 assert not manager._cache['blabalblabla'] - assert not run_coroutine_threadsafe( - manager.async_run_test("blabalblabla"), self.hass.loop).result() + yield from manager.async_run_test("blabalblabla") + assert mock_test.called assert mock_test.call_count == 1 assert len(manager._cache) == 1 From 61909e873f894591419ef25015df8360650886c3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2017 14:38:06 -0800 Subject: [PATCH 043/198] Feature/reorg recorder (#6237) * Re-organize recorder * Fix history * Fix history stats * Fix restore state * Lint * Fix session reconfigure * Move imports around * Do not start recording till HASS started * Lint * Fix logbook * Fix race condition recorder init * Better reporting on errors --- homeassistant/components/history.py | 158 +++---- homeassistant/components/logbook.py | 39 +- homeassistant/components/recorder/__init__.py | 412 +++++------------- homeassistant/components/recorder/const.py | 3 + .../components/recorder/migration.py | 88 ++++ homeassistant/components/recorder/purge.py | 31 ++ homeassistant/components/recorder/util.py | 71 +++ .../components/sensor/history_stats.py | 4 +- homeassistant/helpers/restore_state.py | 8 +- tests/common.py | 15 +- tests/components/recorder/test_init.py | 277 +----------- tests/components/recorder/test_migrate.py | 67 +++ tests/components/recorder/test_purge.py | 109 +++++ tests/components/recorder/test_util.py | 59 +++ tests/components/sensor/test_history_stats.py | 4 - tests/components/test_history.py | 18 +- tests/components/test_logbook.py | 37 +- tests/helpers/test_restore_state.py | 21 +- 18 files changed, 724 insertions(+), 697 deletions(-) create mode 100644 homeassistant/components/recorder/const.py create mode 100644 homeassistant/components/recorder/migration.py create mode 100644 homeassistant/components/recorder/purge.py create mode 100644 homeassistant/components/recorder/util.py create mode 100644 tests/components/recorder/test_migrate.py create mode 100644 tests/components/recorder/test_purge.py create mode 100644 tests/components/recorder/test_util.py diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 254115c55b1..5c68f767cd2 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -20,6 +20,7 @@ from homeassistant.components import recorder, script from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.const import ATTR_HIDDEN +from homeassistant.components.recorder.util import session_scope, execute _LOGGER = logging.getLogger(__name__) @@ -34,19 +35,20 @@ SIGNIFICANT_DOMAINS = ('thermostat', 'climate') IGNORE_DOMAINS = ('zone', 'scene',) -def last_recorder_run(): +def last_recorder_run(hass): """Retireve the last closed recorder run from the DB.""" - recorder.get_instance() - rec_runs = recorder.get_model('RecorderRuns') - with recorder.session_scope() as session: - res = recorder.query(rec_runs).order_by(rec_runs.end.desc()).first() + from homeassistant.components.recorder.models import RecorderRuns + + with session_scope(hass=hass) as session: + res = (session.query(RecorderRuns) + .order_by(RecorderRuns.end.desc()).first()) if res is None: return None session.expunge(res) return res -def get_significant_states(start_time, end_time=None, entity_id=None, +def get_significant_states(hass, start_time, end_time=None, entity_id=None, filters=None): """ Return states changes during UTC period start_time - end_time. @@ -55,50 +57,60 @@ def get_significant_states(start_time, end_time=None, entity_id=None, as well as all states from certain domains (for instance thermostat so that we get current temperature in our graphs). """ + from homeassistant.components.recorder.models import States + entity_ids = (entity_id.lower(), ) if entity_id is not None else None - states = recorder.get_model('States') - query = recorder.query(states).filter( - (states.domain.in_(SIGNIFICANT_DOMAINS) | - (states.last_changed == states.last_updated)) & - (states.last_updated > start_time)) - if filters: - query = filters.apply(query, entity_ids) - if end_time is not None: - query = query.filter(states.last_updated < end_time) + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.domain.in_(SIGNIFICANT_DOMAINS) | + (States.last_changed == States.last_updated)) & + (States.last_updated > start_time)) - states = ( - state for state in recorder.execute( - query.order_by(states.entity_id, states.last_updated)) - if (_is_significant(state) and - not state.attributes.get(ATTR_HIDDEN, False))) + if filters: + query = filters.apply(query, entity_ids) - return states_to_json(states, start_time, entity_id, filters) + if end_time is not None: + query = query.filter(States.last_updated < end_time) + + states = ( + state for state in execute( + query.order_by(States.entity_id, States.last_updated)) + if (_is_significant(state) and + not state.attributes.get(ATTR_HIDDEN, False))) + + return states_to_json(hass, states, start_time, entity_id, filters) -def state_changes_during_period(start_time, end_time=None, entity_id=None): +def state_changes_during_period(hass, start_time, end_time=None, + entity_id=None): """Return states changes during UTC period start_time - end_time.""" - states = recorder.get_model('States') - query = recorder.query(states).filter( - (states.last_changed == states.last_updated) & - (states.last_changed > start_time)) + from homeassistant.components.recorder.models import States - if end_time is not None: - query = query.filter(states.last_updated < end_time) + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated) & + (States.last_changed > start_time)) - if entity_id is not None: - query = query.filter_by(entity_id=entity_id.lower()) + if end_time is not None: + query = query.filter(States.last_updated < end_time) - states = recorder.execute( - query.order_by(states.entity_id, states.last_updated)) + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) - return states_to_json(states, start_time, entity_id) + states = execute( + query.order_by(States.entity_id, States.last_updated)) + + return states_to_json(hass, states, start_time, entity_id) -def get_states(utc_point_in_time, entity_ids=None, run=None, filters=None): +def get_states(hass, utc_point_in_time, entity_ids=None, run=None, + filters=None): """Return the states at a specific point in time.""" + from homeassistant.components.recorder.models import States + if run is None: - run = recorder.run_information(utc_point_in_time) + run = recorder.run_information(hass, utc_point_in_time) # History did not run before utc_point_in_time if run is None: @@ -106,29 +118,29 @@ def get_states(utc_point_in_time, entity_ids=None, run=None, filters=None): from sqlalchemy import and_, func - states = recorder.get_model('States') - most_recent_state_ids = recorder.query( - func.max(states.state_id).label('max_state_id') - ).filter( - (states.created >= run.start) & - (states.created < utc_point_in_time) & - (~states.domain.in_(IGNORE_DOMAINS))) - if filters: - most_recent_state_ids = filters.apply(most_recent_state_ids, - entity_ids) + with session_scope(hass=hass) as session: + most_recent_state_ids = session.query( + func.max(States.state_id).label('max_state_id') + ).filter( + (States.created >= run.start) & + (States.created < utc_point_in_time) & + (~States.domain.in_(IGNORE_DOMAINS))) - most_recent_state_ids = most_recent_state_ids.group_by( - states.entity_id).subquery() + if filters: + most_recent_state_ids = filters.apply(most_recent_state_ids, + entity_ids) - query = recorder.query(states).join(most_recent_state_ids, and_( - states.state_id == most_recent_state_ids.c.max_state_id)) + most_recent_state_ids = most_recent_state_ids.group_by( + States.entity_id).subquery() - for state in recorder.execute(query): - if not state.attributes.get(ATTR_HIDDEN, False): - yield state + query = session.query(States).join(most_recent_state_ids, and_( + States.state_id == most_recent_state_ids.c.max_state_id)) + + return [state for state in execute(query) + if not state.attributes.get(ATTR_HIDDEN, False)] -def states_to_json(states, start_time, entity_id, filters=None): +def states_to_json(hass, states, start_time, entity_id, filters=None): """Convert SQL results into JSON friendly data structure. This takes our state list and turns it into a JSON friendly data @@ -143,7 +155,7 @@ def states_to_json(states, start_time, entity_id, filters=None): entity_ids = [entity_id] if entity_id is not None else None # Get the states at the start time - for state in get_states(start_time, entity_ids, filters=filters): + for state in get_states(hass, start_time, entity_ids, filters=filters): state.last_changed = start_time state.last_updated = start_time result[state.entity_id].append(state) @@ -154,9 +166,9 @@ def states_to_json(states, start_time, entity_id, filters=None): return result -def get_state(utc_point_in_time, entity_id, run=None): +def get_state(hass, utc_point_in_time, entity_id, run=None): """Return a state at a specific point in time.""" - states = list(get_states(utc_point_in_time, (entity_id,), run)) + states = list(get_states(hass, utc_point_in_time, (entity_id,), run)) return states[0] if states else None @@ -173,7 +185,6 @@ def setup(hass, config): filters.included_entities = include[CONF_ENTITIES] filters.included_domains = include[CONF_DOMAINS] - recorder.get_instance() hass.http.register_view(HistoryPeriodView(filters)) register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box') @@ -223,8 +234,8 @@ class HistoryPeriodView(HomeAssistantView): entity_id = request.GET.get('filter_entity_id') result = yield from request.app['hass'].loop.run_in_executor( - None, get_significant_states, start_time, end_time, entity_id, - self.filters) + None, get_significant_states, request.app['hass'], start_time, + end_time, entity_id, self.filters) result = result.values() if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start @@ -254,41 +265,42 @@ class Filters(object): * if include and exclude is defined - select the entities specified in the include and filter out the ones from the exclude list. """ - states = recorder.get_model('States') + from homeassistant.components.recorder.models import States + # specific entities requested - do not in/exclude anything if entity_ids is not None: - return query.filter(states.entity_id.in_(entity_ids)) - query = query.filter(~states.domain.in_(IGNORE_DOMAINS)) + return query.filter(States.entity_id.in_(entity_ids)) + query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) filter_query = None # filter if only excluded domain is configured if self.excluded_domains and not self.included_domains: - filter_query = ~states.domain.in_(self.excluded_domains) + filter_query = ~States.domain.in_(self.excluded_domains) if self.included_entities: - filter_query &= states.entity_id.in_(self.included_entities) + filter_query &= States.entity_id.in_(self.included_entities) # filter if only included domain is configured elif not self.excluded_domains and self.included_domains: - filter_query = states.domain.in_(self.included_domains) + filter_query = States.domain.in_(self.included_domains) if self.included_entities: - filter_query |= states.entity_id.in_(self.included_entities) + filter_query |= States.entity_id.in_(self.included_entities) # filter if included and excluded domain is configured elif self.excluded_domains and self.included_domains: - filter_query = ~states.domain.in_(self.excluded_domains) + filter_query = ~States.domain.in_(self.excluded_domains) if self.included_entities: - filter_query &= (states.domain.in_(self.included_domains) | - states.entity_id.in_(self.included_entities)) + filter_query &= (States.domain.in_(self.included_domains) | + States.entity_id.in_(self.included_entities)) else: - filter_query &= (states.domain.in_(self.included_domains) & ~ - states.domain.in_(self.excluded_domains)) + filter_query &= (States.domain.in_(self.included_domains) & ~ + States.domain.in_(self.excluded_domains)) # no domain filter just included entities elif not self.excluded_domains and not self.included_domains and \ self.included_entities: - filter_query = states.entity_id.in_(self.included_entities) + filter_query = States.entity_id.in_(self.included_entities) if filter_query is not None: query = query.filter(filter_query) # finally apply excluded entities filter if configured if self.excluded_entities: - query = query.filter(~states.entity_id.in_(self.excluded_entities)) + query = query.filter(~States.entity_id.in_(self.excluded_entities)) return query diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 30d52303099..92f99887867 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from homeassistant.components import recorder, sun +from homeassistant.components import sun from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView from homeassistant.const import (EVENT_HOMEASSISTANT_START, @@ -98,7 +98,7 @@ def setup(hass, config): message = message.async_render() async_log_entry(hass, name, message, domain, entity_id) - hass.http.register_view(LogbookView(config)) + hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) register_built_in_panel(hass, 'logbook', 'Logbook', 'mdi:format-list-bulleted-type') @@ -132,20 +132,11 @@ class LogbookView(HomeAssistantView): start_day = dt_util.as_utc(datetime) end_day = start_day + timedelta(days=1) + hass = request.app['hass'] - def get_results(): - """Query DB for results.""" - events = recorder.get_model('Events') - query = recorder.query('Events').order_by( - events.time_fired).filter( - (events.time_fired > start_day) & - (events.time_fired < end_day)) - events = recorder.execute(query) - return _exclude_events(events, self.config) - - events = yield from request.app['hass'].loop.run_in_executor( - None, get_results) - + events = yield from hass.loop.run_in_executor( + None, _get_events, hass, start_day, end_day) + events = _exclude_events(events, self.config) return self.json(humanify(events)) @@ -282,17 +273,31 @@ def humanify(events): entity_id) +def _get_events(hass, start_day, end_day): + """Get events for a period of time.""" + from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.util import ( + execute, session_scope) + + with session_scope(hass=hass) as session: + query = session.query(Events).order_by( + Events.time_fired).filter( + (Events.time_fired > start_day) & + (Events.time_fired < end_day)) + return execute(query) + + def _exclude_events(events, config): """Get lists of excluded entities and platforms.""" excluded_entities = [] excluded_domains = [] included_entities = [] included_domains = [] - exclude = config[DOMAIN].get(CONF_EXCLUDE) + exclude = config.get(CONF_EXCLUDE) if exclude: excluded_entities = exclude[CONF_ENTITIES] excluded_domains = exclude[CONF_DOMAINS] - include = config[DOMAIN].get(CONF_INCLUDE) + include = config.get(CONF_INCLUDE) if include: included_entities = include[CONF_ENTITIES] included_domains = include[CONF_DOMAINS] diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 0f8d7b48fe2..c60b95d1cae 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -8,27 +8,31 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/recorder/ """ import asyncio +import concurrent.futures import logging import queue import threading import time from datetime import timedelta, datetime -from typing import Any, Union, Optional, List, Dict -from contextlib import contextmanager +from typing import Optional, Dict import voluptuous as vol -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import ( + HomeAssistant, callback, split_entity_id, CoreState) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ENTITIES, CONF_EXCLUDE, CONF_DOMAINS, - CONF_INCLUDE, EVENT_HOMEASSISTANT_STOP, + CONF_INCLUDE, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, QueryType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util +from . import purge, migration +from .const import DATA_INSTANCE +from .util import session_scope + DOMAIN = 'recorder' REQUIREMENTS = ['sqlalchemy==1.1.5'] @@ -39,9 +43,7 @@ DEFAULT_DB_FILE = 'home-assistant_v2.db' CONF_DB_URL = 'db_url' CONF_PURGE_DAYS = 'purge_days' -RETRIES = 3 CONNECT_RETRY_WAIT = 10 -QUERY_RETRY_WAIT = 0.1 ERROR_QUERY = "Error during query: %s" FILTER_SCHEMA = vol.Schema({ @@ -65,88 +67,32 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -_INSTANCE = None # type: Any _LOGGER = logging.getLogger(__name__) -@contextmanager -def session_scope(): - """Provide a transactional scope around a series of operations.""" - session = _INSTANCE.get_session() - try: - yield session - session.commit() - except Exception as err: # pylint: disable=broad-except - _LOGGER.error(ERROR_QUERY, err) - session.rollback() - raise - finally: - session.close() - - -@asyncio.coroutine -def async_get_instance(): - """Throw error if recorder not initialized.""" - if _INSTANCE is None: - raise RuntimeError("Recorder not initialized.") - - yield from _INSTANCE.async_db_ready.wait() - - return _INSTANCE - - -def get_instance(): - """Throw error if recorder not initialized.""" - if _INSTANCE is None: - raise RuntimeError("Recorder not initialized.") - - ident = _INSTANCE.hass.loop.__dict__.get("_thread_ident") - if ident is not None and ident == threading.get_ident(): - raise RuntimeError('Cannot be called from within the event loop') - - _wait(_INSTANCE.db_ready, "Database not ready") - - return _INSTANCE - - -# pylint: disable=invalid-sequence-index -def execute(qry: QueryType) -> List[Any]: - """Query the database and convert the objects to HA native form. - - This method also retries a few times in the case of stale connections. +def wait_connection_ready(hass): """ - get_instance() - from sqlalchemy.exc import SQLAlchemyError - with session_scope() as session: - for _ in range(0, RETRIES): - try: - return [ - row for row in - (row.to_native() for row in qry) - if row is not None] - except SQLAlchemyError as err: - _LOGGER.error(ERROR_QUERY, err) - session.rollback() - time.sleep(QUERY_RETRY_WAIT) - return [] + Wait till the connection is ready. + + Returns a coroutine object. + """ + return hass.data[DATA_INSTANCE].async_db_ready.wait() -def run_information(point_in_time: Optional[datetime]=None): +def run_information(hass, point_in_time: Optional[datetime]=None): """Return information about current run. There is also the run that covers point_in_time. """ - ins = get_instance() + from . import models + ins = hass.data[DATA_INSTANCE] - recorder_runs = get_model('RecorderRuns') + recorder_runs = models.RecorderRuns if point_in_time is None or point_in_time > ins.recording_start: - return recorder_runs( - end=None, - start=ins.recording_start, - closed_incorrect=False) + return ins.run_info - with session_scope() as session: - res = query(recorder_runs).filter( + with session_scope(hass=hass) as session: + res = session.query(recorder_runs).filter( (recorder_runs.start < point_in_time) & (recorder_runs.end > point_in_time)).first() if res: @@ -154,88 +100,67 @@ def run_information(point_in_time: Optional[datetime]=None): return res -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +@asyncio.coroutine +def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Setup the recorder.""" - global _INSTANCE # pylint: disable=global-statement + conf = config.get(DOMAIN, {}) + purge_days = conf.get(CONF_PURGE_DAYS) - if _INSTANCE is not None: - _LOGGER.error("Only a single instance allowed") - return False - - purge_days = config.get(DOMAIN, {}).get(CONF_PURGE_DAYS) - - db_url = config.get(DOMAIN, {}).get(CONF_DB_URL, None) + db_url = conf.get(CONF_DB_URL, None) if not db_url: db_url = DEFAULT_URL.format( hass_config_path=hass.config.path(DEFAULT_DB_FILE)) - include = config.get(DOMAIN, {}).get(CONF_INCLUDE, {}) - exclude = config.get(DOMAIN, {}).get(CONF_EXCLUDE, {}) - _INSTANCE = Recorder(hass, purge_days=purge_days, uri=db_url, - include=include, exclude=exclude) - _INSTANCE.start() + include = conf.get(CONF_INCLUDE, {}) + exclude = conf.get(CONF_EXCLUDE, {}) + hass.data[DATA_INSTANCE] = Recorder( + hass, purge_days=purge_days, uri=db_url, include=include, + exclude=exclude) + hass.data[DATA_INSTANCE].async_initialize() + hass.data[DATA_INSTANCE].start() return True -def query(model_name: Union[str, Any], session=None, *args) -> QueryType: - """Helper to return a query handle.""" - if session is None: - session = get_instance().get_session() - - if isinstance(model_name, str): - return session.query(get_model(model_name), *args) - return session.query(model_name, *args) - - -def get_model(model_name: str) -> Any: - """Get a model class.""" - from homeassistant.components.recorder import models - try: - return getattr(models, model_name) - except AttributeError: - _LOGGER.error("Invalid model name %s", model_name) - return None - - class Recorder(threading.Thread): """A threaded recorder class.""" def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, include: Dict, exclude: Dict) -> None: """Initialize the recorder.""" - threading.Thread.__init__(self) + threading.Thread.__init__(self, name='Recorder') self.hass = hass self.purge_days = purge_days self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri - self.db_ready = threading.Event() self.async_db_ready = asyncio.Event(loop=hass.loop) self.engine = None # type: Any - self._run = None # type: Any + self.run_info = None # type: Any self.include_e = include.get(CONF_ENTITIES, []) self.include_d = include.get(CONF_DOMAINS, []) self.exclude = exclude.get(CONF_ENTITIES, []) + \ exclude.get(CONF_DOMAINS, []) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - hass.bus.listen(MATCH_ALL, self.event_listener) - self.get_session = None + @callback + def async_initialize(self): + """Initialize the recorder.""" + self.hass.bus.async_listen(MATCH_ALL, self.event_listener) + def run(self): """Start processing events to save.""" - from homeassistant.components.recorder.models import Events, States from sqlalchemy.exc import SQLAlchemyError + from .models import States, Events while True: try: self._setup_connection() + migration.migrate_schema(self) self._setup_run() - self.db_ready.set() self.hass.loop.call_soon_threadsafe(self.async_db_ready.set) break except SQLAlchemyError as err: @@ -243,9 +168,49 @@ class Recorder(threading.Thread): "in %s seconds)", err, CONNECT_RETRY_WAIT) time.sleep(CONNECT_RETRY_WAIT) - if self.purge_days is not None: - async_track_time_interval( - self.hass, self._purge_old_data, timedelta(days=2)) + purge_task = object() + shutdown_task = object() + hass_started = concurrent.futures.Future() + + @callback + def register(): + """Post connection initialize.""" + def shutdown(event): + """Shut down the Recorder.""" + if not hass_started.done(): + hass_started.set_result(shutdown_task) + self.queue.put(None) + self.join() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + shutdown) + + if self.hass.state == CoreState.running: + hass_started.set_result(None) + else: + @callback + def notify_hass_started(event): + """Notify that hass has started.""" + hass_started.set_result(None) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, + notify_hass_started) + + if self.purge_days is not None: + @callback + def do_purge(now): + """Event listener for purging data.""" + self.queue.put(purge_task) + + async_track_time_interval(self.hass, do_purge, + timedelta(days=2)) + + self.hass.add_job(register) + result = hass_started.result() + + # If shutdown happened before HASS finished starting + if result is shutdown_task: + return while True: event = self.queue.get() @@ -255,8 +220,10 @@ class Recorder(threading.Thread): self._close_connection() self.queue.task_done() return - - if event.event_type == EVENT_TIME_CHANGED: + elif event is purge_task: + purge.purge_old_data(self, self.purge_days) + continue + elif event.event_type == EVENT_TIME_CHANGED: self.queue.task_done() continue @@ -280,17 +247,14 @@ class Recorder(threading.Thread): self.queue.task_done() continue - with session_scope() as session: + with session_scope(session=self.get_session()) as session: dbevent = Events.from_event(event) - self._commit(session, dbevent) + session.add(dbevent) - if event.event_type != EVENT_STATE_CHANGED: - self.queue.task_done() - continue - - dbstate = States.from_event(event) - dbstate.event_id = dbevent.event_id - self._commit(session, dbstate) + if event.event_type == EVENT_STATE_CHANGED: + dbstate = States.from_event(event) + dbstate.event_id = dbevent.event_id + session.add(dbstate) self.queue.task_done() @@ -299,27 +263,16 @@ class Recorder(threading.Thread): """Listen for new events and put them in the process queue.""" self.queue.put(event) - def shutdown(self, event): - """Tell the recorder to shut down.""" - global _INSTANCE # pylint: disable=global-statement - self.queue.put(None) - self.join() - _INSTANCE = None - def block_till_done(self): """Block till all events processed.""" self.queue.join() - def block_till_db_ready(self): - """Block until the database session is ready.""" - _wait(self.db_ready, "Database not ready") - def _setup_connection(self): """Ensure database is ready to fly.""" - import homeassistant.components.recorder.models as models from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker + from . import models if self.db_url == 'sqlite://' or ':memory:' in self.db_url: from sqlalchemy.pool import StaticPool @@ -334,85 +287,6 @@ class Recorder(threading.Thread): models.Base.metadata.create_all(self.engine) session_factory = sessionmaker(bind=self.engine) self.get_session = scoped_session(session_factory) - self._migrate_schema() - - def _migrate_schema(self): - """Check if the schema needs to be upgraded.""" - from homeassistant.components.recorder.models import SCHEMA_VERSION - schema_changes = get_model('SchemaChanges') - with session_scope() as session: - res = session.query(schema_changes).order_by( - schema_changes.change_id.desc()).first() - current_version = getattr(res, 'schema_version', None) - - if current_version == SCHEMA_VERSION: - return - _LOGGER.debug("Schema version incorrect: %s", current_version) - - if current_version is None: - current_version = self._inspect_schema_version() - _LOGGER.debug("No schema version found. Inspected version: %s", - current_version) - - for version in range(current_version, SCHEMA_VERSION): - new_version = version + 1 - _LOGGER.info("Upgrading recorder db schema to version %s", - new_version) - self._apply_update(new_version) - self._commit(session, - schema_changes(schema_version=new_version)) - _LOGGER.info("Upgraded recorder db schema to version %s", - new_version) - - def _apply_update(self, new_version): - """Perform operations to bring schema up to date.""" - from sqlalchemy import Table - import homeassistant.components.recorder.models as models - - if new_version == 1: - def create_index(table_name, column_name): - """Create an index for the specified table and column.""" - table = Table(table_name, models.Base.metadata) - name = "_".join(("ix", table_name, column_name)) - # Look up the index object that was created from the models - index = next(idx for idx in table.indexes if idx.name == name) - _LOGGER.debug("Creating index for table %s column %s", - table_name, column_name) - index.create(self.engine) - _LOGGER.debug("Index creation done for table %s column %s", - table_name, column_name) - - create_index("events", "time_fired") - else: - raise ValueError("No schema migration defined for version {}" - .format(new_version)) - - def _inspect_schema_version(self): - """Determine the schema version by inspecting the db structure. - - When the schema verison is not present in the db, either db was just - created with the correct schema, or this is a db created before schema - versions were tracked. For now, we'll test if the changes for schema - version 1 are present to make the determination. Eventually this logic - can be removed and we can assume a new db is being created. - """ - from sqlalchemy.engine import reflection - import homeassistant.components.recorder.models as models - inspector = reflection.Inspector.from_engine(self.engine) - indexes = inspector.get_indexes("events") - with session_scope() as session: - for index in indexes: - if index['column_names'] == ["time_fired"]: - # Schema addition from version 1 detected. New DB. - current_version = models.SchemaChanges( - schema_version=models.SCHEMA_VERSION) - self._commit(session, current_version) - return models.SCHEMA_VERSION - - # Version 1 schema changes not found, this db needs to be migrated. - current_version = models.SchemaChanges(schema_version=0) - self._commit(session, current_version) - return current_version.schema_version def _close_connection(self): """Close the connection.""" @@ -422,93 +296,27 @@ class Recorder(threading.Thread): def _setup_run(self): """Log the start of the current run.""" - recorder_runs = get_model('RecorderRuns') - with session_scope() as session: - for run in query( - recorder_runs, session=session).filter_by(end=None): + from .models import RecorderRuns + + with session_scope(session=self.get_session()) as session: + for run in session.query(RecorderRuns).filter_by(end=None): run.closed_incorrect = True run.end = self.recording_start _LOGGER.warning("Ended unfinished session (id=%s from %s)", run.run_id, run.start) session.add(run) - _LOGGER.warning("Found unfinished sessions") - - self._run = recorder_runs( + self.run_info = RecorderRuns( start=self.recording_start, created=dt_util.utcnow() ) - self._commit(session, self._run) + session.add(self.run_info) + session.flush() + session.expunge(self.run_info) def _close_run(self): """Save end time for current run.""" - with session_scope() as session: - self._run.end = dt_util.utcnow() - self._commit(session, self._run) - self._run = None - - def _purge_old_data(self, _=None): - """Purge events and states older than purge_days ago.""" - from homeassistant.components.recorder.models import Events, States - - if not self.purge_days or self.purge_days < 1: - _LOGGER.debug("purge_days set to %s, will not purge any old data.", - self.purge_days) - return - - purge_before = dt_util.utcnow() - timedelta(days=self.purge_days) - - def _purge_states(session): - deleted_rows = session.query(States) \ - .filter((States.created < purge_before)) \ - .delete(synchronize_session=False) - _LOGGER.debug("Deleted %s states", deleted_rows) - - with session_scope() as session: - if self._commit(session, _purge_states): - _LOGGER.info("Purged states created before %s", purge_before) - - def _purge_events(session): - deleted_rows = session.query(Events) \ - .filter((Events.created < purge_before)) \ - .delete(synchronize_session=False) - _LOGGER.debug("Deleted %s events", deleted_rows) - - with session_scope() as session: - if self._commit(session, _purge_events): - _LOGGER.info("Purged events created before %s", purge_before) - - # Execute sqlite vacuum command to free up space on disk - if self.engine.driver == 'sqlite': - _LOGGER.info("Vacuuming SQLite to free space") - self.engine.execute("VACUUM") - - @staticmethod - def _commit(session, work): - """Commit & retry work: Either a model or in a function.""" - import sqlalchemy.exc - for _ in range(0, RETRIES): - try: - if callable(work): - work(session) - else: - session.add(work) - session.commit() - return True - except sqlalchemy.exc.OperationalError as err: - _LOGGER.error(ERROR_QUERY, err) - session.rollback() - time.sleep(QUERY_RETRY_WAIT) - return False - - -def _wait(event, message): - """Event wait helper.""" - for retry in (10, 20, 30): - event.wait(10) - if event.is_set(): - return - msg = "{} ({} seconds)".format(message, retry) - _LOGGER.warning(msg) - if not event.is_set(): - raise HomeAssistantError(msg) + with session_scope(session=self.get_session()) as session: + self.run_info.end = dt_util.utcnow() + session.add(self.run_info) + self.run_info = None diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py new file mode 100644 index 00000000000..e2716ea982a --- /dev/null +++ b/homeassistant/components/recorder/const.py @@ -0,0 +1,3 @@ +"""Recorder constants.""" + +DATA_INSTANCE = 'recorder_instance' diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py new file mode 100644 index 00000000000..09c5e9837c3 --- /dev/null +++ b/homeassistant/components/recorder/migration.py @@ -0,0 +1,88 @@ +"""Schema migration helpers.""" +import logging + +from .util import session_scope + +_LOGGER = logging.getLogger(__name__) + + +def migrate_schema(instance): + """Check if the schema needs to be upgraded.""" + from .models import SchemaChanges, SCHEMA_VERSION + + with session_scope(session=instance.get_session()) as session: + res = session.query(SchemaChanges).order_by( + SchemaChanges.change_id.desc()).first() + current_version = getattr(res, 'schema_version', None) + + if current_version == SCHEMA_VERSION: + return + + _LOGGER.debug("Database requires upgrade. Schema version: %s", + current_version) + + if current_version is None: + current_version = _inspect_schema_version(instance.engine, session) + _LOGGER.debug("No schema version found. Inspected version: %s", + current_version) + + for version in range(current_version, SCHEMA_VERSION): + new_version = version + 1 + _LOGGER.info("Upgrading recorder db schema to version %s", + new_version) + _apply_update(instance.engine, new_version) + session.add(SchemaChanges(schema_version=new_version)) + + _LOGGER.info("Upgrade to version %s done", new_version) + + +def _apply_update(engine, new_version): + """Perform operations to bring schema up to date.""" + from sqlalchemy import Table + from . import models + + if new_version == 1: + def create_index(table_name, column_name): + """Create an index for the specified table and column.""" + table = Table(table_name, models.Base.metadata) + name = "_".join(("ix", table_name, column_name)) + # Look up the index object that was created from the models + index = next(idx for idx in table.indexes if idx.name == name) + _LOGGER.debug("Creating index for table %s column %s", + table_name, column_name) + index.create(engine) + _LOGGER.debug("Index creation done for table %s column %s", + table_name, column_name) + + create_index("events", "time_fired") + else: + raise ValueError("No schema migration defined for version {}" + .format(new_version)) + + +def _inspect_schema_version(engine, session): + """Determine the schema version by inspecting the db structure. + + When the schema verison is not present in the db, either db was just + created with the correct schema, or this is a db created before schema + versions were tracked. For now, we'll test if the changes for schema + version 1 are present to make the determination. Eventually this logic + can be removed and we can assume a new db is being created. + """ + from sqlalchemy.engine import reflection + from .models import SchemaChanges, SCHEMA_VERSION + + inspector = reflection.Inspector.from_engine(engine) + indexes = inspector.get_indexes("events") + + for index in indexes: + if index['column_names'] == ["time_fired"]: + # Schema addition from version 1 detected. New DB. + session.add(SchemaChanges( + schema_version=SCHEMA_VERSION)) + return SCHEMA_VERSION + + # Version 1 schema changes not found, this db needs to be migrated. + current_version = SchemaChanges(schema_version=0) + session.add(current_version) + return current_version.schema_version diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py new file mode 100644 index 00000000000..2b675e72759 --- /dev/null +++ b/homeassistant/components/recorder/purge.py @@ -0,0 +1,31 @@ +"""Purge old data helper.""" +from datetime import timedelta +import logging + +import homeassistant.util.dt as dt_util + +from .util import session_scope + +_LOGGER = logging.getLogger(__name__) + + +def purge_old_data(instance, purge_days): + """Purge events and states older than purge_days ago.""" + from .models import States, Events + purge_before = dt_util.utcnow() - timedelta(days=purge_days) + + with session_scope(session=instance.get_session()) as session: + deleted_rows = session.query(States) \ + .filter((States.created < purge_before)) \ + .delete(synchronize_session=False) + _LOGGER.debug("Deleted %s states", deleted_rows) + + deleted_rows = session.query(Events) \ + .filter((Events.created < purge_before)) \ + .delete(synchronize_session=False) + _LOGGER.debug("Deleted %s events", deleted_rows) + + # Execute sqlite vacuum command to free up space on disk + if instance.engine.driver == 'sqlite': + _LOGGER.info("Vacuuming SQLite to free space") + instance.engine.execute("VACUUM") diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py new file mode 100644 index 00000000000..e4ea1af1060 --- /dev/null +++ b/homeassistant/components/recorder/util.py @@ -0,0 +1,71 @@ +"""SQLAlchemy util functions.""" +from contextlib import contextmanager +import logging +import time + +from .const import DATA_INSTANCE + +_LOGGER = logging.getLogger(__name__) + +RETRIES = 3 +QUERY_RETRY_WAIT = 0.1 + + +@contextmanager +def session_scope(*, hass=None, session=None): + """Provide a transactional scope around a series of operations.""" + if session is None and hass is not None: + session = hass.data[DATA_INSTANCE].get_session() + + if session is None: + raise RuntimeError('Session required') + + try: + yield session + session.commit() + except Exception as err: # pylint: disable=broad-except + _LOGGER.error('Error executing query: %s', err) + session.rollback() + raise + finally: + session.close() + + +def commit(session, work): + """Commit & retry work: Either a model or in a function.""" + import sqlalchemy.exc + for _ in range(0, RETRIES): + try: + if callable(work): + work(session) + else: + session.add(work) + session.commit() + return True + except sqlalchemy.exc.OperationalError as err: + _LOGGER.error('Error executing query: %s', err) + session.rollback() + time.sleep(QUERY_RETRY_WAIT) + return False + + +def execute(qry): + """Query the database and convert the objects to HA native form. + + This method also retries a few times in the case of stale connections. + """ + from sqlalchemy.exc import SQLAlchemyError + + for tryno in range(0, RETRIES): + try: + return [ + row for row in + (row.to_native() for row in qry) + if row is not None] + except SQLAlchemyError as err: + _LOGGER.error('Error executing query: %s', err) + + if tryno == RETRIES - 1: + raise + else: + time.sleep(QUERY_RETRY_WAIT) diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index b019e6745fb..eb54869d66f 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -164,13 +164,13 @@ class HistoryStatsSensor(Entity): # Get history between start and end history_list = history.state_changes_during_period( - start, end, str(self._entity_id)) + self.hass, start, end, str(self._entity_id)) if self._entity_id not in history_list.keys(): return # Get the first state - last_state = history.get_state(start, self._entity_id) + last_state = history.get_state(self.hass, start, self._entity_id) last_state = (last_state is not None and last_state == self._entity_state) last_time = dt_util.as_timestamp(start) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 86cd3e7037f..4ac1e442546 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, CoreState, callback from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.components.history import get_states, last_recorder_run from homeassistant.components.recorder import ( - async_get_instance, DOMAIN as _RECORDER) + wait_connection_ready, DOMAIN as _RECORDER) import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def _load_restore_cache(hass: HomeAssistant): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, remove_cache) - last_run = last_recorder_run() + last_run = last_recorder_run(hass) if last_run is None or last_run.end is None: _LOGGER.debug('Not creating cache - no suitable last run found: %s', @@ -38,7 +38,7 @@ def _load_restore_cache(hass: HomeAssistant): last_end_time = last_end_time.replace(tzinfo=dt_util.UTC) _LOGGER.debug("Last run: %s - %s", last_run.start, last_end_time) - states = get_states(last_end_time, run=last_run) + states = get_states(hass, last_end_time, run=last_run) # Cache the states hass.data[DATA_RESTORE_CACHE] = { @@ -58,7 +58,7 @@ def async_get_last_state(hass, entity_id: str): hass.state) return None - yield from async_get_instance() # Ensure recorder ready + yield from wait_connection_ready(hass) if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) diff --git a/tests/common.py b/tests/common.py index 93ddc7c2f65..55d6896d410 100644 --- a/tests/common.py +++ b/tests/common.py @@ -28,7 +28,8 @@ from homeassistant.components import sun, mqtt, recorder from homeassistant.components.http.auth import auth_middleware from homeassistant.components.http.const import ( KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS) -from homeassistant.util.async import run_callback_threadsafe +from homeassistant.util.async import ( + run_callback_threadsafe, run_coroutine_threadsafe) _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) @@ -464,15 +465,17 @@ def assert_setup_component(count, domain=None): .format(count, res_len, res) -def init_recorder_component(hass, add_config=None, db_ready_callback=None): +def init_recorder_component(hass, add_config=None): """Initialize the recorder.""" config = dict(add_config) if add_config else {} config[recorder.CONF_DB_URL] = 'sqlite://' # In memory DB - assert setup_component(hass, recorder.DOMAIN, - {recorder.DOMAIN: config}) - assert recorder.DOMAIN in hass.config.components - recorder.get_instance().block_till_db_ready() + with patch('homeassistant.components.recorder.migration.migrate_schema'): + assert setup_component(hass, recorder.DOMAIN, + {recorder.DOMAIN: config}) + assert recorder.DOMAIN in hass.config.components + run_coroutine_threadsafe( + recorder.wait_connection_ready(hass), hass.loop).result() _LOGGER.info("In-memory recorder successfully started") diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index fa38a9d3784..0724313dcea 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1,94 +1,29 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access -import json -from datetime import datetime, timedelta import unittest -from unittest.mock import patch, call, MagicMock import pytest -from sqlalchemy import create_engine from homeassistant.core import callback from homeassistant.const import MATCH_ALL -from homeassistant.components import recorder +from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.models import States, Events from tests.common import get_test_home_assistant, init_recorder_component -from tests.components.recorder import models_original -class BaseTestRecorder(unittest.TestCase): - """Base class for common recorder tests.""" +class TestRecorder(unittest.TestCase): + """Test the recorder module.""" def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() init_recorder_component(self.hass) self.hass.start() - recorder.get_instance().block_till_done() def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - with self.assertRaises(RuntimeError): - recorder.get_instance() - - def _add_test_states(self): - """Add multiple states to the db for testing.""" - now = datetime.now() - five_days_ago = now - timedelta(days=5) - attributes = {'test_attr': 5, 'test_attr_10': 'nice'} - - self.hass.block_till_done() - recorder._INSTANCE.block_till_done() - - with recorder.session_scope() as session: - for event_id in range(5): - if event_id < 3: - timestamp = five_days_ago - state = 'purgeme' - else: - timestamp = now - state = 'dontpurgeme' - - session.add(recorder.get_model('States')( - entity_id='test.recorder2', - domain='sensor', - state=state, - attributes=json.dumps(attributes), - last_changed=timestamp, - last_updated=timestamp, - created=timestamp, - event_id=event_id + 1000 - )) - - def _add_test_events(self): - """Add a few events for testing.""" - now = datetime.now() - five_days_ago = now - timedelta(days=5) - event_data = {'test_attr': 5, 'test_attr_10': 'nice'} - - self.hass.block_till_done() - recorder._INSTANCE.block_till_done() - - with recorder.session_scope() as session: - for event_id in range(5): - if event_id < 2: - timestamp = five_days_ago - event_type = 'EVENT_TEST_PURGE' - else: - timestamp = now - event_type = 'EVENT_TEST' - - session.add(recorder.get_model('Events')( - event_type=event_type, - event_data=json.dumps(event_data), - origin='LOCAL', - created=timestamp, - time_fired=timestamp, - )) - - -class TestRecorder(BaseTestRecorder): - """Test the recorder module.""" def test_saving_state(self): """Test saving and restoring a state.""" @@ -99,15 +34,14 @@ class TestRecorder(BaseTestRecorder): self.hass.states.set(entity_id, state, attributes) self.hass.block_till_done() - recorder._INSTANCE.block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() - db_states = recorder.query('States') - states = recorder.execute(db_states) + with session_scope(hass=self.hass) as session: + db_states = list(session.query(States)) + assert len(db_states) == 1 + state = db_states[0].to_native() - assert db_states[0].event_id is not None - - self.assertEqual(1, len(states)) - self.assertEqual(self.hass.states.get(entity_id), states[0]) + assert state == self.hass.states.get(entity_id) def test_saving_event(self): """Test saving and restoring an event.""" @@ -127,17 +61,17 @@ class TestRecorder(BaseTestRecorder): self.hass.bus.fire(event_type, event_data) self.hass.block_till_done() - recorder._INSTANCE.block_till_done() - - db_events = recorder.execute( - recorder.query('Events').filter_by( - event_type=event_type)) assert len(events) == 1 - assert len(db_events) == 1 - event = events[0] - db_event = db_events[0] + + self.hass.data[DATA_INSTANCE].block_till_done() + + with session_scope(hass=self.hass) as session: + db_events = list(session.query(Events).filter_by( + event_type=event_type)) + assert len(db_events) == 1 + db_event = db_events[0].to_native() assert event.event_type == db_event.event_type assert event.data == db_event.data @@ -147,110 +81,6 @@ class TestRecorder(BaseTestRecorder): assert event.time_fired.replace(microsecond=0) == \ db_event.time_fired.replace(microsecond=0) - def test_purge_old_states(self): - """Test deleting old states.""" - self._add_test_states() - # make sure we start with 5 states - states = recorder.query('States') - self.assertEqual(states.count(), 5) - - # run purge_old_data() - recorder._INSTANCE.purge_days = 4 - recorder._INSTANCE._purge_old_data() - - # we should only have 2 states left after purging - self.assertEqual(states.count(), 2) - - def test_purge_old_events(self): - """Test deleting old events.""" - self._add_test_events() - events = recorder.query('Events').filter( - recorder.get_model('Events').event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 5) - - # run purge_old_data() - recorder._INSTANCE.purge_days = 4 - recorder._INSTANCE._purge_old_data() - - # now we should only have 3 events left - self.assertEqual(events.count(), 3) - - def test_purge_disabled(self): - """Test leaving purge_days disabled.""" - self._add_test_states() - self._add_test_events() - # make sure we start with 5 states and events - states = recorder.query('States') - events = recorder.query('Events').filter( - recorder.get_model('Events').event_type.like("EVENT_TEST%")) - self.assertEqual(states.count(), 5) - self.assertEqual(events.count(), 5) - - # run purge_old_data() - recorder._INSTANCE.purge_days = None - recorder._INSTANCE._purge_old_data() - - # we should have all of our states still - self.assertEqual(states.count(), 5) - self.assertEqual(events.count(), 5) - - def test_schema_no_recheck(self): - """Test that schema is not double-checked when up-to-date.""" - with patch.object(recorder._INSTANCE, '_apply_update') as update, \ - patch.object(recorder._INSTANCE, '_inspect_schema_version') \ - as inspect: - recorder._INSTANCE._migrate_schema() - self.assertEqual(update.call_count, 0) - self.assertEqual(inspect.call_count, 0) - - def test_invalid_update(self): - """Test that an invalid new version raises an exception.""" - with self.assertRaises(ValueError): - recorder._INSTANCE._apply_update(-1) - - -def create_engine_test(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - engine = create_engine(*args, **kwargs) - models_original.Base.metadata.create_all(engine) - return engine - - -class TestMigrateRecorder(BaseTestRecorder): - """Test recorder class that starts with an original schema db.""" - - @patch('sqlalchemy.create_engine', new=create_engine_test) - @patch('homeassistant.components.recorder.Recorder._migrate_schema') - def setUp(self, migrate): # pylint: disable=invalid-name,arguments-differ - """Setup things to be run when tests are started. - - create_engine is patched to create a db that starts with the old - schema. - - _migrate_schema is mocked to ensure it isn't run, so we can test it - below. - """ - super().setUp() - - def test_schema_update_calls(self): # pylint: disable=no-self-use - """Test that schema migrations occurr in correct order.""" - with patch.object(recorder._INSTANCE, '_apply_update') as update: - recorder._INSTANCE._migrate_schema() - update.assert_has_calls([call(version+1) for version in range( - 0, recorder.models.SCHEMA_VERSION)]) - - def test_schema_migrate(self): # pylint: disable=no-self-use - """Test the full schema migration logic. - - We're just testing that the logic can execute successfully here without - throwing exceptions. Maintaining a set of assertions based on schema - inspection could quickly become quite cumbersome. - """ - recorder._INSTANCE._migrate_schema() - @pytest.fixture def hass_recorder(): @@ -262,7 +92,7 @@ def hass_recorder(): init_recorder_component(hass, config) hass.start() hass.block_till_done() - recorder.get_instance().block_till_done() + hass.data[DATA_INSTANCE].block_till_done() return hass yield setup_recorder @@ -275,11 +105,10 @@ def _add_entities(hass, entity_ids): for idx, entity_id in enumerate(entity_ids): hass.states.set(entity_id, 'state{}'.format(idx), attributes) hass.block_till_done() - recorder._INSTANCE.block_till_done() - db_states = recorder.query('States') - states = recorder.execute(db_states) - assert db_states[0].event_id is not None - return states + hass.data[DATA_INSTANCE].block_till_done() + + with session_scope(hass=hass) as session: + return [st.to_native() for st in session.query(States)] # pylint: disable=redefined-outer-name,invalid-name @@ -334,61 +163,3 @@ def test_saving_state_include_domain_exclude_entity(hass_recorder): assert len(states) == 1 assert hass.states.get('test.ok') == states[0] assert hass.states.get('test.ok').state == 'state2' - - -def test_recorder_errors_exceptions(hass_recorder): \ - # pylint: disable=redefined-outer-name - """Test session_scope and get_model errors.""" - # Model cannot be resolved - assert recorder.get_model('dont-exist') is None - - # Verify the instance fails before setup - with pytest.raises(RuntimeError): - recorder.get_instance() - - # Setup the recorder - hass_recorder() - - recorder.get_instance() - - # Verify session scope raises (and prints) an exception - with patch('homeassistant.components.recorder._LOGGER.error') as e_mock, \ - pytest.raises(Exception) as err: - with recorder.session_scope() as session: - session.execute('select * from notthere') - assert e_mock.call_count == 1 - assert recorder.ERROR_QUERY[:-4] in e_mock.call_args[0][0] - assert 'no such table' in str(err.value) - - -def test_recorder_bad_commit(hass_recorder): - """Bad _commit should retry 3 times.""" - hass_recorder() - - def work(session): - """Bad work.""" - session.execute('select * from notthere') - - with patch('homeassistant.components.recorder.time.sleep') as e_mock, \ - recorder.session_scope() as session: - res = recorder._INSTANCE._commit(session, work) - assert res is False - assert e_mock.call_count == 3 - - -def test_recorder_bad_execute(hass_recorder): - """Bad execute, retry 3 times.""" - hass_recorder() - - def to_native(): - """Rasie exception.""" - from sqlalchemy.exc import SQLAlchemyError - raise SQLAlchemyError() - - mck1 = MagicMock() - mck1.to_native = to_native - - with patch('homeassistant.components.recorder.time.sleep') as e_mock: - res = recorder.execute((mck1,)) - assert res == [] - assert e_mock.call_count == 3 diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py new file mode 100644 index 00000000000..4990cbc00eb --- /dev/null +++ b/tests/components/recorder/test_migrate.py @@ -0,0 +1,67 @@ +"""The tests for the Recorder component.""" +# pylint: disable=protected-access +import asyncio +from unittest.mock import patch, call + +import pytest +from sqlalchemy import create_engine + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.recorder import wait_connection_ready, migration +from homeassistant.components.recorder.models import SCHEMA_VERSION +from homeassistant.components.recorder.const import DATA_INSTANCE +from tests.components.recorder import models_original + + +def create_engine_test(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + engine = create_engine(*args, **kwargs) + models_original.Base.metadata.create_all(engine) + return engine + + +@asyncio.coroutine +def test_schema_update_calls(hass): + """Test that schema migrations occurr in correct order.""" + with patch('sqlalchemy.create_engine', new=create_engine_test), \ + patch('homeassistant.components.recorder.migration._apply_update') as \ + update: + yield from async_setup_component(hass, 'recorder', { + 'recorder': { + 'db_url': 'sqlite://' + } + }) + yield from wait_connection_ready(hass) + + update.assert_has_calls([ + call(hass.data[DATA_INSTANCE].engine, version+1) for version + in range(0, SCHEMA_VERSION)]) + + +@asyncio.coroutine +def test_schema_migrate(hass): + """Test the full schema migration logic. + + We're just testing that the logic can execute successfully here without + throwing exceptions. Maintaining a set of assertions based on schema + inspection could quickly become quite cumbersome. + """ + with patch('sqlalchemy.create_engine', new=create_engine_test), \ + patch('homeassistant.components.recorder.Recorder._setup_run') as \ + setup_run: + yield from async_setup_component(hass, 'recorder', { + 'recorder': { + 'db_url': 'sqlite://' + } + }) + yield from wait_connection_ready(hass) + assert setup_run.called + + +def test_invalid_update(): + """Test that an invalid new version raises an exception.""" + with pytest.raises(ValueError): + migration._apply_update(None, -1) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py new file mode 100644 index 00000000000..1a52e0503bb --- /dev/null +++ b/tests/components/recorder/test_purge.py @@ -0,0 +1,109 @@ +"""Test data purging.""" +import json +from datetime import datetime, timedelta +import unittest + +from homeassistant.components import recorder +from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder.purge import purge_old_data +from homeassistant.components.recorder.models import States, Events +from homeassistant.components.recorder.util import session_scope +from tests.common import get_test_home_assistant, init_recorder_component + + +class TestRecorderPurge(unittest.TestCase): + """Base class for common recorder tests.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + init_recorder_component(self.hass) + self.hass.start() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def _add_test_states(self): + """Add multiple states to the db for testing.""" + now = datetime.now() + five_days_ago = now - timedelta(days=5) + attributes = {'test_attr': 5, 'test_attr_10': 'nice'} + + self.hass.block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() + + with recorder.session_scope(hass=self.hass) as session: + for event_id in range(5): + if event_id < 3: + timestamp = five_days_ago + state = 'purgeme' + else: + timestamp = now + state = 'dontpurgeme' + + session.add(States( + entity_id='test.recorder2', + domain='sensor', + state=state, + attributes=json.dumps(attributes), + last_changed=timestamp, + last_updated=timestamp, + created=timestamp, + event_id=event_id + 1000 + )) + + def _add_test_events(self): + """Add a few events for testing.""" + now = datetime.now() + five_days_ago = now - timedelta(days=5) + event_data = {'test_attr': 5, 'test_attr_10': 'nice'} + + self.hass.block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() + + with recorder.session_scope(hass=self.hass) as session: + for event_id in range(5): + if event_id < 2: + timestamp = five_days_ago + event_type = 'EVENT_TEST_PURGE' + else: + timestamp = now + event_type = 'EVENT_TEST' + + session.add(Events( + event_type=event_type, + event_data=json.dumps(event_data), + origin='LOCAL', + created=timestamp, + time_fired=timestamp, + )) + + def test_purge_old_states(self): + """Test deleting old states.""" + self._add_test_states() + # make sure we start with 5 states + with session_scope(hass=self.hass) as session: + states = session.query(States) + self.assertEqual(states.count(), 5) + + # run purge_old_data() + purge_old_data(self.hass.data[DATA_INSTANCE], 4) + + # we should only have 2 states left after purging + self.assertEqual(states.count(), 2) + + def test_purge_old_events(self): + """Test deleting old events.""" + self._add_test_events() + + with session_scope(hass=self.hass) as session: + events = session.query(Events).filter( + Events.event_type.like("EVENT_TEST%")) + self.assertEqual(events.count(), 5) + + # run purge_old_data() + purge_old_data(self.hass.data[DATA_INSTANCE], 4) + + # now we should only have 3 events left + self.assertEqual(events.count(), 3) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py new file mode 100644 index 00000000000..ad130b1ca91 --- /dev/null +++ b/tests/components/recorder/test_util.py @@ -0,0 +1,59 @@ +"""Test util methods.""" +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.components.recorder import util +from homeassistant.components.recorder.const import DATA_INSTANCE +from tests.common import get_test_home_assistant, init_recorder_component + + +@pytest.fixture +def hass_recorder(): + """HASS fixture with in-memory recorder.""" + hass = get_test_home_assistant() + + def setup_recorder(config=None): + """Setup with params.""" + init_recorder_component(hass, config) + hass.start() + hass.block_till_done() + hass.data[DATA_INSTANCE].block_till_done() + return hass + + yield setup_recorder + hass.stop() + + +def test_recorder_bad_commit(hass_recorder): + """Bad _commit should retry 3 times.""" + hass = hass_recorder() + + def work(session): + """Bad work.""" + session.execute('select * from notthere') + + with patch('homeassistant.components.recorder.time.sleep') as e_mock, \ + util.session_scope(hass=hass) as session: + res = util.commit(session, work) + assert res is False + assert e_mock.call_count == 3 + + +def test_recorder_bad_execute(hass_recorder): + """Bad execute, retry 3 times.""" + from sqlalchemy.exc import SQLAlchemyError + hass_recorder() + + def to_native(): + """Rasie exception.""" + raise SQLAlchemyError() + + mck1 = MagicMock() + mck1.to_native = to_native + + with pytest.raises(SQLAlchemyError), \ + patch('homeassistant.components.recorder.time.sleep') as e_mock: + util.execute((mck1,)) + + assert e_mock.call_count == 2 diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index d4f1cbcbe9a..52a229f43c8 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -5,7 +5,6 @@ import unittest from unittest.mock import patch from homeassistant.bootstrap import setup_component -import homeassistant.components.recorder as recorder from homeassistant.components.sensor.history_stats import HistoryStatsSensor import homeassistant.core as ha from homeassistant.helpers.template import Template @@ -207,6 +206,3 @@ class TestHistoryStatsSensor(unittest.TestCase): """Initialize the recorder.""" init_recorder_component(self.hass) self.hass.start() - recorder.get_instance().block_till_db_ready() - self.hass.block_till_done() - recorder.get_instance().block_till_done() diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 65870d1450f..7324a5e9b32 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -29,13 +29,12 @@ class TestComponentHistory(unittest.TestCase): """Initialize the recorder.""" init_recorder_component(self.hass) self.hass.start() - recorder.get_instance().block_till_db_ready() self.wait_recording_done() def wait_recording_done(self): """Block till recording is done.""" self.hass.block_till_done() - recorder.get_instance().block_till_done() + self.hass.data[recorder.DATA_INSTANCE].block_till_done() def test_setup(self): """Test setup method of history.""" @@ -87,12 +86,13 @@ class TestComponentHistory(unittest.TestCase): # Get states returns everything before POINT self.assertEqual(states, - sorted(history.get_states(future), + sorted(history.get_states(self.hass, future), key=lambda state: state.entity_id)) # Test get_state here because we have a DB setup self.assertEqual( - states[0], history.get_state(future, states[0].entity_id)) + states[0], history.get_state(self.hass, future, + states[0].entity_id)) def test_state_changes_during_period(self): """Test state change during period.""" @@ -128,7 +128,8 @@ class TestComponentHistory(unittest.TestCase): set_state('Netflix') set_state('Plex') - hist = history.state_changes_during_period(start, end, entity_id) + hist = history.state_changes_during_period( + self.hass, start, end, entity_id) self.assertEqual(states, hist[entity_id]) @@ -141,7 +142,7 @@ class TestComponentHistory(unittest.TestCase): """ zero, four, states = self.record_states() hist = history.get_significant_states( - zero, four, filters=history.Filters()) + self.hass, zero, four, filters=history.Filters()) assert states == hist def test_get_significant_states_entity_id(self): @@ -153,7 +154,7 @@ class TestComponentHistory(unittest.TestCase): del states['script.can_cancel_this_one'] hist = history.get_significant_states( - zero, four, 'media_player.test', + self.hass, zero, four, 'media_player.test', filters=history.Filters()) assert states == hist @@ -355,7 +356,8 @@ class TestComponentHistory(unittest.TestCase): filters.included_entities = include[history.CONF_ENTITIES] filters.included_domains = include[history.CONF_DOMAINS] - hist = history.get_significant_states(zero, four, filters=filters) + hist = history.get_significant_states( + self.hass, zero, four, filters=filters) assert states == hist def record_states(self): diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 69497ef8388..13735df0a11 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -138,7 +138,7 @@ class TestComponentLogbook(unittest.TestCase): eventA.data['old_state'] = None events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), self.EMPTY_CONFIG) + eventA, eventB), {}) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -160,7 +160,7 @@ class TestComponentLogbook(unittest.TestCase): eventA.data['new_state'] = None events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), self.EMPTY_CONFIG) + eventA, eventB), {}) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -182,7 +182,7 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), self.EMPTY_CONFIG) + eventA, eventB), {}) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -206,8 +206,9 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), config) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), + config[logbook.DOMAIN]) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -231,8 +232,9 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_DOMAINS: ['switch', ]}}}) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_START), - eventA, eventB), config) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + config[logbook.DOMAIN]) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -267,8 +269,9 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), config) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), + config[logbook.DOMAIN]) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -292,8 +295,9 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { logbook.CONF_ENTITIES: [entity_id2, ]}}}) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), config) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), + config[logbook.DOMAIN]) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -317,8 +321,9 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { logbook.CONF_DOMAINS: ['sensor', ]}}}) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_START), - eventA, eventB), config) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + config[logbook.DOMAIN]) entries = list(logbook.humanify(events)) self.assertEqual(2, len(entries)) @@ -350,9 +355,9 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_EXCLUDE: { logbook.CONF_DOMAINS: ['switch', ], logbook.CONF_ENTITIES: ['sensor.bli', ]}}}) - events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_START), - eventA1, eventA2, eventA3, - eventB1, eventB2), config) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_START), eventA1, eventA2, eventA3, + eventB1, eventB2), config[logbook.DOMAIN]) entries = list(logbook.humanify(events)) self.assertEqual(3, len(entries)) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 3a4c058f853..59598823911 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -10,6 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.components import input_boolean, recorder from homeassistant.helpers.restore_state import ( async_get_last_state, DATA_RESTORE_CACHE) +from homeassistant.components.recorder.models import RecorderRuns, States from tests.common import ( get_test_home_assistant, mock_coro, init_recorder_component) @@ -31,7 +32,7 @@ def test_caching_data(hass): return_value=MagicMock(end=dt_util.utcnow())), \ patch('homeassistant.helpers.restore_state.get_states', return_value=states), \ - patch('homeassistant.helpers.restore_state.async_get_instance', + patch('homeassistant.helpers.restore_state.wait_connection_ready', return_value=mock_coro()): state = yield from async_get_last_state(hass, 'input_boolean.b1') @@ -49,33 +50,29 @@ def test_caching_data(hass): assert DATA_RESTORE_CACHE not in hass.data -def _add_data_in_last_run(entities): +def _add_data_in_last_run(hass, entities): """Add test data in the last recorder_run.""" # pylint: disable=protected-access t_now = dt_util.utcnow() - timedelta(minutes=10) t_min_1 = t_now - timedelta(minutes=20) t_min_2 = t_now - timedelta(minutes=30) - recorder_runs = recorder.get_model('RecorderRuns') - states = recorder.get_model('States') - with recorder.session_scope() as session: - run = recorder_runs( + with recorder.session_scope(hass=hass) as session: + session.add(RecorderRuns( start=t_min_2, end=t_now, created=t_min_2 - ) - recorder._INSTANCE._commit(session, run) + )) for entity_id, state in entities.items(): - dbstate = states( + session.add(States( entity_id=entity_id, domain=split_entity_id(entity_id)[0], state=state, attributes='{}', last_changed=t_min_1, last_updated=t_min_1, - created=t_min_1) - recorder._INSTANCE._commit(session, dbstate) + created=t_min_1)) def test_filling_the_cache(): @@ -88,7 +85,7 @@ def test_filling_the_cache(): init_recorder_component(hass) - _add_data_in_last_run({ + _add_data_in_last_run(hass, { test_entity_id1: 'on', test_entity_id2: 'off', }) From 5932446508159aee9f0811de1f07b786a7076648 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 26 Feb 2017 23:43:02 +0100 Subject: [PATCH 044/198] Bugfix mqtt socket error (#6256) --- homeassistant/components/mqtt/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 78311623258..94fc7cc85f0 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -394,8 +394,7 @@ class MQTT(object): self.progress = {} self.birth_message = birth_message self._mqttc = None - self._subscribe_lock = asyncio.Lock(loop=hass.loop) - self._publish_lock = asyncio.Lock(loop=hass.loop) + self._paho_lock = asyncio.Lock(loop=hass.loop) if protocol == PROTOCOL_31: proto = mqtt.MQTTv31 @@ -435,7 +434,7 @@ class MQTT(object): This method must be run in the event loop and returns a coroutine. """ - with (yield from self._publish_lock): + with (yield from self._paho_lock): yield from self.hass.loop.run_in_executor( None, self._mqttc.publish, topic, payload, qos, retain) @@ -485,7 +484,7 @@ class MQTT(object): if topic in self.topics: return - with (yield from self._subscribe_lock): + with (yield from self._paho_lock): result, mid = yield from self.hass.loop.run_in_executor( None, self._mqttc.subscribe, topic, qos) From e2014eb153601c77d1205b7555f9c8db4251f058 Mon Sep 17 00:00:00 2001 From: Scott Henning Date: Sun, 26 Feb 2017 17:04:30 -0600 Subject: [PATCH 045/198] Notify ciscospark (#6130) * Adding ciscospark notifier * Adding ciscospark notifier * CI cleanup. * houndci-bot changes * ok --- a bunch of code verify changes --- homeassistant/components/notify/ciscospark.py | 67 +++++++++++++++++++ requirements_all.txt | 3 + 2 files changed, 70 insertions(+) create mode 100644 homeassistant/components/notify/ciscospark.py diff --git a/homeassistant/components/notify/ciscospark.py b/homeassistant/components/notify/ciscospark.py new file mode 100644 index 00000000000..3a4ef1384d9 --- /dev/null +++ b/homeassistant/components/notify/ciscospark.py @@ -0,0 +1,67 @@ +""" +Cisco Spark platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.ciscospark/ +""" +import logging +import voluptuous as vol +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE) +from homeassistant.const import (CONF_TOKEN) +import homeassistant.helpers.config_validation as cv + +CONF_ROOMID = "roomid" + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['ciscosparkapi==0.4.2'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Required(CONF_ROOMID): cv.string, +}) + + +# pylint: disable=unused-variable +def get_service(hass, config, discovery_info=None): + """Get the CiscoSpark notification service.""" + return CiscoSparkNotificationService( + config.get(CONF_TOKEN), + config.get(CONF_ROOMID)) + + +class CiscoSparkNotificationService(BaseNotificationService): + """CiscoSparkNotificationService.""" + + def __init__(self, token, default_room): + """ + Initialize the service. + + Args: + token: Cisco Spark Developer's Token + default_room: Cisco Spark Room ID + """ + from ciscosparkapi import CiscoSparkAPI + self._default_room = default_room + self._token = token + self._spark = CiscoSparkAPI(access_token=self._token) + + def send_message(self, message="", **kwargs): + """ + Send a message to a user. + + Args: + message: notificaiton text + kwargs: attributes used - 'title' + """ + from ciscosparkapi import SparkApiError + try: + title = "" + if kwargs.get(ATTR_TITLE) is not None: + title = kwargs.get(ATTR_TITLE) + ": " + self._spark.messages.create(roomId=self._default_room, + text=title + message) + except SparkApiError as api_error: + _LOGGER.error("Could not send CiscoSpark notification. Error: %s", + api_error) diff --git a/requirements_all.txt b/requirements_all.txt index 5b0c020b316..9f8ba5d2bcc 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -80,6 +80,9 @@ boto3==1.4.3 # homeassistant.components.switch.broadlink broadlink==0.3 +# homeassistant.components.notify.ciscospark +ciscosparkapi==0.4.2 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==2.0.1 From d789de9ea2b82bef418e1a14241c0532952734f6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2017 15:28:12 -0800 Subject: [PATCH 046/198] Config fix (#6261) --- homeassistant/components/config/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9fbb030e96e..631650077ce 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -129,5 +129,8 @@ def _read(path): def _write(path, data): """Write YAML helper.""" + # Do it before opening file. If dump causes error it will now not + # truncate the file. + data = dump(data) with open(path, 'w', encoding='utf-8') as outfile: - outfile.write(dump(data)) + outfile.write(data) From 31ddcc6278f1439f383a6672bd3a9078d4892904 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 27 Feb 2017 00:28:54 +0100 Subject: [PATCH 047/198] Bugfix mqtt paho client to speend time (#6266) --- homeassistant/components/mqtt/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 94fc7cc85f0..e8616e22761 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -437,6 +437,7 @@ class MQTT(object): with (yield from self._paho_lock): yield from self.hass.loop.run_in_executor( None, self._mqttc.publish, topic, payload, qos, retain) + yield from asyncio.sleep(0, loop=self.hass.loop) @asyncio.coroutine def async_connect(self): @@ -487,6 +488,7 @@ class MQTT(object): with (yield from self._paho_lock): result, mid = yield from self.hass.loop.run_in_executor( None, self._mqttc.subscribe, topic, qos) + yield from asyncio.sleep(0, loop=self.hass.loop) _raise_on_error(result) self.progress[mid] = topic From 53a735a329f4af6447298a8c9fdab544f7c3ca77 Mon Sep 17 00:00:00 2001 From: Jeff Wilson Date: Sun, 26 Feb 2017 23:59:23 -0500 Subject: [PATCH 048/198] Properly report features for each hue bulb type (#6271) --- homeassistant/components/light/hue.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 778652872c3..4cebf12109e 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -47,9 +47,19 @@ MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) PHUE_CONFIG_FILE = 'phue.conf' -SUPPORT_HUE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | - SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION | - SUPPORT_XY_COLOR) +SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION | SUPPORT_FLASH) +SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS) +SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP) +SUPPORT_HUE_COLOR = (SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | + SUPPORT_RGB_COLOR | SUPPORT_XY_COLOR) +SUPPORT_HUE_EXTENDED = (SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR) + +SUPPORT_HUE = { + 'Extended color light': SUPPORT_HUE_EXTENDED, + 'Color light': SUPPORT_HUE_COLOR, + 'Dimmable light': SUPPORT_HUE_DIMMABLE, + 'Color temperature light': SUPPORT_HUE_COLOR_TEMP + } CONF_ALLOW_IN_EMULATED_HUE = "allow_in_emulated_hue" DEFAULT_ALLOW_IN_EMULATED_HUE = True @@ -354,7 +364,7 @@ class HueLight(Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_HUE + return SUPPORT_HUE.get(self.info.get('type'), SUPPORT_HUE_EXTENDED) @property def effect_list(self): From 65d255a6266a2a60fa836d54cba49dfed32c326e Mon Sep 17 00:00:00 2001 From: Jose Juan Montes Date: Mon, 27 Feb 2017 06:16:11 +0100 Subject: [PATCH 049/198] Local file camera now supports yet inexisting files. (#6157) --- homeassistant/components/camera/local_file.py | 14 ++++++--- tests/components/camera/test_local_file.py | 31 ++++++++++++------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py index 65defb4557b..85438820393 100644 --- a/homeassistant/components/camera/local_file.py +++ b/homeassistant/components/camera/local_file.py @@ -20,7 +20,7 @@ CONF_FILE_PATH = 'file_path' DEFAULT_NAME = 'Local File' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_FILE_PATH): cv.isfile, + vol.Required(CONF_FILE_PATH): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string }) @@ -31,8 +31,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # check filepath given is readable if not os.access(file_path, os.R_OK): - _LOGGER.error("file path is not readable") - return False + _LOGGER.warning("Could not read camera %s image from file: %s", + config[CONF_NAME], file_path) add_devices([LocalFile(config[CONF_NAME], file_path)]) @@ -49,8 +49,12 @@ class LocalFile(Camera): def camera_image(self): """Return image response.""" - with open(self._file_path, 'rb') as file: - return file.read() + try: + with open(self._file_path, 'rb') as file: + return file.read() + except FileNotFoundError: + _LOGGER.warning("Could not read camera %s image from file: %s", + self._name, self._file_path) @property def name(self): diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index d43c138c570..55ddbd10741 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -8,7 +8,8 @@ from mock_open import MockOpen from homeassistant.bootstrap import setup_component -from tests.common import assert_setup_component, mock_http_component +from tests.common import mock_http_component +import logging @asyncio.coroutine @@ -42,19 +43,25 @@ def test_loading_file(hass, test_client): @asyncio.coroutine -def test_file_not_readable(hass): - """Test local file will not setup when file is not readable.""" +def test_file_not_readable(hass, caplog): + """Test a warning is shown setup when file is not readable.""" mock_http_component(hass) + @mock.patch('os.path.isfile', mock.Mock(return_value=True)) + @mock.patch('os.access', mock.Mock(return_value=False)) def run_test(): - with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \ - mock.patch('os.access', return_value=False), \ - assert_setup_component(0, 'camera'): - assert setup_component(hass, 'camera', { - 'camera': { - 'name': 'config_test', - 'platform': 'local_file', - 'file_path': 'mock.file', - }}) + + caplog.set_level( + logging.WARNING, logger='requests.packages.urllib3.connectionpool') + + assert setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'local_file', + 'file_path': 'mock.file', + }}) + assert 'Could not read' in caplog.text + assert 'config_test' in caplog.text + assert 'mock.file' in caplog.text yield from hass.loop.run_in_executor(None, run_test) From d5bdf7783e026cee5fdf54d59ce3d8637639e0bb Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 27 Feb 2017 06:21:12 +0100 Subject: [PATCH 050/198] light.transition now supports float instead of int in order to be able to perform faster transitions (#6163) --- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/light/hue.py | 7 +++++-- homeassistant/components/light/lifx.py | 4 ++-- homeassistant/components/light/limitlessled.py | 2 +- homeassistant/components/light/mqtt_json.py | 4 ++-- homeassistant/components/light/mqtt_template.py | 4 ++-- homeassistant/components/light/osramlightify.py | 4 ++-- homeassistant/components/light/yeelight.py | 6 +++--- 8 files changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 8b25e2a726b..502620eb362 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -86,7 +86,7 @@ PROP_TO_ATTR = { } # Service call validation schemas -VALID_TRANSITION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)) +VALID_TRANSITION = vol.All(vol.Coerce(float), vol.Clamp(min=0, max=900)) VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)) LIGHT_TURN_ON_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 4cebf12109e..645d4b81c8d 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -376,7 +376,7 @@ class HueLight(Light): command = {'on': True} if ATTR_TRANSITION in kwargs: - command['transitiontime'] = kwargs[ATTR_TRANSITION] * 10 + command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: command['xy'] = kwargs[ATTR_XY_COLOR] @@ -422,7 +422,10 @@ class HueLight(Light): if ATTR_TRANSITION in kwargs: # Transition time is in 1/10th seconds and cannot exceed # 900 seconds. - command['transitiontime'] = min(9000, kwargs[ATTR_TRANSITION] * 10) + command['transitiontime'] = min( + 9000, + int(kwargs[ATTR_TRANSITION] * 10) + ) flash = kwargs.get(ATTR_FLASH) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 0777396316a..69c948bb1e9 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -202,7 +202,7 @@ class LIFXLight(Light): def turn_on(self, **kwargs): """Turn the device on.""" if ATTR_TRANSITION in kwargs: - fade = kwargs[ATTR_TRANSITION] * 1000 + fade = int(kwargs[ATTR_TRANSITION] * 1000) else: fade = 0 @@ -238,7 +238,7 @@ class LIFXLight(Light): def turn_off(self, **kwargs): """Turn the device off.""" if ATTR_TRANSITION in kwargs: - fade = kwargs[ATTR_TRANSITION] * 1000 + fade = int(kwargs[ATTR_TRANSITION] * 1000) else: fade = 0 diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index a395af30cf0..86d72baeada 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -143,7 +143,7 @@ def state(new_state): pipeline.on() # Set transition time. if ATTR_TRANSITION in kwargs: - transition_time = kwargs[ATTR_TRANSITION] + transition_time = int(kwargs[ATTR_TRANSITION]) # Do group type-specific work. function(self, transition_time, pipeline, **kwargs) # Update state. diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index 49c69ef348b..abc05198443 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -215,7 +215,7 @@ class MqttJson(Light): message['flash'] = self._flash_times[CONF_FLASH_TIME_SHORT] if ATTR_TRANSITION in kwargs: - message['transition'] = kwargs[ATTR_TRANSITION] + message['transition'] = int(kwargs[ATTR_TRANSITION]) if ATTR_BRIGHTNESS in kwargs: message['brightness'] = int(kwargs[ATTR_BRIGHTNESS]) @@ -245,7 +245,7 @@ class MqttJson(Light): message = {'state': 'OFF'} if ATTR_TRANSITION in kwargs: - message['transition'] = kwargs[ATTR_TRANSITION] + message['transition'] = int(kwargs[ATTR_TRANSITION]) mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index d99db968315..931b5f68ab3 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -269,7 +269,7 @@ class MqttTemplate(Light): # transition if ATTR_TRANSITION in kwargs: - values['transition'] = kwargs[ATTR_TRANSITION] + values['transition'] = int(kwargs[ATTR_TRANSITION]) mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], @@ -293,7 +293,7 @@ class MqttTemplate(Light): # transition if ATTR_TRANSITION in kwargs: - values['transition'] = kwargs[ATTR_TRANSITION] + values['transition'] = int(kwargs[ATTR_TRANSITION]) mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index b4c593d8395..4a4182be894 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -145,7 +145,7 @@ class OsramLightifyLight(Light): self._state = self._light.on() if ATTR_TRANSITION in kwargs: - transition = kwargs[ATTR_TRANSITION] * 10 + transition = int(kwargs[ATTR_TRANSITION] * 10) _LOGGER.debug("turn_on requested transition time for light:" " %s is: %s ", self._name, transition) @@ -196,7 +196,7 @@ class OsramLightifyLight(Light): _LOGGER.debug("turn_off Attempting to turn off light: %s ", self._name) if ATTR_TRANSITION in kwargs: - transition = kwargs[ATTR_TRANSITION] * 10 + transition = int(kwargs[ATTR_TRANSITION] * 10) _LOGGER.debug("turn_off requested transition time for light:" " %s is: %s ", self._name, transition) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 5eae4c66bb6..7e0bd0e253e 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -259,7 +259,7 @@ class YeelightLight(Light): _LOGGER.error("Flash supported currently only in RGB mode.") return - transition = self.config[CONF_TRANSITION] + transition = int(self.config[CONF_TRANSITION]) if flash == FLASH_LONG: count = 1 duration = transition * 5 @@ -288,9 +288,9 @@ class YeelightLight(Light): rgb = kwargs.get(ATTR_RGB_COLOR) flash = kwargs.get(ATTR_FLASH) - duration = self.config[CONF_TRANSITION] # in ms + duration = int(self.config[CONF_TRANSITION]) # in ms if ATTR_TRANSITION in kwargs: # passed kwarg overrides config - duration = kwargs.get(ATTR_TRANSITION) * 1000 # kwarg in s + duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s self._bulb.turn_on(duration=duration) From 6ea74ce74002c1f711c43839d21fab3090086c55 Mon Sep 17 00:00:00 2001 From: groth-its Date: Mon, 27 Feb 2017 06:28:31 +0100 Subject: [PATCH 051/198] Fix for OSRAM lights connected to hue bridge (#6122) * Fix for OSRAM lights connected to hue bridge Do not send command "effect = none" to OSRAM lights Osram lights connected to a hue bridge do not seem to handle "effect = none" very well. Most of the times they jump to the selected color and then change to red within a second. Osram lights connected to a hue bridge do not handle xy values outside of their gamut. Since they just stay at their old color value, handling the UI is very unpredictable. Sending HSV values to the lights fixes this. * Add tests for new util methods --- homeassistant/components/light/hue.py | 28 +++++++++++++++++----- homeassistant/util/color.py | 13 ++++++++++ tests/util/test_color.py | 34 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 645d4b81c8d..09444ee5765 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -379,12 +379,27 @@ class HueLight(Light): command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) if ATTR_XY_COLOR in kwargs: - command['xy'] = kwargs[ATTR_XY_COLOR] + if self.info['manufacturername'] == "OSRAM": + hsv = color_util.color_xy_brightness_to_hsv( + *kwargs[ATTR_XY_COLOR], + ibrightness=self.info['bri']) + command['hue'] = hsv[0] + command['sat'] = hsv[1] + command['bri'] = hsv[2] + else: + command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - xyb = color_util.color_RGB_to_xy( - *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - command['xy'] = xyb[0], xyb[1] - command['bri'] = xyb[2] + if self.info['manufacturername'] == "OSRAM": + hsv = color_util.color_RGB_to_hsv( + *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + command['hue'] = hsv[0] + command['sat'] = hsv[1] + command['bri'] = hsv[2] + else: + xyb = color_util.color_RGB_to_xy( + *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + command['xy'] = xyb[0], xyb[1] + command['bri'] = xyb[2] if ATTR_BRIGHTNESS in kwargs: command['bri'] = kwargs[ATTR_BRIGHTNESS] @@ -411,7 +426,8 @@ class HueLight(Light): command['hue'] = random.randrange(0, 65535) command['sat'] = random.randrange(150, 254) elif self.bridge_type == 'hue': - command['effect'] = 'none' + if self.info['manufacturername'] != "OSRAM": + command['effect'] = 'none' self._command_func(self.light_id, command) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 9502849e1d9..5a7c3b12e04 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,6 +1,7 @@ """Color util methods.""" import logging import math +import colorsys from typing import Tuple @@ -259,6 +260,18 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, return (ir, ig, ib) +def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[int, int, int]: + """Convert an rgb color to its hsv representation.""" + fHSV = colorsys.rgb_to_hsv(iR/255.0, iG/255.0, iB/255.0) + return (int(fHSV[0]*65536), int(fHSV[1]*255), int(fHSV[2]*255)) + + +def color_xy_brightness_to_hsv(vX: float, vY: float, + ibrightness: int) -> Tuple[int, int, int]: + """Convert an xy brightness color to its hsv representation.""" + return color_RGB_to_hsv(*color_xy_brightness_to_RGB(vX, vY, ibrightness)) + + def _match_max_scale(input_colors: Tuple[int, ...], output_colors: Tuple[int, ...]) -> Tuple[int, ...]: """Match the maximum value of the output to the input.""" diff --git a/tests/util/test_color.py b/tests/util/test_color.py index e4048cd3cde..ada7ccc072e 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -39,6 +39,40 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((0, 83, 255), color_util.color_xy_brightness_to_RGB(0, 0, 255)) + def test_color_RGB_to_hsv(self): + """Test color_RGB_to_hsv.""" + self.assertEqual((0, 0, 0), + color_util.color_RGB_to_hsv(0, 0, 0)) + + self.assertEqual((0, 0, 255), + color_util.color_RGB_to_hsv(255, 255, 255)) + + self.assertEqual((43690, 255, 255), + color_util.color_RGB_to_hsv(0, 0, 255)) + + self.assertEqual((21845, 255, 255), + color_util.color_RGB_to_hsv(0, 255, 0)) + + self.assertEqual((0, 255, 255), + color_util.color_RGB_to_hsv(255, 0, 0)) + + def test_color_xy_brightness_to_hsv(self): + """Test color_RGB_to_xy.""" + self.assertEqual(color_util.color_RGB_to_hsv(0, 0, 0), + color_util.color_xy_brightness_to_hsv(1, 1, 0)) + + self.assertEqual(color_util.color_RGB_to_hsv(255, 235, 214), + color_util.color_xy_brightness_to_hsv(.35, .35, 255)) + + self.assertEqual(color_util.color_RGB_to_hsv(255, 0, 45), + color_util.color_xy_brightness_to_hsv(1, 0, 255)) + + self.assertEqual(color_util.color_RGB_to_hsv(0, 255, 0), + color_util.color_xy_brightness_to_hsv(0, 1, 255)) + + self.assertEqual(color_util.color_RGB_to_hsv(0, 83, 255), + color_util.color_xy_brightness_to_hsv(0, 0, 255)) + def test_rgb_hex_to_rgb_list(self): """Test rgb_hex_to_rgb_list.""" self.assertEqual([255, 255, 255], From d7af43b87da2aca3ee5717a6dd3a0fe7e56ac731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Bastian=20P=C3=B6ttner?= Date: Mon, 27 Feb 2017 06:35:33 +0100 Subject: [PATCH 052/198] Add support for MAX!Cube thermostats and window shutter sensors (#6105) --- .coveragerc | 3 + .../components/binary_sensor/maxcube.py | 76 ++++++ homeassistant/components/climate/maxcube.py | 216 ++++++++++++++++++ homeassistant/components/maxcube.py | 94 ++++++++ requirements_all.txt | 3 + 5 files changed, 392 insertions(+) create mode 100644 homeassistant/components/binary_sensor/maxcube.py create mode 100644 homeassistant/components/climate/maxcube.py create mode 100644 homeassistant/components/maxcube.py diff --git a/.coveragerc b/.coveragerc index 50bf08b0279..820c53d81ee 100644 --- a/.coveragerc +++ b/.coveragerc @@ -132,6 +132,9 @@ omit = homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py + homeassistant/components/maxcube.py + homeassistant/components/*/maxcube.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/nx584.py diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py new file mode 100644 index 00000000000..77448fd6adc --- /dev/null +++ b/homeassistant/components/binary_sensor/maxcube.py @@ -0,0 +1,76 @@ +""" +Support for MAX! Window Shutter via MAX! Cube. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/maxcube/ +""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.const import STATE_UNKNOWN + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Iterate through all MAX! Devices and add window shutters to HASS.""" + cube = hass.data[MAXCUBE_HANDLE].cube + + # List of devices + devices = [] + + for device in cube.devices: + # Create device name by concatenating room name + device name + name = "%s %s" % (cube.room_by_id(device.room_id).name, device.name) + + # Only add Window Shutters + if cube.is_windowshutter(device): + # add device to HASS + devices.append(MaxCubeShutter(hass, name, device.rf_address)) + + if len(devices) > 0: + add_devices(devices) + + +class MaxCubeShutter(BinarySensorDevice): + """MAX! Cube BinarySensor device.""" + + def __init__(self, hass, name, rf_address): + """Initialize MAX! Cube BinarySensorDevice.""" + self._name = name + self._sensor_type = 'opening' + self._rf_address = rf_address + self._cubehandle = hass.data[MAXCUBE_HANDLE] + self._state = STATE_UNKNOWN + + @property + def should_poll(self): + """Polling is required.""" + return True + + @property + def name(self): + """Return the name of the BinarySensorDevice.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type + + @property + def is_on(self): + """Return true if the binary sensor is on/open.""" + return self._state + + def update(self): + """Get latest data from MAX! Cube.""" + self._cubehandle.update() + + # Get the device we want to update + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Update our internal state + self._state = device.is_open diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py new file mode 100644 index 00000000000..a04a547f534 --- /dev/null +++ b/homeassistant/components/climate/maxcube.py @@ -0,0 +1,216 @@ +""" +Support for MAX! Thermostats via MAX! Cube. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/maxcube/ +""" + +import socket +import logging + +from homeassistant.components.climate import ClimateDevice, STATE_AUTO +from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.const import STATE_UNKNOWN + +_LOGGER = logging.getLogger(__name__) + +STATE_MANUAL = "manual" +STATE_BOOST = "boost" +STATE_VACATION = "vacation" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Iterate through all MAX! Devices and add thermostats to HASS.""" + cube = hass.data[MAXCUBE_HANDLE].cube + + # List of devices + devices = [] + + for device in cube.devices: + # Create device name by concatenating room name + device name + name = "%s %s" % (cube.room_by_id(device.room_id).name, device.name) + + # Only add thermostats and wallthermostats + if cube.is_thermostat(device) or cube.is_wallthermostat(device): + # Add device to HASS + devices.append(MaxCubeClimate(hass, name, device.rf_address)) + + # Add all devices at once + if len(devices) > 0: + add_devices(devices) + + +class MaxCubeClimate(ClimateDevice): + """MAX! Cube ClimateDevice.""" + + def __init__(self, hass, name, rf_address): + """Initialize MAX! Cube ClimateDevice.""" + self._name = name + self._unit_of_measurement = TEMP_CELSIUS + self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST, + STATE_VACATION] + self._rf_address = rf_address + self._cubehandle = hass.data[MAXCUBE_HANDLE] + + @property + def should_poll(self): + """Polling is required.""" + return True + + @property + def name(self): + """Return the name of the ClimateDevice.""" + return self._name + + @property + def min_temp(self): + """Return the minimum temperature.""" + # Get the device we want (does not do any IO, just reads from memory) + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Map and return minimum temperature + return self.map_temperature_max_hass(device.min_temperature) + + @property + def max_temp(self): + """Return the maximum temperature.""" + # Get the device we want (does not do any IO, just reads from memory) + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Map and return maximum temperature + return self.map_temperature_max_hass(device.max_temperature) + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + # Get the device we want (does not do any IO, just reads from memory) + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Map and return current temperature + return self.map_temperature_max_hass(device.actual_temperature) + + @property + def current_operation(self): + """Return current operation (auto, manual, boost, vacation).""" + # Get the device we want (does not do any IO, just reads from memory) + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Mode Mapping + return self.map_mode_max_hass(device.mode) + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + # Get the device we want (does not do any IO, just reads from memory) + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Map and return target temperature + return self.map_temperature_max_hass(device.target_temperature) + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + # Fail is target temperature has not been supplied as argument + if kwargs.get(ATTR_TEMPERATURE) is None: + return False + + # Determine the new target temperature + target_temperature = kwargs.get(ATTR_TEMPERATURE) + + # Write the target temperature to the MAX! Cube. + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + cube = self._cubehandle.cube + + with self._cubehandle.mutex: + try: + cube.set_target_temperature(device, target_temperature) + except (socket.timeout, socket.error): + _LOGGER.error("Setting target temperature failed") + return False + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + # Get the device we want to update + device = self._cubehandle.cube.device_by_rf(self._rf_address) + + # Mode Mapping + mode = self.map_mode_hass_max(operation_mode) + + # Write new mode to thermostat + if mode is None: + return False + + with self._cubehandle.mutex: + try: + self._cubehandle.cube.set_mode(device, mode) + except (socket.timeout, socket.error): + _LOGGER.error("Setting operation mode failed") + return False + + def update(self): + """Get latest data from MAX! Cube.""" + # Update the CubeHandle + self._cubehandle.update() + + @staticmethod + def map_temperature_max_hass(temperature): + """Map Temperature from MAX! to HASS.""" + if temperature is None: + return STATE_UNKNOWN + + return temperature + + @staticmethod + def map_mode_hass_max(operation_mode): + """Map HASS Operation Modes to MAX! Operation Modes.""" + from maxcube.device import \ + MAX_DEVICE_MODE_AUTOMATIC, \ + MAX_DEVICE_MODE_MANUAL, \ + MAX_DEVICE_MODE_VACATION, \ + MAX_DEVICE_MODE_BOOST + + if operation_mode == STATE_AUTO: + mode = MAX_DEVICE_MODE_AUTOMATIC + elif operation_mode == STATE_MANUAL: + mode = MAX_DEVICE_MODE_MANUAL + elif operation_mode == STATE_VACATION: + mode = MAX_DEVICE_MODE_VACATION + elif operation_mode == STATE_BOOST: + mode = MAX_DEVICE_MODE_BOOST + else: + mode = None + + return mode + + @staticmethod + def map_mode_max_hass(mode): + """Map MAX! Operation Modes to HASS Operation Modes.""" + from maxcube.device import \ + MAX_DEVICE_MODE_AUTOMATIC, \ + MAX_DEVICE_MODE_MANUAL, \ + MAX_DEVICE_MODE_VACATION, \ + MAX_DEVICE_MODE_BOOST + + if mode == MAX_DEVICE_MODE_AUTOMATIC: + operation_mode = STATE_AUTO + elif mode == MAX_DEVICE_MODE_MANUAL: + operation_mode = STATE_MANUAL + elif mode == MAX_DEVICE_MODE_VACATION: + operation_mode = STATE_VACATION + elif mode == MAX_DEVICE_MODE_BOOST: + operation_mode = STATE_BOOST + else: + operation_mode = None + + return operation_mode diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py new file mode 100644 index 00000000000..bc201825e83 --- /dev/null +++ b/homeassistant/components/maxcube.py @@ -0,0 +1,94 @@ +""" +Platform for the MAX! Cube LAN Gateway. + +For more details about this component, please refer to the documentation +https://home-assistant.io/components/maxcube/ +""" + +from socket import timeout +import logging +import time +from threading import Lock + +from homeassistant.components.discovery import load_platform +from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +REQUIREMENTS = ['maxcube-api==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'maxcube' +MAXCUBE_HANDLE = 'maxcube' + +DEFAULT_PORT = 62910 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Establish connection to MAX! Cube.""" + from maxcube.connection import MaxCubeConnection + from maxcube.cube import MaxCube + + # Read Config + host = config.get(DOMAIN).get(CONF_HOST) + port = config.get(DOMAIN).get(CONF_PORT) + + # Assign Cube Handle to global variable + try: + cube = MaxCube(MaxCubeConnection(host, port)) + except timeout: + _LOGGER.error("Connection to Max!Cube could not be established") + cube = None + return False + + hass.data[MAXCUBE_HANDLE] = MaxCubeHandle(cube) + + # Load Climate (for Thermostats) + load_platform(hass, 'climate', DOMAIN) + + # Load BinarySensor (for Window Shutter) + load_platform(hass, 'binary_sensor', DOMAIN) + + # Initialization successfull + return True + + +class MaxCubeHandle(object): + """Keep the cube instance in one place and centralize the update.""" + + def __init__(self, cube): + """Initialize the Cube Handle.""" + # Cube handle + self.cube = cube + + # Instantiate Mutex + self.mutex = Lock() + + # Update Timestamp + self._updatets = time.time() + + def update(self): + """Pull the latest data from the MAX! Cube.""" + # Acquire mutex to prevent simultaneous update from multiple threads + with self.mutex: + # Only update every 60s + if (time.time() - self._updatets) >= 60: + _LOGGER.debug("UPDATE: Updating") + + try: + self.cube.update() + except timeout: + _LOGGER.error("Max!Cube connection failed") + return False + + self._updatets = time.time() + else: + _LOGGER.debug("UPDATE: Skipping") diff --git a/requirements_all.txt b/requirements_all.txt index 9f8ba5d2bcc..2fe7c9046fa 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -336,6 +336,9 @@ liveboxplaytv==1.4.9 # homeassistant.components.notify.matrix matrix-client==0.0.5 +# homeassistant.components.maxcube +maxcube-api==0.1.0 + # homeassistant.components.notify.message_bird messagebird==1.2.0 From 7dc05785ccca02e2cf8428c871c316ddc142293e Mon Sep 17 00:00:00 2001 From: TimV Date: Mon, 27 Feb 2017 00:38:47 -0500 Subject: [PATCH 053/198] Analog modem callerid support (#5840) * analog-modem-callerid * analog-modem-callerid * analog-mod * Updates from latest review * Updates from latest review --- .coveragerc | 1 + .../components/sensor/modem_callerid.py | 122 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 126 insertions(+) create mode 100644 homeassistant/components/sensor/modem_callerid.py diff --git a/.coveragerc b/.coveragerc index 820c53d81ee..3f5ba7a35de 100644 --- a/.coveragerc +++ b/.coveragerc @@ -341,6 +341,7 @@ omit = homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/mhz19.py homeassistant/components/sensor/miflora.py + homeassistant/components/sensor/modem_callerid.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/netdata.py homeassistant/components/sensor/neurio_energy.py diff --git a/homeassistant/components/sensor/modem_callerid.py b/homeassistant/components/sensor/modem_callerid.py new file mode 100644 index 00000000000..bb9a984c87b --- /dev/null +++ b/homeassistant/components/sensor/modem_callerid.py @@ -0,0 +1,122 @@ +""" +A sensor to monitor incoming calls using a USB modem that supports caller ID. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.modem_callerid/ +""" +import logging +import voluptuous as vol +from homeassistant.const import (STATE_IDLE, + EVENT_HOMEASSISTANT_STOP, + CONF_NAME, + CONF_DEVICE) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['https://github.com/vroomfonde1/basicmodem' + '/archive/0.7.zip' + '#basicmodem==0.7'] + +_LOGGER = logging.getLogger(__name__) +DEFAULT_NAME = 'Modem CallerID' +ICON = 'mdi:phone-clasic' +DEFAULT_DEVICE = '/dev/ttyACM0' + +STATE_RING = 'ring' +STATE_CALLERID = 'callerid' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup modem caller id sensor platform.""" + from basicmodem.basicmodem import BasicModem as bm + name = config.get(CONF_NAME) + port = config.get(CONF_DEVICE) + + modem = bm(port) + if modem.state == modem.STATE_FAILED: + _LOGGER.error('Unable to initialize modem.') + return + + add_devices([ModemCalleridSensor(hass, name, port, modem)]) + + +class ModemCalleridSensor(Entity): + """Implementation of USB modem callerid sensor.""" + + def __init__(self, hass, name, port, modem): + """Initialize the sensor.""" + self._attributes = {"cid_time": 0, "cid_number": '', "cid_name": ''} + self._name = name + self.port = port + self.modem = modem + self._state = STATE_IDLE + modem.registercallback(self._incomingcallcallback) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self._stop_modem) + + def set_state(self, state): + """Set the state.""" + self._state = state + + def set_attributes(self, attributes): + """Set the state attributes.""" + self._attributes = attributes + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def _stop_modem(self, event): + """HA is shutting down, close modem port.""" + if self.modem: + self.modem.close() + self.modem = None + return + + def _incomingcallcallback(self, newstate): + """Callback from modem, process based on new state.""" + if newstate == self.modem.STATE_RING: + if self.state == self.modem.STATE_IDLE: + att = {"cid_time": self.modem.get_cidtime, + "cid_number": '', + "cid_name": ''} + self.set_attributes(att) + self._state = STATE_RING + self.schedule_update_ha_state() + elif newstate == self.modem.STATE_CALLERID: + att = {"cid_time": self.modem.get_cidtime, + "cid_number": self.modem.get_cidnumber, + "cid_name": self.modem.get_cidname} + self.set_attributes(att) + self._state = STATE_CALLERID + self.schedule_update_ha_state() + elif newstate == self.modem.STATE_IDLE: + self._state = STATE_IDLE + self.schedule_update_ha_state() + return diff --git a/requirements_all.txt b/requirements_all.txt index 2fe7c9046fa..c13b7952944 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -287,6 +287,9 @@ https://github.com/thecynic/pylutron/archive/v0.1.0.zip#pylutron==0.1.0 # homeassistant.components.mysensors https://github.com/theolind/pymysensors/archive/0b705119389be58332f17753c53167f551254b6c.zip#pymysensors==0.8 +# homeassistant.components.sensor.modem_callerid +https://github.com/vroomfonde1/basicmodem/archive/0.7.zip#basicmodem==0.7 + # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 From 757813411d4f20130cf5f5030132a7823e851d8d Mon Sep 17 00:00:00 2001 From: pavoni Date: Mon, 27 Feb 2017 10:13:48 +0000 Subject: [PATCH 054/198] Fix vera thermostat mode set bug --- homeassistant/components/vera.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index ff75f6e7314..3eeb6a1c8c6 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.23'] +REQUIREMENTS = ['pyvera==0.2.24'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c13b7952944..b0582587a76 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -616,7 +616,7 @@ pyunifi==1.3 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.23 +pyvera==0.2.24 # homeassistant.components.notify.html5 pywebpush==0.6.1 From e6c88c05adfb60ed810053f950a0f62260e3c17b Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Mon, 27 Feb 2017 11:45:32 +0100 Subject: [PATCH 055/198] [sensor.dnsip] New Sensor: DNS IP (#6214) * Added DNS IP sensor * Removed unused import * Added coverage * fixed flake * Applied suggested changes * Removed debug code * Switched to aiodns * Raised scan interval * Updating state with entity creation * Lint * Updated requirements_all --- .coveragerc | 1 + homeassistant/components/sensor/dnsip.py | 86 ++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 90 insertions(+) create mode 100644 homeassistant/components/sensor/dnsip.py diff --git a/.coveragerc b/.coveragerc index 3f5ba7a35de..ae71a10f73d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -312,6 +312,7 @@ omit = homeassistant/components/sensor/darksky.py homeassistant/components/sensor/deutsche_bahn.py homeassistant/components/sensor/dht.py + homeassistant/components/sensor/dnsip.py homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/ebox.py diff --git a/homeassistant/components/sensor/dnsip.py b/homeassistant/components/sensor/dnsip.py new file mode 100644 index 00000000000..2807dbc2c58 --- /dev/null +++ b/homeassistant/components/sensor/dnsip.py @@ -0,0 +1,86 @@ +""" +Get your own public IP address or that of any host. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dnsip/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['aiodns==1.1.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_HOSTNAME = 'hostname' +CONF_RESOLVER = 'resolver' +CONF_RESOLVER_IPV6 = 'resolver_ipv6' +CONF_IPV6 = 'ipv6' + +DEFAULT_HOSTNAME = 'myip.opendns.com' +DEFAULT_RESOLVER = '208.67.222.222' +DEFAULT_RESOLVER_IPV6 = '2620:0:ccc::2' +DEFAULT_IPV6 = False + +SCAN_INTERVAL = timedelta(seconds=120) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string, + vol.Optional(CONF_RESOLVER, default=DEFAULT_RESOLVER): cv.string, + vol.Optional(CONF_RESOLVER_IPV6, default=DEFAULT_RESOLVER_IPV6): cv.string, + vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup the DNS IP sensor.""" + hostname = config.get(CONF_HOSTNAME) + ipv6 = config.get(CONF_IPV6) + if ipv6: + resolver = config.get(CONF_RESOLVER_IPV6) + else: + resolver = config.get(CONF_RESOLVER) + + yield from async_add_devices([WanIpSensor( + hass, hostname, resolver, ipv6)], True) + + +class WanIpSensor(Entity): + """Implementation of a DNS IP sensor.""" + + def __init__(self, hass, hostname, resolver, ipv6): + """Initialize the sensor.""" + import aiodns + self.hass = hass + self._name = hostname + self.resolver = aiodns.DNSResolver(loop=self.hass.loop) + self.resolver.nameservers = [resolver] + self.querytype = 'AAAA' if ipv6 else 'A' + self._state = STATE_UNKNOWN + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the current DNS IP address for hostname.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Get the current DNS IP address for hostname.""" + response = yield from self.resolver.query(self._name, self.querytype) + if response: + self._state = response[0].host + else: + self._state = STATE_UNKNOWN diff --git a/requirements_all.txt b/requirements_all.txt index c13b7952944..37022e6efd0 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,6 +33,9 @@ SoCo==0.12 # homeassistant.components.notify.twitter TwitterAPI==2.4.4 +# homeassistant.components.sensor.dnsip +aiodns==1.1.1 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.5.0 From 7f99e99dad451cbba0ec653b361355328535e734 Mon Sep 17 00:00:00 2001 From: Lindsay Ward Date: Tue, 28 Feb 2017 04:47:51 +1000 Subject: [PATCH 056/198] Update library version for Yeelight Sunflower lights platform (fix for packaging problem with 0.0.7) (#6233) --- homeassistant/components/light/yeelightsunflower.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/yeelightsunflower.py b/homeassistant/components/light/yeelightsunflower.py index 6d132f8a1fc..d1b2bcdab8e 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -15,7 +15,7 @@ from homeassistant.components.light import (Light, from homeassistant.const import CONF_HOST import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yeelightsunflower==0.0.6'] +REQUIREMENTS = ['yeelightsunflower==0.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3cf37eef644..32f5cb57a88 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -772,7 +772,7 @@ yahooweather==0.8 yeelight==0.2.2 # homeassistant.components.light.yeelightsunflower -yeelightsunflower==0.0.6 +yeelightsunflower==0.0.8 # homeassistant.components.light.zengge zengge==0.2 From d7db3aba36d06e7d5d7b5958fcc0479ef38225ec Mon Sep 17 00:00:00 2001 From: arjenfvellinga Date: Mon, 27 Feb 2017 19:57:39 +0100 Subject: [PATCH 057/198] Prevent duplicate names on Vera devices by appending the device id (#6100) * Prevent duplicate names by prepending device id to it. * Always append device id, not conditionally. * Moved naming of devices * flake8 --- homeassistant/components/vera.py | 38 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 3eeb6a1c8c6..7c2c7b744f9 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -57,35 +57,39 @@ def setup(hass, base_config): global VERA_CONTROLLER import pyvera as veraApi - config = base_config.get(DOMAIN) - base_url = config.get(CONF_CONTROLLER) - VERA_CONTROLLER, _ = veraApi.init_controller(base_url) - def stop_subscription(event): """Shutdown Vera subscriptions and subscription thread on exit.""" _LOGGER.info("Shutting down subscriptions.") VERA_CONTROLLER.stop() + config = base_config.get(DOMAIN) + + # Get Vera specific configuration. + base_url = config.get(CONF_CONTROLLER) + light_ids = config.get(CONF_LIGHTS) + exclude_ids = config.get(CONF_EXCLUDE) + + # Initialize the Vera controller. + VERA_CONTROLLER, _ = veraApi.init_controller(base_url) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) try: all_devices = VERA_CONTROLLER.get_devices() except RequestException: - # There was a network related error connecting to the vera controller. + # There was a network related error connecting to the Vera controller. _LOGGER.exception("Error communicating with Vera API") return False - exclude = config.get(CONF_EXCLUDE) + # Exclude devices unwanted by user. + devices = [device for device in all_devices + if device.device_id not in exclude_ids] - lights_ids = config.get(CONF_LIGHTS) + for device in devices: + device_type = map_vera_device(device, light_ids) + if device_type is None: + continue - for device in all_devices: - if device.device_id in exclude: - continue - dev_type = map_vera_device(device, lights_ids) - if dev_type is None: - continue - VERA_DEVICES[dev_type].append(device) + VERA_DEVICES[device_type].append(device) for component in VERA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, base_config) @@ -120,13 +124,15 @@ def map_vera_device(vera_device, remap): class VeraDevice(Entity): - """Representation of a Vera devicetity.""" + """Representation of a Vera device entity.""" def __init__(self, vera_device, controller): """Initialize the device.""" self.vera_device = vera_device self.controller = controller - self._name = self.vera_device.name + + # Append device id to prevent name clashes in HA. + self._name = self.vera_device.name + ' ' + str(vera_device.device_id) self.controller.register(vera_device, self._update_callback) self.update() From 7ee75d67c50f02c8947e673bc0fa1d41880d3522 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 27 Feb 2017 21:19:11 +0200 Subject: [PATCH 058/198] Add temperature support for MH-Z19 CO2 sensor. (#6169) * Add temperature support for MH-Z19 CO2 sensor. * Remove debug printout * More tests * Minor fixes --- .coveragerc | 1 - homeassistant/components/sensor/mhz19.py | 99 ++++++++++++--- homeassistant/components/sensor/serial_pm.py | 2 +- requirements_all.txt | 2 +- tests/components/sensor/test_mhz19.py | 122 +++++++++++++++++++ 5 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 tests/components/sensor/test_mhz19.py diff --git a/.coveragerc b/.coveragerc index ae71a10f73d..4db8323c3a1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -340,7 +340,6 @@ omit = homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py - homeassistant/components/sensor/mhz19.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py homeassistant/components/sensor/mqtt_room.py diff --git a/homeassistant/components/sensor/mhz19.py b/homeassistant/components/sensor/mhz19.py index 2ca15898b18..816b7465f8f 100644 --- a/homeassistant/components/sensor/mhz19.py +++ b/homeassistant/components/sensor/mhz19.py @@ -5,25 +5,40 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.mhz19/ """ import logging +from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_NAME +from homeassistant.const import ( + ATTR_TEMPERATURE, CONF_NAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.util.temperature import celsius_to_fahrenheit +from homeassistant.util import Throttle -REQUIREMENTS = ['pmsensor==0.3'] +REQUIREMENTS = ['pmsensor==0.4'] _LOGGER = logging.getLogger(__name__) CONF_SERIAL_DEVICE = 'serial_device' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) DEFAULT_NAME = 'CO2 Sensor' +ATTR_CO2_CONCENTRATION = 'co2_concentration' + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_CO2 = 'co2' +SENSOR_TYPES = { + SENSOR_TEMPERATURE: ['Temperature', None], + SENSOR_CO2: ['CO2', 'ppm'] +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SERIAL_DEVICE): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_CO2]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) @@ -37,50 +52,96 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not open serial connection to %s (%s)", config.get(CONF_SERIAL_DEVICE), err) return False + SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit - dev = MHZ19Sensor(config.get(CONF_SERIAL_DEVICE), config.get(CONF_NAME)) - add_devices([dev]) + data = MHZClient(co2sensor, config.get(CONF_SERIAL_DEVICE)) + dev = [] + name = config.get(CONF_NAME) + + for variable in config[CONF_MONITORED_CONDITIONS]: + dev.append( + MHZ19Sensor(data, variable, SENSOR_TYPES[variable][1], name)) + + add_devices(dev, True) + return True class MHZ19Sensor(Entity): """Representation of an CO2 sensor.""" - def __init__(self, serial_device, name): + def __init__(self, mhz_client, sensor_type, temp_unit, name): """Initialize a new PM sensor.""" + self._mhz_client = mhz_client + self._sensor_type = sensor_type + self._temp_unit = temp_unit self._name = name - self._state = None - self._serial = serial_device + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._ppm = None + self._temperature = None @property def name(self): """Return the name of the sensor.""" - return self._name + return '{}: {}'.format(self._name, SENSOR_TYPES[self._sensor_type][0]) @property def state(self): """Return the state of the sensor.""" - return self._state + return self._ppm if self._sensor_type == SENSOR_CO2 \ + else self._temperature @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return "ppm" + return self._unit_of_measurement def update(self): """Read from sensor and update the state.""" - from pmsensor import co2sensor + self._mhz_client.update() + data = self._mhz_client.data + self._temperature = data.get(SENSOR_TEMPERATURE) + if self._temperature is not None and \ + self._temp_unit == TEMP_FAHRENHEIT: + self._temperature = round( + celsius_to_fahrenheit(self._temperature), 1) + self._ppm = data.get(SENSOR_CO2) - _LOGGER.debug("Reading data from CO2 sensor") + @property + def device_state_attributes(self): + """Return the state attributes.""" + result = {} + if self._sensor_type == SENSOR_TEMPERATURE and self._ppm is not None: + result[ATTR_CO2_CONCENTRATION] = self._ppm + if self._sensor_type == SENSOR_CO2 and self._temperature is not None: + result[ATTR_TEMPERATURE] = self._temperature + return result + + +class MHZClient(object): + """Get the latest data from the DHT sensor.""" + + def __init__(self, co2sensor, serial): + """Initialize the sensor.""" + self.co2sensor = co2sensor + self._serial = serial + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data the MH-Z19 sensor.""" + self.data = {} try: - ppm = co2sensor.read_mh_z19(self._serial) - # values from sensor can only between 0 and 5000 - if (ppm >= 0) & (ppm <= 5000): - self._state = ppm + result = self.co2sensor.read_mh_z19_with_temperature(self._serial) + if result is None: + return + co2, temperature = result + except OSError as err: _LOGGER.error("Could not open serial connection to %s (%s)", self._serial, err) return - def should_poll(self): - """Sensor needs polling.""" - return True + if temperature is not None: + self.data[SENSOR_TEMPERATURE] = temperature + if co2 is not None and 0 < co2 <= 5000: + self.data[SENSOR_CO2] = co2 diff --git a/homeassistant/components/sensor/serial_pm.py b/homeassistant/components/sensor/serial_pm.py index 9704991e959..a031f9cbd56 100644 --- a/homeassistant/components/sensor/serial_pm.py +++ b/homeassistant/components/sensor/serial_pm.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -REQUIREMENTS = ['pmsensor==0.3'] +REQUIREMENTS = ['pmsensor==0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 32f5cb57a88..26169f838eb 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -412,7 +412,7 @@ plexapi==2.0.2 # homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm -pmsensor==0.3 +pmsensor==0.4 # homeassistant.components.climate.proliphix proliphix==0.4.1 diff --git a/tests/components/sensor/test_mhz19.py b/tests/components/sensor/test_mhz19.py new file mode 100644 index 00000000000..4311493ac97 --- /dev/null +++ b/tests/components/sensor/test_mhz19.py @@ -0,0 +1,122 @@ +"""Tests for MH-Z19 sensor.""" +import unittest +from unittest.mock import patch, DEFAULT, Mock + +from homeassistant.bootstrap import setup_component +from homeassistant.components.sensor import DOMAIN +import homeassistant.components.sensor.mhz19 as mhz19 +from homeassistant.const import TEMP_FAHRENHEIT +from tests.common import get_test_home_assistant, assert_setup_component + + +class TestMHZ19Sensor(unittest.TestCase): + """Test the MH-Z19 sensor.""" + + hass = None + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_missing_config(self): + """Test setup with configuration missing required entries.""" + with assert_setup_component(0): + assert setup_component(self.hass, DOMAIN, { + 'sensor': {'platform': 'mhz19'}}) + + @patch('pmsensor.co2sensor.read_mh_z19', side_effect=OSError('test error')) + def test_setup_failed_connect(self, mock_co2): + """Test setup when connection error occurs.""" + self.assertFalse(mhz19.setup_platform(self.hass, { + 'platform': 'mhz19', + mhz19.CONF_SERIAL_DEVICE: 'test.serial', + }, None)) + + def test_setup_connected(self): + """Test setup when connection succeeds.""" + with patch.multiple('pmsensor.co2sensor', read_mh_z19=DEFAULT, + read_mh_z19_with_temperature=DEFAULT): + from pmsensor.co2sensor import read_mh_z19_with_temperature + read_mh_z19_with_temperature.return_value = None + mock_add = Mock() + self.assertTrue(mhz19.setup_platform(self.hass, { + 'platform': 'mhz19', + 'monitored_conditions': ['co2', 'temperature'], + mhz19.CONF_SERIAL_DEVICE: 'test.serial', + }, mock_add)) + self.assertEqual(1, mock_add.call_count) + + @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', + side_effect=OSError('test error')) + def test_client_update_oserror(self, mock_function): + """Test MHZClient when library throws OSError.""" + from pmsensor import co2sensor + client = mhz19.MHZClient(co2sensor, 'test.serial') + client.update() + self.assertEqual({}, client.data) + + @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', + return_value=(5001, 24)) + def test_client_update_ppm_overflow(self, mock_function): + """Test MHZClient when ppm is too high.""" + from pmsensor import co2sensor + client = mhz19.MHZClient(co2sensor, 'test.serial') + client.update() + self.assertIsNone(client.data.get('co2')) + + @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', + return_value=(1000, 24)) + def test_client_update_good_read(self, mock_function): + """Test MHZClient when ppm is too high.""" + from pmsensor import co2sensor + client = mhz19.MHZClient(co2sensor, 'test.serial') + client.update() + self.assertEqual({'temperature': 24, 'co2': 1000}, client.data) + + @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', + return_value=(1000, 24)) + def test_co2_sensor(self, mock_function): + """Test CO2 sensor.""" + from pmsensor import co2sensor + client = mhz19.MHZClient(co2sensor, 'test.serial') + sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, None, 'name') + sensor.update() + + self.assertEqual('name: CO2', sensor.name) + self.assertEqual(1000, sensor.state) + self.assertEqual('ppm', sensor.unit_of_measurement) + self.assertTrue(sensor.should_poll) + self.assertEqual({'temperature': 24}, sensor.device_state_attributes) + + @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', + return_value=(1000, 24)) + def test_temperature_sensor(self, mock_function): + """Test temperature sensor.""" + from pmsensor import co2sensor + client = mhz19.MHZClient(co2sensor, 'test.serial') + sensor = mhz19.MHZ19Sensor( + client, mhz19.SENSOR_TEMPERATURE, None, 'name') + sensor.update() + + self.assertEqual('name: Temperature', sensor.name) + self.assertEqual(24, sensor.state) + self.assertEqual('°C', sensor.unit_of_measurement) + self.assertTrue(sensor.should_poll) + self.assertEqual( + {'co2_concentration': 1000}, sensor.device_state_attributes) + + @patch('pmsensor.co2sensor.read_mh_z19_with_temperature', + return_value=(1000, 24)) + def test_temperature_sensor_f(self, mock_function): + """Test temperature sensor.""" + from pmsensor import co2sensor + client = mhz19.MHZClient(co2sensor, 'test.serial') + sensor = mhz19.MHZ19Sensor( + client, mhz19.SENSOR_TEMPERATURE, TEMP_FAHRENHEIT, 'name') + sensor.update() + + self.assertEqual(75.2, sensor.state) From d7bf3920a584467f651a9d053c70bba11fd7cfe0 Mon Sep 17 00:00:00 2001 From: Boris K Date: Tue, 28 Feb 2017 04:52:10 +0100 Subject: [PATCH 059/198] improve history_stats accuracy (#6294) --- .../components/sensor/history_stats.py | 6 ++++++ tests/components/sensor/test_history_stats.py | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index eb54869d66f..e436939d036 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -187,6 +187,12 @@ class HistoryStatsSensor(Entity): last_state = current_state last_time = current_time + # Count time elapsed between last history state and end of measure + if last_state: + measure_end = min(dt_util.as_timestamp(end), dt_util.as_timestamp( + datetime.datetime.now())) + elapsed += measure_end - last_time + # Save value in hours self.value = elapsed / 3600 diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index 52a229f43c8..29d353e09ba 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -71,13 +71,19 @@ class TestHistoryStatsSensor(unittest.TestCase): def test_measure(self): """Test the history statistics sensor measure.""" - later = dt_util.utcnow() - timedelta(seconds=15) - earlier = later - timedelta(minutes=30) + t0 = dt_util.utcnow() - timedelta(minutes=40) + t1 = t0 + timedelta(minutes=20) + t2 = dt_util.utcnow() - timedelta(minutes=10) + + # Start t0 t1 t2 End + # |--20min--|--20min--|--10min--|--10min--| + # |---off---|---on----|---off---|---on----| fake_states = { 'binary_sensor.test_id': [ - ha.State('binary_sensor.test_id', 'on', last_changed=earlier), - ha.State('binary_sensor.test_id', 'off', last_changed=later), + ha.State('binary_sensor.test_id', 'on', last_changed=t0), + ha.State('binary_sensor.test_id', 'off', last_changed=t1), + ha.State('binary_sensor.test_id', 'on', last_changed=t2), ] } @@ -97,8 +103,8 @@ class TestHistoryStatsSensor(unittest.TestCase): sensor1.update() sensor2.update() - self.assertEqual(sensor1.value, 0.5) - self.assertEqual(sensor2.value, 0) + self.assertEqual(round(sensor1.value, 3), 0.5) + self.assertEqual(round(sensor2.value, 3), 0) self.assertEqual(sensor1.device_state_attributes['ratio'], '50.0%') def test_wrong_date(self): From f7c7073cd782d9556eeb2ec4a9141e5ac703621f Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Mon, 27 Feb 2017 20:52:32 -0700 Subject: [PATCH 060/198] Updated pyitachip2ir (#6296) * Updated pyitachip2ir * Updated requirements_all.txt --- homeassistant/components/remote/itach.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote/itach.py b/homeassistant/components/remote/itach.py index d76c39bf36a..fa424576a11 100644 --- a/homeassistant/components/remote/itach.py +++ b/homeassistant/components/remote/itach.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.components.remote import ( PLATFORM_SCHEMA, ATTR_COMMAND) -REQUIREMENTS = ['pyitachip2ir==0.0.5'] +REQUIREMENTS = ['pyitachip2ir==0.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 26169f838eb..6c883ff4f2d 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ pyicloud==0.9.1 pyiss==1.0.1 # homeassistant.components.remote.itach -pyitachip2ir==0.0.5 +pyitachip2ir==0.0.6 # homeassistant.components.sensor.lastfm pylast==1.8.0 From 0fa259089db0d689ad6ebd077418a00f36e1b0ba Mon Sep 17 00:00:00 2001 From: Open Home Automation Date: Tue, 28 Feb 2017 04:54:43 +0100 Subject: [PATCH 061/198] Influx fix (#6289) * Fix: replace influxdb query by another query that is more lightweight and won't timeout * Fix: replace influxdb query by another query that is more lightweight and won't timeout --- homeassistant/components/influxdb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 5221679b6b5..7f233d09acc 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -85,7 +85,7 @@ def setup(hass, config): try: influx = InfluxDBClient(**kwargs) - influx.query("SELECT * FROM /.*/ LIMIT 1;") + influx.query("SHOW DIAGNOSTICS;") except exceptions.InfluxDBClientError as exc: _LOGGER.error("Database host is not accessible due to '%s', please " "check your entries in the configuration file and that " From faf8bbcf13c42de7972ff81f7ef17fc5d3c9c137 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Mon, 27 Feb 2017 22:55:34 -0500 Subject: [PATCH 062/198] Fix toggle and media_play_pause post async (#6291) --- .../components/media_player/__init__.py | 22 ++-- .../media_player/test_async_helpers.py | 104 ++++++++++++++++++ 2 files changed, 112 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f4b828a0289..a603cb9c3e3 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -757,18 +757,15 @@ class MediaPlayerDevice(Entity): """Boolean if clear playlist command supported.""" return bool(self.supported_features & SUPPORT_CLEAR_PLAYLIST) - def toggle(self): - """Toggle the power on the media player.""" - if self.state in [STATE_OFF, STATE_IDLE]: - self.turn_on() - else: - self.turn_off() - def async_toggle(self): """Toggle the power on the media player. This method must be run in the event loop and returns a coroutine. """ + if hasattr(self, 'toggle'): + # pylint: disable=no-member + return self.hass.loop.run_in_executor(None, self.toggle) + if self.state in [STATE_OFF, STATE_IDLE]: return self.async_turn_on() else: @@ -804,18 +801,15 @@ class MediaPlayerDevice(Entity): yield from self.async_set_volume_level( max(0, self.volume_level - .1)) - def media_play_pause(self): - """Play or pause the media player.""" - if self.state == STATE_PLAYING: - self.media_pause() - else: - self.media_play() - def async_media_play_pause(self): """Play or pause the media player. This method must be run in the event loop and returns a coroutine. """ + if hasattr(self, 'media_play_pause'): + # pylint: disable=no-member + return self.hass.loop.run_in_executor(None, self.media_play_pause) + if self.state == STATE_PLAYING: return self.async_media_pause() else: diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 784c54f6d62..6acbf5c2db3 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -3,6 +3,8 @@ import unittest import asyncio import homeassistant.components.media_player as mp +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_ON, STATE_OFF, STATE_IDLE) from homeassistant.util.async import run_coroutine_threadsafe from tests.common import get_test_home_assistant @@ -15,6 +17,12 @@ class AsyncMediaPlayer(mp.MediaPlayerDevice): """Initialize the test media player.""" self.hass = hass self._volume = 0 + self._state = STATE_OFF + + @property + def state(self): + """State of the player.""" + return self._state @property def volume_level(self): @@ -26,6 +34,26 @@ class AsyncMediaPlayer(mp.MediaPlayerDevice): """Set volume level, range 0..1.""" self._volume = volume + @asyncio.coroutine + def async_media_play(self): + """Send play command.""" + self._state = STATE_PLAYING + + @asyncio.coroutine + def async_media_pause(self): + """Send pause command.""" + self._state = STATE_PAUSED + + @asyncio.coroutine + def async_turn_on(self): + """Turn the media player on.""" + self._state = STATE_ON + + @asyncio.coroutine + def async_turn_off(self): + """Turn the media player off.""" + self._state = STATE_OFF + class SyncMediaPlayer(mp.MediaPlayerDevice): """Sync media player test class.""" @@ -34,6 +62,12 @@ class SyncMediaPlayer(mp.MediaPlayerDevice): """Initialize the test media player.""" self.hass = hass self._volume = 0 + self._state = STATE_OFF + + @property + def state(self): + """State of the player.""" + return self._state @property def volume_level(self): @@ -54,6 +88,36 @@ class SyncMediaPlayer(mp.MediaPlayerDevice): if self.volume_level > 0: self.set_volume_level(max(0, self.volume_level - .2)) + def media_play_pause(self): + """Play or pause the media player.""" + if self._state == STATE_PLAYING: + self._state = STATE_PAUSED + else: + self._state = STATE_PLAYING + + def toggle(self): + """Toggle the power on the media player.""" + if self._state in [STATE_OFF, STATE_IDLE]: + self._state = STATE_ON + else: + self._state = STATE_OFF + + @asyncio.coroutine + def async_media_play_pause(self): + """Create a coroutine to wrap the future returned by ABC. + + This allows the run_coroutine_threadsafe helper to be used. + """ + yield from super().async_media_play_pause() + + @asyncio.coroutine + def async_toggle(self): + """Create a coroutine to wrap the future returned by ABC. + + This allows the run_coroutine_threadsafe helper to be used. + """ + yield from super().async_toggle() + class TestAsyncMediaPlayer(unittest.TestCase): """Test the media_player module.""" @@ -87,6 +151,26 @@ class TestAsyncMediaPlayer(unittest.TestCase): self.player.async_volume_down(), self.hass.loop).result() self.assertEqual(self.player.volume_level, 0.4) + def test_media_play_pause(self): + """Test the media_play_pause helper function.""" + self.assertEqual(self.player.state, STATE_OFF) + run_coroutine_threadsafe( + self.player.async_media_play_pause(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_PLAYING) + run_coroutine_threadsafe( + self.player.async_media_play_pause(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_PAUSED) + + def test_toggle(self): + """Test the toggle helper function.""" + self.assertEqual(self.player.state, STATE_OFF) + run_coroutine_threadsafe( + self.player.async_toggle(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_ON) + run_coroutine_threadsafe( + self.player.async_toggle(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_OFF) + class TestSyncMediaPlayer(unittest.TestCase): """Test the media_player module.""" @@ -117,3 +201,23 @@ class TestSyncMediaPlayer(unittest.TestCase): run_coroutine_threadsafe( self.player.async_volume_down(), self.hass.loop).result() self.assertEqual(self.player.volume_level, 0.3) + + def test_media_play_pause(self): + """Test the media_play_pause helper function.""" + self.assertEqual(self.player.state, STATE_OFF) + run_coroutine_threadsafe( + self.player.async_media_play_pause(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_PLAYING) + run_coroutine_threadsafe( + self.player.async_media_play_pause(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_PAUSED) + + def test_toggle(self): + """Test the toggle helper function.""" + self.assertEqual(self.player.state, STATE_OFF) + run_coroutine_threadsafe( + self.player.async_toggle(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_ON) + run_coroutine_threadsafe( + self.player.async_toggle(), self.hass.loop).result() + self.assertEqual(self.player.state, STATE_OFF) From aa1f64bed67c33e24cd03ceb9c996d11bb71172f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 28 Feb 2017 10:49:06 +0100 Subject: [PATCH 063/198] Migrate calendar setup to async. (#6305) --- homeassistant/components/calendar/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index e4de69c3ce8..1aefc11d9c0 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -3,8 +3,8 @@ Support for Google Calendar event device sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/calendar/ - """ +import asyncio import logging from datetime import timedelta @@ -27,13 +27,13 @@ DOMAIN = 'calendar' ENTITY_ID_FORMAT = DOMAIN + '.{}' -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Track states and offer events for calendars.""" component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL, DOMAIN) - component.setup(config) - + yield from component.async_setup(config) return True From be297c4c7e670bc1e3a9c558114f571f0bafc8da Mon Sep 17 00:00:00 2001 From: Krasimir Zhelev Date: Tue, 28 Feb 2017 15:23:07 +0100 Subject: [PATCH 064/198] Frontier silicon (#6131) * added frontier_silicon constant * added the frontier_silicon component * cleaning up according to travis * trying to satisfy pylint * trying to satisfy pylint * fsapi version 0.0.6 * with fsapi version 0.0.7 * added fsapi dependency * yielding the FSAPI * Removing white space from docstring * Removing white space from an empty line * Switching to sync * clean up white spaces and rename device to FSAPIDevice * added frontier_silicon constant * added the frontier_silicon component * cleaning up according to travis * trying to satisfy pylint * trying to satisfy pylint * fsapi version 0.0.6 * with fsapi version 0.0.7 * added fsapi dependency * yielding the FSAPI * Removing white space from docstring * Removing white space from an empty line * Switching to sync * clean up white spaces and rename device to FSAPIDevice * changed info to debug * added frontier_silicon constant * added the frontier_silicon component * cleaning up according to travis * trying to satisfy pylint * trying to satisfy pylint * fsapi version 0.0.6 * with fsapi version 0.0.7 * added fsapi dependency * yielding the FSAPI * Removing white space from docstring * Removing white space from an empty line * Switching to sync * clean up white spaces and rename device to FSAPIDevice * added the frontier_silicon component * trying to satisfy pylint * fsapi version 0.0.6 * remove white space * generated requirements * added the frontier_silicon component * cleaning up according to travis * trying to satisfy pylint * trying to satisfy pylint * fsapi version 0.0.6 * with fsapi version 0.0.7 * added fsapi dependency * yielding the FSAPI * Removing white space from docstring * Removing white space from an empty line * Switching to sync * clean up white spaces and rename device to FSAPIDevice * trying to satisfy pylint * changed info to debug * added the frontier_silicon component * fsapi version 0.0.6 * generated requirements * pylint * moved import requests to the method where it is being used * add a basic unit test * cleaned up source code * added frontier_silicon constant * added the frontier_silicon component * added basic test * added fsapi to requirements_all.txt * added coverage omit, though a basic test was included * added MEDIA_TYPE_MUSIC for artist and album * removed duplicate cons * switched fsapi call to a property, removed unecessary comment * detailed docstring for fs_device * added a space for the info_name - info_text separator * reduced proeprty (fsapi) access for volume down/up --- .coveragerc | 1 + homeassistant/components/discovery.py | 1 + .../media_player/frontier_silicon.py | 252 ++++++++++++++++++ requirements_all.txt | 3 + .../media_player/test_frontier_silicon.py | 42 +++ 5 files changed, 299 insertions(+) create mode 100644 homeassistant/components/media_player/frontier_silicon.py create mode 100644 tests/components/media_player/test_frontier_silicon.py diff --git a/.coveragerc b/.coveragerc index 4db8323c3a1..c88856c724e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -234,6 +234,7 @@ omit = homeassistant/components/media_player/dunehd.py homeassistant/components/media_player/emby.py homeassistant/components/media_player/firetv.py + homeassistant/components/media_player/frontier_silicon.py homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/gstreamer.py homeassistant/components/media_player/hdmi_cec.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index b8999ee2c43..284e8c042da 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -42,6 +42,7 @@ SERVICE_HANDLERS = { 'yeelight': ('light', 'yeelight'), 'flux_led': ('light', 'flux_led'), 'apple_tv': ('media_player', 'apple_tv'), + 'frontier_silicon': ('media_player', 'frontier_silicon'), 'openhome': ('media_player', 'openhome'), } diff --git a/homeassistant/components/media_player/frontier_silicon.py b/homeassistant/components/media_player/frontier_silicon.py new file mode 100644 index 00000000000..386a489b646 --- /dev/null +++ b/homeassistant/components/media_player/frontier_silicon.py @@ -0,0 +1,252 @@ +""" +Support for Frontier Silicon Devices (Medion, Hama, Auna,...). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.frontier_silicon/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_PLAY, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA, + MEDIA_TYPE_MUSIC) +from homeassistant.const import ( + STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN, + CONF_HOST, CONF_PORT, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['fsapi==0.0.7'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FRONTIER_SILICON = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \ + SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_STOP | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +DEFAULT_PORT = 80 +DEFAULT_PASSWORD = '1234' +DEVICE_URL = 'http://{0}:{1}/device' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Frontier Silicon platform.""" + import requests + + if discovery_info is not None: + add_devices( + [FSAPIDevice(discovery_info, DEFAULT_PASSWORD)], + update_before_add=True) + return True + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + password = config.get(CONF_PASSWORD) + + try: + add_devices( + [FSAPIDevice(DEVICE_URL.format(host, port), password)], + update_before_add=True) + _LOGGER.debug('FSAPI device %s:%s -> %s', host, port, password) + return True + except requests.exceptions.RequestException: + _LOGGER.error('Could not add the FSAPI device at %s:%s -> %s', + host, port, password) + + return False + + +class FSAPIDevice(MediaPlayerDevice): + """Representation of a Frontier Silicon device on the network.""" + + def __init__(self, device_url, password): + """Initialize the Frontier Silicon API device.""" + self._device_url = device_url + self._password = password + self._state = STATE_UNKNOWN + + self._name = None + self._title = None + self._artist = None + self._album_name = None + self._mute = None + self._source = None + self._source_list = None + self._media_image_url = None + + # Properties + @property + def fs_device(self): + """ + Create a fresh fsapi session. + + A new session is created for each request in case someone else + connected to the device in between the updates and invalidated the + existing session (i.e UNDOK). + """ + from fsapi import FSAPI + + return FSAPI(self._device_url, self._password) + + @property + def should_poll(self): + """Device should be polled.""" + return True + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def media_title(self): + """Title of current playing media.""" + return self._title + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self._artist + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + return self._album_name + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return SUPPORT_FRONTIER_SILICON + + @property + def state(self): + """Return the state of the player.""" + return self._state + + # source + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @property + def source(self): + """Name of the current input source.""" + return self._source + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._media_image_url + + def update(self): + """Get the latest date and update device state.""" + fs_device = self.fs_device + + if not self._name: + self._name = fs_device.friendly_name + + if not self._source_list: + self._source_list = fs_device.mode_list + + status = fs_device.play_status + self._state = { + 'playing': STATE_PLAYING, + 'paused': STATE_PAUSED, + 'stopped': STATE_OFF, + 'unknown': STATE_UNKNOWN, + None: STATE_OFF, + }.get(status, STATE_UNKNOWN) + + info_name = fs_device.play_info_name + info_text = fs_device.play_info_text + + self._title = ' - '.join(filter(None, [info_name, info_text])) + self._artist = fs_device.play_info_artist + self._album_name = fs_device.play_info_album + + self._source = fs_device.mode + self._mute = fs_device.mute + self._media_image_url = fs_device.play_info_graphics + + # Management actions + + # power control + def turn_on(self): + """Turn on the device.""" + self.fs_device.power = True + + def turn_off(self): + """Turn off the device.""" + self.fs_device.power = False + + def media_play(self): + """Send play command.""" + self.fs_device.play() + + def media_pause(self): + """Send pause command.""" + self.fs_device.pause() + + def media_play_pause(self): + """Send play/pause command.""" + if 'playing' in self._state: + self.fs_device.pause() + else: + self.fs_device.play() + + def media_stop(self): + """Send play/pause command.""" + self.fs_device.pause() + + def media_previous_track(self): + """Send previous track command (results in rewind).""" + self.fs_device.prev() + + def media_next_track(self): + """Send next track command (results in fast-forward).""" + self.fs_device.next() + + # mute + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + def mute_volume(self, mute): + """Send mute command.""" + self.fs_device.mute = mute + + # volume + def volume_up(self): + """Send volume up command.""" + self.fs_device.volume += 1 + + def volume_down(self): + """Send volume down command.""" + self.fs_device.volume -= 1 + + def set_volume_level(self, volume): + """Set volume command.""" + self.fs_device.volume = volume + + def select_source(self, source): + """Select input source.""" + self.fs_device.mode = source diff --git a/requirements_all.txt b/requirements_all.txt index 6c883ff4f2d..9f69acee1b4 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -167,6 +167,9 @@ freesms==0.1.1 # homeassistant.components.switch.fritzdect fritzhome==1.0.2 +# homeassistant.components.media_player.frontier_silicon +fsapi==0.0.7 + # homeassistant.components.conversation fuzzywuzzy==0.15.0 diff --git a/tests/components/media_player/test_frontier_silicon.py b/tests/components/media_player/test_frontier_silicon.py new file mode 100644 index 00000000000..a2c3223cd9c --- /dev/null +++ b/tests/components/media_player/test_frontier_silicon.py @@ -0,0 +1,42 @@ +"""The tests for the Demo Media player platform.""" +import unittest +from unittest import mock + +import logging + +from homeassistant.components.media_player.frontier_silicon import FSAPIDevice +from homeassistant.components.media_player import frontier_silicon +from homeassistant import const + +from tests.common import get_test_home_assistant + +_LOGGER = logging.getLogger(__name__) + + +class TestFrontierSiliconMediaPlayer(unittest.TestCase): + """Test the media_player module.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_host_required_with_host(self): + """Test that a host with a valid url is set when using a conf.""" + fake_config = { + const.CONF_HOST: 'host_ip', + } + result = frontier_silicon.setup_platform(self.hass, + fake_config, mock.MagicMock()) + + self.assertTrue(result) + + def test_invalid_host(self): + """Test that a host with a valid url is set when using a conf.""" + import requests + + fsapi = FSAPIDevice('INVALID_URL', '1234') + self.assertRaises(requests.exceptions.MissingSchema, fsapi.update) From 383b0914b38449d7146f8027bd4b175a6941975f Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Tue, 28 Feb 2017 11:01:19 -0500 Subject: [PATCH 065/198] Version bump to 0.40.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3de056753e6..422854c482c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 39 +MINOR_VERSION = 40 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 41f558b181075980c551796c76cd32480c32e539 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 1 Mar 2017 05:33:19 +0100 Subject: [PATCH 066/198] Bootstrap / Component setup async (#6264) * Bootstrap / Entiy setup async * Cleanup add_job stuff / return task/future object * Address paulus comments / part 1 * fix install pip * Cleanup bootstrap / move config stuff to config.py * Make demo async * Further bootstrap improvement * Address Martin's comments * Fix initial tests * Fix final tests * Fix bug with prepare loader * Remove no longer needed things * Log error when invalid config * More cleanup * Cleanups platform events & fix lint * Use a non blocking add_entities callback for platform * Fix Autoamtion is setup befor entity is ready * Better automation fix * Address paulus comments * Typo * fix lint * rename functions * fix tests * fix test * change exceptions * fix spell --- homeassistant/bootstrap.py | 478 +++++++----------- .../alarm_control_panel/envisalink.py | 2 +- .../components/alarm_control_panel/mqtt.py | 2 +- .../components/automation/__init__.py | 26 +- .../components/binary_sensor/envisalink.py | 2 +- .../components/binary_sensor/ffmpeg_motion.py | 2 +- .../components/binary_sensor/ffmpeg_noise.py | 2 +- .../components/binary_sensor/mqtt.py | 2 +- .../components/binary_sensor/template.py | 2 +- .../components/binary_sensor/threshold.py | 2 +- homeassistant/components/camera/ffmpeg.py | 2 +- homeassistant/components/camera/generic.py | 2 +- homeassistant/components/camera/mjpeg.py | 2 +- homeassistant/components/camera/synology.py | 2 +- homeassistant/components/camera/zoneminder.py | 2 +- .../components/climate/generic_thermostat.py | 2 +- homeassistant/components/cover/mqtt.py | 2 +- homeassistant/components/demo.py | 170 ++++--- .../components/device_tracker/__init__.py | 5 +- homeassistant/components/fan/mqtt.py | 2 +- .../image_processing/microsoft_face_detect.py | 2 +- .../microsoft_face_identify.py | 2 +- .../image_processing/openalpr_cloud.py | 2 +- .../image_processing/openalpr_local.py | 2 +- homeassistant/components/light/mqtt.py | 2 +- homeassistant/components/light/mqtt_json.py | 2 +- .../components/light/mqtt_template.py | 2 +- homeassistant/components/light/rflink.py | 19 +- homeassistant/components/lock/mqtt.py | 2 +- .../components/media_player/anthemav.py | 2 +- .../components/media_player/squeezebox.py | 2 +- .../components/media_player/universal.py | 2 +- homeassistant/components/scene/__init__.py | 1 - .../components/scene/homeassistant.py | 3 +- homeassistant/components/script.py | 2 +- .../components/sensor/api_streams.py | 2 +- homeassistant/components/sensor/dnsip.py | 2 +- homeassistant/components/sensor/dsmr.py | 2 +- homeassistant/components/sensor/envisalink.py | 2 +- homeassistant/components/sensor/min_max.py | 2 +- homeassistant/components/sensor/moon.py | 2 +- homeassistant/components/sensor/mqtt.py | 2 +- homeassistant/components/sensor/mqtt_room.py | 2 +- homeassistant/components/sensor/random.py | 2 +- homeassistant/components/sensor/rflink.py | 9 +- homeassistant/components/sensor/statistics.py | 2 +- homeassistant/components/sensor/template.py | 2 +- homeassistant/components/sensor/time_date.py | 2 +- homeassistant/components/sensor/worldclock.py | 2 +- homeassistant/components/sensor/yr.py | 2 +- homeassistant/components/switch/hook.py | 2 +- homeassistant/components/switch/mqtt.py | 2 +- homeassistant/components/switch/rest.py | 2 +- homeassistant/components/switch/rflink.py | 2 +- homeassistant/components/switch/template.py | 2 +- homeassistant/components/zwave/__init__.py | 2 +- homeassistant/config.py | 120 ++++- homeassistant/core.py | 32 +- homeassistant/helpers/discovery.py | 33 +- homeassistant/helpers/entity_component.py | 73 ++- homeassistant/loader.py | 35 -- homeassistant/scripts/check_config.py | 6 +- tests/common.py | 30 +- .../alarm_control_panel/test_mqtt.py | 10 - tests/components/automation/test_event.py | 4 +- tests/components/automation/test_init.py | 4 +- tests/components/automation/test_mqtt.py | 5 +- .../automation/test_numeric_state.py | 4 +- tests/components/automation/test_state.py | 5 +- tests/components/automation/test_sun.py | 7 +- tests/components/automation/test_template.py | 5 +- tests/components/automation/test_time.py | 5 +- tests/components/automation/test_zone.py | 4 +- tests/components/binary_sensor/test_mqtt.py | 7 +- tests/components/camera/test_uvc.py | 5 +- .../climate/test_generic_thermostat.py | 40 +- tests/components/config/test_core.py | 3 + tests/components/config/test_init.py | 4 +- tests/components/cover/test_mqtt.py | 11 +- tests/components/cover/test_rfxtrx.py | 4 +- .../components/device_tracker/test_asuswrt.py | 5 +- tests/components/device_tracker/test_ddwrt.py | 5 +- tests/components/device_tracker/test_mqtt.py | 1 - .../device_tracker/test_upc_connect.py | 5 +- tests/components/http/test_init.py | 69 +-- tests/components/light/test_demo.py | 4 +- tests/components/light/test_mqtt.py | 8 - tests/components/light/test_mqtt_json.py | 7 - tests/components/light/test_mqtt_template.py | 7 - tests/components/light/test_rfxtrx.py | 4 +- tests/components/lock/test_mqtt.py | 3 - .../components/media_player/test_universal.py | 2 - tests/components/mqtt/test_server.py | 17 +- tests/components/notify/test_demo.py | 16 - tests/components/sensor/test_mqtt.py | 6 +- tests/components/sensor/test_pilight.py | 5 +- tests/components/sensor/test_rfxtrx.py | 4 +- tests/components/switch/test_mqtt.py | 3 - tests/components/switch/test_rfxtrx.py | 4 +- tests/components/test_input_boolean.py | 4 +- tests/components/test_panel_custom.py | 3 + tests/components/test_rfxtrx.py | 4 +- tests/components/test_script.py | 4 +- tests/components/test_zone.py | 12 +- tests/helpers/test_discovery.py | 23 +- tests/helpers/test_entity_component.py | 6 + tests/helpers/test_restore_state.py | 5 +- tests/test_bootstrap.py | 83 ++- tests/test_loader.py | 30 -- 109 files changed, 764 insertions(+), 848 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cb32fc887c9..b1233594f89 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -4,38 +4,41 @@ import logging import logging.handlers import os import sys +from time import time from collections import OrderedDict from types import ModuleType from typing import Any, Optional, Dict import voluptuous as vol -from voluptuous.humanize import humanize_error import homeassistant.components as core_components from homeassistant.components import persistent_notification import homeassistant.config as conf_util +from homeassistant.config import async_notify_setup_error import homeassistant.core as core from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE import homeassistant.loader as loader import homeassistant.util.package as pkg_util -from homeassistant.util.async import ( - run_coroutine_threadsafe, run_callback_threadsafe) +from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.util.logging import AsyncHandler from homeassistant.util.yaml import clear_secret_cache from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - event_decorators, service, config_per_platform, extract_domain_configs) +from homeassistant.helpers import event_decorators, service from homeassistant.helpers.signal import async_register_signal_handling _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = 'component' +DATA_SETUP = 'setup_tasks' +DATA_PIP_LOCK = 'pip_lock' + ERROR_LOG_FILENAME = 'home-assistant.log' -DATA_PERSISTENT_ERRORS = 'bootstrap_persistent_errors' -HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)' + +FIRST_INIT_COMPONENT = set(( + 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction')) def setup_component(hass: core.HomeAssistant, domain: str, @@ -52,49 +55,82 @@ def async_setup_component(hass: core.HomeAssistant, domain: str, This method is a coroutine. """ - if domain in hass.config.components: - _LOGGER.debug('Component %s already set up.', domain) - return True + setup_tasks = hass.data.get(DATA_SETUP) - if not loader.PREPARED: - yield from hass.loop.run_in_executor(None, loader.prepare, hass) + if setup_tasks is not None and domain in setup_tasks: + return (yield from setup_tasks[domain]) if config is None: config = {} - components = loader.load_order_component(domain) + if setup_tasks is None: + setup_tasks = hass.data[DATA_SETUP] = {} - # OrderedSet is empty if component or dependencies could not be resolved - if not components: - _async_persistent_notification(hass, domain, True) - return False + task = setup_tasks[domain] = hass.async_add_job( + _async_setup_component(hass, domain, config)) - for component in components: - res = yield from _async_setup_component(hass, component, config) - if not res: - _LOGGER.error('Component %s failed to setup', component) - _async_persistent_notification(hass, component, True) - return False + return (yield from task) + + +@asyncio.coroutine +def _async_process_requirements(hass: core.HomeAssistant, name: str, + requirements) -> bool: + """Install the requirements for a component. + + This method is a coroutine. + """ + if hass.config.skip_pip: + return True + + pip_lock = hass.data.get(DATA_PIP_LOCK) + if pip_lock is None: + pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock(loop=hass.loop) + + def pip_install(mod): + """Install packages.""" + return pkg_util.install_package(mod, target=hass.config.path('deps')) + + with (yield from pip_lock): + for req in requirements: + ret = yield from hass.loop.run_in_executor(None, pip_install, req) + if not ret: + _LOGGER.error('Not initializing %s because could not install ' + 'dependency %s', name, req) + async_notify_setup_error(hass, name) + return False return True -def _handle_requirements(hass: core.HomeAssistant, component, - name: str) -> bool: - """Install the requirements for a component. +@asyncio.coroutine +def _async_process_dependencies(hass, config, name, dependencies): + """Ensure all dependencies are set up.""" + blacklisted = [dep for dep in dependencies + if dep in loader.DEPENDENCY_BLACKLIST] - This method needs to run in an executor. - """ - if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'): + if blacklisted: + _LOGGER.error('Unable to setup dependencies of %s: ' + 'found blacklisted dependencies: %s', + name, ', '.join(blacklisted)) + return False + + tasks = [async_setup_component(hass, dep, config) for dep + in dependencies] + + if not tasks: return True - for req in component.REQUIREMENTS: - if not pkg_util.install_package(req, target=hass.config.path('deps')): - _LOGGER.error('Not initializing %s because could not install ' - 'dependency %s', name, req) - _async_persistent_notification(hass, name) - return False + results = yield from asyncio.gather(*tasks, loop=hass.loop) + failed = [dependencies[idx] for idx, res + in enumerate(results) if not res] + + if failed: + _LOGGER.error('Unable to setup dependencies of %s. ' + 'Setup failed for dependencies: %s', + name, ', '.join(failed)) + + return False return True @@ -104,172 +140,78 @@ def _async_setup_component(hass: core.HomeAssistant, """Setup a component for Home Assistant. This method is a coroutine. + + hass: Home Assistant instance. + domain: Domain of component to setup. + config: The Home Assistant configuration. """ - # pylint: disable=too-many-return-statements - if domain in hass.config.components: - return True + def log_error(msg): + """Log helper.""" + _LOGGER.error('Setup failed for %s: %s', domain, msg) + async_notify_setup_error(hass, domain, True) - setup_lock = hass.data.get('setup_lock') - if setup_lock is None: - setup_lock = hass.data['setup_lock'] = asyncio.Lock(loop=hass.loop) + # Validate no circular dependencies + components = loader.load_order_component(domain) - setup_progress = hass.data.get('setup_progress') - if setup_progress is None: - setup_progress = hass.data['setup_progress'] = [] - - if domain in setup_progress: - _LOGGER.error('Attempt made to setup %s during setup of %s', - domain, domain) - _async_persistent_notification(hass, domain, True) + # OrderedSet is empty if component or dependencies could not be resolved + if not components: + log_error('Unable to resolve component or dependencies') return False - try: - # Used to indicate to discovery that a setup is ongoing and allow it - # to wait till it is done. - did_lock = False - if not setup_lock.locked(): - yield from setup_lock.acquire() - did_lock = True - - setup_progress.append(domain) - config = yield from async_prepare_setup_component(hass, config, domain) - - if config is None: - return False - - component = loader.get_component(domain) - if component is None: - _async_persistent_notification(hass, domain) - return False - - async_comp = hasattr(component, 'async_setup') - - try: - _LOGGER.info("Setting up %s", domain) - if async_comp: - result = yield from component.async_setup(hass, config) - else: - result = yield from hass.loop.run_in_executor( - None, component.setup, hass, config) - except Exception: # pylint: disable=broad-except - _LOGGER.exception('Error during setup of component %s', domain) - _async_persistent_notification(hass, domain, True) - return False - - if result is False: - _LOGGER.error('component %s failed to initialize', domain) - _async_persistent_notification(hass, domain, True) - return False - elif result is not True: - _LOGGER.error('component %s did not return boolean if setup ' - 'was successful. Disabling component.', domain) - _async_persistent_notification(hass, domain, True) - loader.set_component(domain, None) - return False - - hass.config.components.add(component.DOMAIN) - - hass.bus.async_fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} - ) - - return True - finally: - setup_progress.remove(domain) - if did_lock: - setup_lock.release() - - -def prepare_setup_component(hass: core.HomeAssistant, config: dict, - domain: str): - """Prepare setup of a component and return processed config.""" - return run_coroutine_threadsafe( - async_prepare_setup_component(hass, config, domain), loop=hass.loop - ).result() - - -@asyncio.coroutine -def async_prepare_setup_component(hass: core.HomeAssistant, config: dict, - domain: str): - """Prepare setup of a component and return processed config. - - This method is a coroutine. - """ - # pylint: disable=too-many-return-statements component = loader.get_component(domain) - missing_deps = [dep for dep in getattr(component, 'DEPENDENCIES', []) - if dep not in hass.config.components] - if missing_deps: - _LOGGER.error( - 'Not initializing %s because not all dependencies loaded: %s', - domain, ", ".join(missing_deps)) - return None + processed_config = \ + conf_util.async_process_component_config(hass, config, domain) - if hasattr(component, 'CONFIG_SCHEMA'): - try: - config = component.CONFIG_SCHEMA(config) - except vol.Invalid as ex: - async_log_exception(ex, domain, config, hass) - return None + if processed_config is None: + log_error('Invalid config') + return False - elif hasattr(component, 'PLATFORM_SCHEMA'): - platforms = [] - for p_name, p_config in config_per_platform(config, domain): - # Validate component specific platform schema - try: - p_validated = component.PLATFORM_SCHEMA(p_config) - except vol.Invalid as ex: - async_log_exception(ex, domain, config, hass) - continue + if not hass.config.skip_pip and hasattr(component, 'REQUIREMENTS'): + req_success = yield from _async_process_requirements( + hass, domain, component.REQUIREMENTS) + if not req_success: + log_error('Could not install all requirements.') + return False - # Not all platform components follow same pattern for platforms - # So if p_name is None we are not going to validate platform - # (the automation component is one of them) - if p_name is None: - platforms.append(p_validated) - continue + if hasattr(component, 'DEPENDENCIES'): + dep_success = yield from _async_process_dependencies( + hass, config, domain, component.DEPENDENCIES) - platform = yield from async_prepare_setup_platform( - hass, config, domain, p_name) + if not dep_success: + log_error('Could not setup all dependencies.') + return False - if platform is None: - continue + async_comp = hasattr(component, 'async_setup') - # Validate platform specific schema - if hasattr(platform, 'PLATFORM_SCHEMA'): - try: - # pylint: disable=no-member - p_validated = platform.PLATFORM_SCHEMA(p_validated) - except vol.Invalid as ex: - async_log_exception(ex, '{}.{}'.format(domain, p_name), - p_validated, hass) - continue + try: + _LOGGER.info("Setting up %s", domain) + if async_comp: + result = yield from component.async_setup(hass, processed_config) + else: + result = yield from hass.loop.run_in_executor( + None, component.setup, hass, processed_config) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error during setup of component %s', domain) + async_notify_setup_error(hass, domain, True) + return False - platforms.append(p_validated) + if result is False: + log_error('Component failed to initialize.') + return False + elif result is not True: + log_error('Component did not return boolean if setup was successful. ' + 'Disabling component.') + loader.set_component(domain, None) + return False - # Create a copy of the configuration with all config for current - # component removed and add validated config back in. - filter_keys = extract_domain_configs(config, domain) - config = {key: value for key, value in config.items() - if key not in filter_keys} - config[domain] = platforms + hass.config.components.add(component.DOMAIN) - res = yield from hass.loop.run_in_executor( - None, _handle_requirements, hass, component, domain) - if not res: - return None + hass.bus.async_fire( + EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} + ) - return config - - -def prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, - platform_name: str) -> Optional[ModuleType]: - """Load a platform and makes sure dependencies are setup.""" - return run_coroutine_threadsafe( - async_prepare_setup_platform(hass, config, domain, platform_name), - loop=hass.loop - ).result() + return True @asyncio.coroutine @@ -280,17 +222,19 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, This method is a coroutine. """ - if not loader.PREPARED: - yield from hass.loop.run_in_executor(None, loader.prepare, hass) - platform_path = PLATFORM_FORMAT.format(domain, platform_name) + def log_error(msg): + """Log helper.""" + _LOGGER.error('Unable to prepare setup for platform %s: %s', + platform_path, msg) + async_notify_setup_error(hass, platform_path) + platform = loader.get_platform(domain, platform_name) # Not found if platform is None: - _LOGGER.error('Unable to find platform %s', platform_path) - _async_persistent_notification(hass, platform_path) + log_error('Unable to find platform') return None # Already loaded @@ -298,25 +242,22 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform # Load dependencies - for component in getattr(platform, 'DEPENDENCIES', []): - if component in loader.DEPENDENCY_BLACKLIST: - raise HomeAssistantError( - '{} is not allowed to be a dependency.'.format(component)) + if hasattr(platform, 'DEPENDENCIES'): + dep_success = yield from _async_process_dependencies( + hass, config, platform_path, platform.DEPENDENCIES) - res = yield from async_setup_component(hass, component, config) - if not res: - _LOGGER.error( - 'Unable to prepare setup for platform %s because ' - 'dependency %s could not be initialized', platform_path, - component) - _async_persistent_notification(hass, platform_path, True) + if not dep_success: + log_error('Could not setup all dependencies.') + return False + + if not hass.config.skip_pip and hasattr(platform, 'REQUIREMENTS'): + req_success = yield from _async_process_requirements( + hass, platform_path, platform.REQUIREMENTS) + + if not req_success: + log_error('Could not install all requirements.') return None - res = yield from hass.loop.run_in_executor( - None, _handle_requirements, hass, platform, platform_path) - if not res: - return None - return platform @@ -339,23 +280,14 @@ def from_config_dict(config: Dict[str, Any], hass.config.config_dir = config_dir mount_local_lib_path(config_dir) - @asyncio.coroutine - def _async_init_from_config_dict(future): - try: - re_hass = yield from async_from_config_dict( - config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days) - future.set_result(re_hass) - # pylint: disable=broad-except - except Exception as exc: - future.set_exception(exc) - # run task - future = asyncio.Future(loop=hass.loop) - hass.async_add_job(_async_init_from_config_dict(future)) - hass.loop.run_until_complete(future) + hass = hass.loop.run_until_complete( + async_from_config_dict( + config, hass, config_dir, enable_log, verbose, skip_pip, + log_rotate_days) + ) - return future.result() + return hass @asyncio.coroutine @@ -372,19 +304,15 @@ def async_from_config_dict(config: Dict[str, Any], Dynamically loads required components and its dependencies. This method is a coroutine. """ + start = time() hass.async_track_tasks() - setup_lock = hass.data.get('setup_lock') - if setup_lock is None: - setup_lock = hass.data['setup_lock'] = asyncio.Lock(loop=hass.loop) - - yield from setup_lock.acquire() core_config = config.get(core.DOMAIN, {}) try: yield from conf_util.async_process_ha_core_config(hass, core_config) except vol.Invalid as ex: - async_log_exception(ex, 'homeassistant', core_config, hass) + conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) return None yield from hass.loop.run_in_executor( @@ -433,20 +361,25 @@ def async_from_config_dict(config: Dict[str, Any], event_decorators.HASS = hass service.HASS = hass - # Setup the components - dependency_blacklist = loader.DEPENDENCY_BLACKLIST - set(components) + # stage 1 + for component in components: + if component not in FIRST_INIT_COMPONENT: + continue + hass.async_add_job(async_setup_component(hass, component, config)) - for domain in loader.load_order_components(components): - if domain in dependency_blacklist: - raise HomeAssistantError( - '{} is not allowed to be a dependency'.format(domain)) + yield from hass.async_block_till_done() - yield from _async_setup_component(hass, domain, config) - - setup_lock.release() + # stage 2 + for component in components: + if component in FIRST_INIT_COMPONENT: + continue + hass.async_add_job(async_setup_component(hass, component, config)) yield from hass.async_stop_track_tasks() + stop = time() + _LOGGER.info('Home Assistant initialized in %ss', round(stop-start, 2)) + async_register_signal_handling(hass) return hass @@ -464,22 +397,13 @@ def from_config_file(config_path: str, if hass is None: hass = core.HomeAssistant() - @asyncio.coroutine - def _async_init_from_config_file(future): - try: - re_hass = yield from async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days) - future.set_result(re_hass) - # pylint: disable=broad-except - except Exception as exc: - future.set_exception(exc) - # run task - future = asyncio.Future(loop=hass.loop) - hass.loop.create_task(_async_init_from_config_file(future)) - hass.loop.run_until_complete(future) + hass = hass.loop.run_until_complete( + async_from_config_file( + config_path, hass, verbose, skip_pip, log_rotate_days) + ) - return future.result() + return hass @asyncio.coroutine @@ -588,62 +512,6 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, 'Unable to setup error log %s (access denied)', err_log_path) -def log_exception(ex, domain, config, hass): - """Generate log exception for config validation.""" - run_callback_threadsafe( - hass.loop, async_log_exception, ex, domain, config, hass).result() - - -@core.callback -def _async_persistent_notification(hass: core.HomeAssistant, component: str, - link: Optional[bool]=False): - """Print a persistent notification. - - This method must be run in the event loop. - """ - errors = hass.data.get(DATA_PERSISTENT_ERRORS) - - if errors is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} - - errors[component] = errors.get(component) or link - _lst = [HA_COMPONENT_URL.format(name.replace('_', '-'), name) - if link else name for name, link in errors.items()] - message = ('The following components and platforms could not be set up:\n' - '* ' + '\n* '.join(list(_lst)) + '\nPlease check your config') - persistent_notification.async_create( - hass, message, 'Invalid config', 'invalid_config') - - -@core.callback -def async_log_exception(ex, domain, config, hass): - """Generate log exception for config validation. - - This method must be run in the event loop. - """ - message = 'Invalid config for [{}]: '.format(domain) - if hass is not None: - _async_persistent_notification(hass, domain, True) - - if 'extra keys not allowed' in ex.error_message: - message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ - .format(ex.path[-1], domain, domain, - '->'.join(str(m) for m in ex.path)) - else: - message += '{}.'.format(humanize_error(config, ex)) - - domain_config = config.get(domain, config) - message += " (See {}, line {}). ".format( - getattr(domain_config, '__config_file__', '?'), - getattr(domain_config, '__line__', '?')) - - if domain != 'homeassistant': - message += ('Please check the docs at ' - 'https://home-assistant.io/components/{}/'.format(domain)) - - _LOGGER.error(message) - - def mount_local_lib_path(config_dir: str) -> str: """Add local library to Python Path. diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index cd5bddbad49..248b0124d77 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -55,7 +55,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ) devices.append(device) - yield from async_add_devices(devices) + async_add_devices(devices) @callback def alarm_keypress_handler(service): diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 455f60319c6..b22f50b6575 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -46,7 +46,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup the MQTT platform.""" - yield from async_add_devices([MqttAlarm( + async_add_devices([MqttAlarm( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index bebace6d827..0e734d7214d 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -28,8 +28,6 @@ import homeassistant.helpers.config_validation as cv DOMAIN = 'automation' ENTITY_ID_FORMAT = DOMAIN + '.{}' -DEPENDENCIES = ['group'] - GROUP_NAME_ALL_AUTOMATIONS = 'all automations' CONF_ALIAS = 'alias' @@ -226,7 +224,7 @@ class AutomationEntity(ToggleEntity): """Entity to show status of entity.""" def __init__(self, name, async_attach_triggers, cond_func, async_action, - hidden): + hidden, initial_state): """Initialize an automation entity.""" self._name = name self._async_attach_triggers = async_attach_triggers @@ -236,6 +234,7 @@ class AutomationEntity(ToggleEntity): self._enabled = False self._last_triggered = None self._hidden = hidden + self._initial_state = initial_state @property def name(self): @@ -264,6 +263,12 @@ class AutomationEntity(ToggleEntity): """Return True if entity is on.""" return self._enabled + @asyncio.coroutine + def async_added_to_hass(self) -> None: + """Startup if initial_state.""" + if self._initial_state: + yield from self.async_enable() + @asyncio.coroutine def async_turn_on(self, **kwargs) -> None: """Turn the entity on and update the state.""" @@ -322,7 +327,6 @@ def _async_process_config(hass, config, component): This method is a coroutine. """ entities = [] - tasks = [] for config_key in extract_domain_configs(config, DOMAIN): conf = config[config_key] @@ -332,6 +336,7 @@ def _async_process_config(hass, config, component): list_no) hidden = config_block[CONF_HIDE_ENTITY] + initial_state = config_block[CONF_INITIAL_STATE] action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), name) @@ -348,15 +353,14 @@ 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) - if config_block[CONF_INITIAL_STATE]: - tasks.append(entity.async_enable()) + config_block.get(CONF_TRIGGER, []), name + ) + entity = AutomationEntity( + name, async_attach_triggers, cond_func, action, hidden, + initial_state) + entities.append(entity) - if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) if entities: yield from component.async_add_entities(entities) diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 279dadf120f..acc71da3f46 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -37,7 +37,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ) devices.append(device) - yield from async_add_devices(devices) + async_add_devices(devices) class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/ffmpeg_motion.py b/homeassistant/components/binary_sensor/ffmpeg_motion.py index 3dd3f351227..418a6342172 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_motion.py +++ b/homeassistant/components/binary_sensor/ffmpeg_motion.py @@ -57,7 +57,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # generate sensor object entity = FFmpegMotion(hass, manager, config) - yield from async_add_devices([entity]) + async_add_devices([entity]) class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/ffmpeg_noise.py b/homeassistant/components/binary_sensor/ffmpeg_noise.py index af5c64186f6..c3400150f74 100644 --- a/homeassistant/components/binary_sensor/ffmpeg_noise.py +++ b/homeassistant/components/binary_sensor/ffmpeg_noise.py @@ -54,7 +54,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # generate sensor object entity = FFmpegNoise(hass, manager, config) - yield from async_add_devices([entity]) + async_add_devices([entity]) class FFmpegNoise(FFmpegBinarySensor): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 06814d85f88..d8467a6cbfe 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -46,7 +46,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass - yield from async_add_devices([MqttBinarySensor( + async_add_devices([MqttBinarySensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), get_deprecated(config, CONF_DEVICE_CLASS, CONF_SENSOR_CLASS), diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 8f11424f54c..35666e0ea55 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -66,7 +66,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error('No sensors added') return False - yield from async_add_devices(sensors, True) + async_add_devices(sensors, True) return True diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index be41fd96556..c97ba17b874 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -52,7 +52,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): limit_type = config.get(CONF_TYPE) device_class = get_deprecated(config, CONF_DEVICE_CLASS, CONF_SENSOR_CLASS) - yield from async_add_devices( + async_add_devices( [ThresholdSensor(hass, entity_id, name, threshold, limit_type, device_class)], True) return True diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 6b00ae240ed..ed8c84f90df 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -34,7 +34,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a FFmpeg Camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): return - yield from async_add_devices([FFmpegCamera(hass, config)]) + async_add_devices([FFmpegCamera(hass, config)]) class FFmpegCamera(Camera): diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index f9a4e8c2f06..3f50bc799c4 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a generic IP Camera.""" - yield from async_add_devices([GenericCamera(hass, config)]) + async_add_devices([GenericCamera(hass, config)]) class GenericCamera(Camera): diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 8d52785557b..fa46ea55e2c 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -45,7 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a MJPEG IP Camera.""" - yield from async_add_devices([MjpegCamera(hass, config)]) + async_add_devices([MjpegCamera(hass, config)]) def extract_image_from_mjpeg(stream): diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py index 39939c73d0d..c5d87c39086 100644 --- a/homeassistant/components/camera/synology.py +++ b/homeassistant/components/camera/synology.py @@ -153,7 +153,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ) devices.append(device) - yield from async_add_devices(devices) + async_add_devices(devices) @asyncio.coroutine diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py index 12615262b26..5148ce8b245 100644 --- a/homeassistant/components/camera/zoneminder.py +++ b/homeassistant/components/camera/zoneminder.py @@ -75,4 +75,4 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.warning('No active cameras found') return - yield from async_add_devices(cameras) + async_add_devices(cameras) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index da746270197..d4b8ef16985 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -63,7 +63,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): min_cycle_duration = config.get(CONF_MIN_DUR) tolerance = config.get(CONF_TOLERANCE) - yield from async_add_devices([GenericThermostat( + async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, tolerance)]) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 97ddad74d79..6403e0bbc85 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -54,7 +54,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass - yield from async_add_devices([MqttCover( + async_add_devices([MqttCover( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 170159e1d25..e03cb72ea44 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -4,6 +4,7 @@ Sets up a demo environment that mimics interaction with devices. For more details about this component, please refer to the documentation https://home-assistant.io/components/demo/ """ +import asyncio import time import homeassistant.bootstrap as bootstrap @@ -34,7 +35,8 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ ] -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Setup a demo environment.""" group = loader.get_component('group') configurator = loader.get_component('configurator') @@ -44,7 +46,7 @@ def setup(hass, config): config.setdefault(DOMAIN, {}) if config[DOMAIN].get('hide_demo_state') != 1: - hass.states.set('a.Demo_Mode', 'Enabled') + hass.states.async_set('a.Demo_Mode', 'Enabled') # Setup sun if not hass.config.latitude: @@ -53,50 +55,71 @@ def setup(hass, config): if not hass.config.longitude: hass.config.longitude = 117.22743 - bootstrap.setup_component(hass, 'sun') + tasks = [ + bootstrap.async_setup_component(hass, 'sun') + ] # Setup demo platforms demo_config = config.copy() for component in COMPONENTS_WITH_DEMO_PLATFORM: demo_config[component] = {CONF_PLATFORM: 'demo'} - bootstrap.setup_component(hass, component, demo_config) + tasks.append( + bootstrap.async_setup_component(hass, component, demo_config)) + + # Set up input select + tasks.append(bootstrap.async_setup_component( + hass, 'input_select', + {'input_select': + {'living_room_preset': {'options': ['Visitors', + 'Visitors with kids', + 'Home Alone']}, + 'who_cooks': {'icon': 'mdi:panda', + 'initial': 'Anne Therese', + 'name': 'Cook today', + 'options': ['Paulus', 'Anne Therese']}}})) + # Set up input boolean + tasks.append(bootstrap.async_setup_component( + hass, 'input_boolean', + {'input_boolean': {'notify': { + 'icon': 'mdi:car', + 'initial': False, + 'name': 'Notify Anne Therese is home'}}})) + + # Set up input boolean + tasks.append(bootstrap.async_setup_component( + hass, 'input_slider', + {'input_slider': { + 'noise_allowance': {'icon': 'mdi:bell-ring', + 'min': 0, + 'max': 10, + 'name': 'Allowed Noise', + 'unit_of_measurement': 'dB'}}})) + + # Set up weblink + tasks.append(bootstrap.async_setup_component( + hass, 'weblink', + {'weblink': {'entities': [{'name': 'Router', + 'url': 'http://192.168.1.1'}]}})) + + results = yield from asyncio.gather(*tasks, loop=hass.loop) + + if any(not result for result in results): + return False # Setup example persistent notification - persistent_notification.create( + persistent_notification.async_create( hass, 'This is an example of a persistent notification.', title='Example Notification') # Setup room groups - lights = sorted(hass.states.entity_ids('light')) - switches = sorted(hass.states.entity_ids('switch')) - media_players = sorted(hass.states.entity_ids('media_player')) + lights = sorted(hass.states.async_entity_ids('light')) + switches = sorted(hass.states.async_entity_ids('switch')) + media_players = sorted(hass.states.async_entity_ids('media_player')) - group.Group.create_group(hass, 'living room', [ - lights[1], switches[0], 'input_select.living_room_preset', - 'rollershutter.living_room_window', media_players[1], - 'scene.romantic_lights']) - group.Group.create_group(hass, 'bedroom', [ - lights[0], switches[1], media_players[0], - 'input_slider.noise_allowance']) - group.Group.create_group(hass, 'kitchen', [ - lights[2], 'rollershutter.kitchen_window', 'lock.kitchen_door']) - group.Group.create_group(hass, 'doors', [ - 'lock.front_door', 'lock.kitchen_door', - 'garage_door.right_garage_door', 'garage_door.left_garage_door']) - group.Group.create_group(hass, 'automations', [ - 'input_select.who_cooks', 'input_boolean.notify', ]) - group.Group.create_group(hass, 'people', [ - 'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy', - 'device_tracker.demo_paulus']) - group.Group.create_group(hass, 'downstairs', [ - 'group.living_room', 'group.kitchen', - 'scene.romantic_lights', 'rollershutter.kitchen_window', - 'rollershutter.living_room_window', 'group.doors', - 'thermostat.ecobee', - ], view=True) + tasks2 = [] # Setup scripts - bootstrap.setup_component( + tasks2.append(bootstrap.async_setup_component( hass, 'script', {'script': { 'demo': { @@ -115,10 +138,10 @@ def setup(hass, config): 'service': 'light.turn_off', 'data': {ATTR_ENTITY_ID: lights[0]} }] - }}}) + }}})) # Setup scenes - bootstrap.setup_component( + tasks2.append(bootstrap.async_setup_component( hass, 'scene', {'scene': [ {'name': 'Romantic lights', @@ -132,41 +155,37 @@ def setup(hass, config): switches[0]: True, switches[1]: False, }}, - ]}) + ]})) - # Set up input select - bootstrap.setup_component( - hass, 'input_select', - {'input_select': - {'living_room_preset': {'options': ['Visitors', - 'Visitors with kids', - 'Home Alone']}, - 'who_cooks': {'icon': 'mdi:panda', - 'initial': 'Anne Therese', - 'name': 'Cook today', - 'options': ['Paulus', 'Anne Therese']}}}) - # Set up input boolean - bootstrap.setup_component( - hass, 'input_boolean', - {'input_boolean': {'notify': {'icon': 'mdi:car', - 'initial': False, - 'name': 'Notify Anne Therese is home'}}}) + tasks2.append(group.Group.async_create_group(hass, 'living room', [ + lights[1], switches[0], 'input_select.living_room_preset', + 'rollershutter.living_room_window', media_players[1], + 'scene.romantic_lights'])) + tasks2.append(group.Group.async_create_group(hass, 'bedroom', [ + lights[0], switches[1], media_players[0], + 'input_slider.noise_allowance'])) + tasks2.append(group.Group.async_create_group(hass, 'kitchen', [ + lights[2], 'rollershutter.kitchen_window', 'lock.kitchen_door'])) + tasks2.append(group.Group.async_create_group(hass, 'doors', [ + 'lock.front_door', 'lock.kitchen_door', + 'garage_door.right_garage_door', 'garage_door.left_garage_door'])) + tasks2.append(group.Group.async_create_group(hass, 'automations', [ + 'input_select.who_cooks', 'input_boolean.notify', ])) + tasks2.append(group.Group.async_create_group(hass, 'people', [ + 'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy', + 'device_tracker.demo_paulus'])) + tasks2.append(group.Group.async_create_group(hass, 'downstairs', [ + 'group.living_room', 'group.kitchen', + 'scene.romantic_lights', 'rollershutter.kitchen_window', + 'rollershutter.living_room_window', 'group.doors', + 'thermostat.ecobee', + ], view=True)) - # Set up input boolean - bootstrap.setup_component( - hass, 'input_slider', - {'input_slider': { - 'noise_allowance': {'icon': 'mdi:bell-ring', - 'min': 0, - 'max': 10, - 'name': 'Allowed Noise', - 'unit_of_measurement': 'dB'}}}) + results = yield from asyncio.gather(*tasks2, loop=hass.loop) + + if any(not result for result in results): + return False - # Set up weblink - bootstrap.setup_component( - hass, 'weblink', - {'weblink': {'entities': [{'name': 'Router', - 'url': 'http://192.168.1.1'}]}}) # Setup configurator configurator_ids = [] @@ -184,14 +203,17 @@ def setup(hass, config): else: configurator.request_done(configurator_ids[0]) - request_id = configurator.request_config( - hass, "Philips Hue", hue_configuration_callback, - description=("Press the button on the bridge to register Philips Hue " - "with Home Assistant."), - description_image="/static/images/config_philips_hue.jpg", - submit_caption="I have pressed the button" - ) + def setup_configurator(): + """Setup configurator.""" + request_id = configurator.request_config( + hass, "Philips Hue", hue_configuration_callback, + description=("Press the button on the bridge to register Philips " + "Hue with Home Assistant."), + description_image="/static/images/config_philips_hue.jpg", + submit_caption="I have pressed the button" + ) + configurator_ids.append(request_id) - configurator_ids.append(request_id) + hass.async_add_job(setup_configurator) return True diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 5aa9765d983..c11e25ae130 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -14,12 +14,11 @@ import aiohttp import async_timeout import voluptuous as vol -from homeassistant.bootstrap import ( - async_prepare_setup_platform, async_log_exception) +from homeassistant.bootstrap import async_prepare_setup_platform from homeassistant.core import callback from homeassistant.components import group, zone from homeassistant.components.discovery import SERVICE_NETGEAR -from homeassistant.config import load_yaml_config_file +from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_per_platform, discovery diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 3463cc01bbc..968f666fa72 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -78,7 +78,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup MQTT fan platform.""" - yield from async_add_devices([MqttFan( + async_add_devices([MqttFan( config.get(CONF_NAME), { key: config.get(key) for key in ( diff --git a/homeassistant/components/image_processing/microsoft_face_detect.py b/homeassistant/components/image_processing/microsoft_face_detect.py index 43c5c9dd7f0..bb1a7accd15 100644 --- a/homeassistant/components/image_processing/microsoft_face_detect.py +++ b/homeassistant/components/image_processing/microsoft_face_detect.py @@ -60,7 +60,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): camera[CONF_ENTITY_ID], api, attributes, camera.get(CONF_NAME) )) - yield from async_add_devices(entities) + async_add_devices(entities) class MicrosoftFaceDetectEntity(ImageProcessingFaceEntity): diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py index 97d210d584a..ec4549dfe0c 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -54,7 +54,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): camera.get(CONF_NAME) )) - yield from async_add_devices(entities) + async_add_devices(entities) class ImageProcessingFaceEntity(ImageProcessingEntity): diff --git a/homeassistant/components/image_processing/openalpr_cloud.py b/homeassistant/components/image_processing/openalpr_cloud.py index 7c7d26ce724..7f8bd83116c 100644 --- a/homeassistant/components/image_processing/openalpr_cloud.py +++ b/homeassistant/components/image_processing/openalpr_cloud.py @@ -66,7 +66,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): camera[CONF_ENTITY_ID], params, confidence, camera.get(CONF_NAME) )) - yield from async_add_devices(entities) + async_add_devices(entities) class OpenAlprCloudEntity(ImageProcessingAlprEntity): diff --git a/homeassistant/components/image_processing/openalpr_local.py b/homeassistant/components/image_processing/openalpr_local.py index a9378dd653d..4040efe3bf4 100644 --- a/homeassistant/components/image_processing/openalpr_local.py +++ b/homeassistant/components/image_processing/openalpr_local.py @@ -70,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): camera[CONF_ENTITY_ID], command, confidence, camera.get(CONF_NAME) )) - yield from async_add_devices(entities) + async_add_devices(entities) class ImageProcessingAlprEntity(ImageProcessingEntity): diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 77b804cb499..3110c2091ad 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -70,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.setdefault( CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) - yield from async_add_devices([MqttLight( + async_add_devices([MqttLight( config.get(CONF_NAME), { key: config.get(key) for key in ( diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index abc05198443..b9fb6c54cb4 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -61,7 +61,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a MQTT JSON Light.""" - yield from async_add_devices([MqttJson( + async_add_devices([MqttJson( config.get(CONF_NAME), { key: config.get(key) for key in ( diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index 931b5f68ab3..2f240ec12a6 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -64,7 +64,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup a MQTT Template light.""" - yield from async_add_devices([MqttTemplate( + async_add_devices([MqttTemplate( hass, config.get(CONF_NAME), config.get(CONF_EFFECT_LIST), diff --git a/homeassistant/components/light/rflink.py b/homeassistant/components/light/rflink.py index 82b7b46b1f8..4d49186398a 100644 --- a/homeassistant/components/light/rflink.py +++ b/homeassistant/components/light/rflink.py @@ -117,7 +117,7 @@ def devices_from_config(domain_config, hass=None): @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Rflink light platform.""" - yield from async_add_devices(devices_from_config(config, hass)) + async_add_devices(devices_from_config(config, hass)) # Add new (unconfigured) devices to user desired group if config[CONF_NEW_DEVICES_GROUP]: @@ -136,7 +136,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device_config = config[CONF_DEVICE_DEFAULTS] device = entity_class(device_id, hass, **device_config) - yield from async_add_devices([device]) + async_add_devices([device]) # Register entity to listen to incoming Rflink events hass.data[DATA_ENTITY_LOOKUP][ @@ -156,7 +156,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class RflinkLight(SwitchableRflinkDevice, Light): """Representation of a Rflink light.""" - pass + @property + def entity_id(self): + """Return entity id.""" + return "light.{}".format(self.name) class DimmableRflinkLight(SwitchableRflinkDevice, Light): @@ -164,6 +167,11 @@ class DimmableRflinkLight(SwitchableRflinkDevice, Light): _brightness = 255 + @property + def entity_id(self): + """Return entity id.""" + return "light.{}".format(self.name) + @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the device on.""" @@ -202,6 +210,11 @@ class HybridRflinkLight(SwitchableRflinkDevice, Light): _brightness = 255 + @property + def entity_id(self): + """Return entity id.""" + return "light.{}".format(self.name) + @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the device on and set dim level.""" diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index 00540f66150..43d5788af9b 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -47,7 +47,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass - yield from async_add_devices([MqttLock( + async_add_devices([MqttLock( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py index 01b4b32deb2..e6fd4e286ab 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -63,7 +63,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.debug('dump_rawdata: '+avr.protocol.dump_rawdata) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.avr.close) - yield from async_add_devices([device]) + async_add_devices([device]) class AnthemAVR(MediaPlayerDevice): diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index efab17a61a9..a18bb10e75d 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -85,7 +85,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return False players = yield from lms.create_players() - yield from async_add_devices(players) + async_add_devices(players) return True diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index aea10e3c44d..b5f88eb28a4 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -63,7 +63,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config[CONF_ATTRS] ) - yield from async_add_devices([player]) + async_add_devices([player]) def validate_config(config): diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 7e20338f4ab..1abe6432409 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -18,7 +18,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent DOMAIN = 'scene' -DEPENDENCIES = ['group'] STATE = 'scening' CONF_ENTITIES = "entities" diff --git a/homeassistant/components/scene/homeassistant.py b/homeassistant/components/scene/homeassistant.py index c7365ea65d9..2081dfe89ab 100644 --- a/homeassistant/components/scene/homeassistant.py +++ b/homeassistant/components/scene/homeassistant.py @@ -13,7 +13,6 @@ from homeassistant.const import ( from homeassistant.core import State from homeassistant.helpers.state import async_reproduce_state -DEPENDENCIES = ['group'] STATE = 'scening' CONF_ENTITIES = "entities" @@ -29,7 +28,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if not isinstance(scene_config, list): scene_config = [scene_config] - yield from async_add_devices(HomeAssistantScene( + async_add_devices(HomeAssistantScene( hass, _process_config(scene)) for scene in scene_config) return True diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 1cca7c8d790..cf4843353b5 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -25,7 +25,6 @@ from homeassistant.helpers.script import Script DOMAIN = "script" ENTITY_ID_FORMAT = DOMAIN + '.{}' GROUP_NAME_ALL_SCRIPTS = 'all scripts' -DEPENDENCIES = ["group"] CONF_SEQUENCE = "sequence" @@ -130,6 +129,7 @@ def async_setup(hass, config): schema=SCRIPT_TURN_ONOFF_SCHEMA) hass.services.async_register(DOMAIN, SERVICE_TOGGLE, toggle_service, schema=SCRIPT_TURN_ONOFF_SCHEMA) + return True diff --git a/homeassistant/components/sensor/api_streams.py b/homeassistant/components/sensor/api_streams.py index 15cfc200c4d..e1d6c775196 100644 --- a/homeassistant/components/sensor/api_streams.py +++ b/homeassistant/components/sensor/api_streams.py @@ -61,7 +61,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, remove_logger) - yield from async_add_devices([entity]) + async_add_devices([entity]) class APICount(Entity): diff --git a/homeassistant/components/sensor/dnsip.py b/homeassistant/components/sensor/dnsip.py index 2807dbc2c58..67b2e04d157 100644 --- a/homeassistant/components/sensor/dnsip.py +++ b/homeassistant/components/sensor/dnsip.py @@ -49,7 +49,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): else: resolver = config.get(CONF_RESOLVER) - yield from async_add_devices([WanIpSensor( + async_add_devices([WanIpSensor( hass, hostname, resolver, ipv6)], True) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 729b435edbc..04fe1e97964 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -103,7 +103,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): DerivativeDSMREntity('Hourly Gas Consumption', gas_obis), ] - yield from async_add_devices(devices) + async_add_devices(devices) def update_entities_telegram(telegram): """Update entities with latests telegram & trigger state update.""" diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index 20142c13c3b..1a870114d65 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -34,7 +34,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass.data[DATA_EVL]) devices.append(device) - yield from async_add_devices(devices) + async_add_devices(devices) class EnvisalinkSensor(EnvisalinkDevice, Entity): diff --git a/homeassistant/components/sensor/min_max.py b/homeassistant/components/sensor/min_max.py index c1eb57170f4..d612ca5cf26 100644 --- a/homeassistant/components/sensor/min_max.py +++ b/homeassistant/components/sensor/min_max.py @@ -61,7 +61,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensor_type = config.get(CONF_TYPE) round_digits = config.get(CONF_ROUND_DIGITS) - yield from async_add_devices( + async_add_devices( [MinMaxSensor(hass, entity_ids, name, sensor_type, round_digits)], True) return True diff --git a/homeassistant/components/sensor/moon.py b/homeassistant/components/sensor/moon.py index 2de5b613065..71995533b7b 100644 --- a/homeassistant/components/sensor/moon.py +++ b/homeassistant/components/sensor/moon.py @@ -33,7 +33,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Moon sensor.""" name = config.get(CONF_NAME) - yield from async_add_devices([MoonSensor(name)], True) + async_add_devices([MoonSensor(name)], True) return True diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index a811d4e691c..a5ecd029a88 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -38,7 +38,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass - yield from async_add_devices([MqttSensor( + async_add_devices([MqttSensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_QOS), diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index ad615b5c890..432fff67802 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -59,7 +59,7 @@ MQTT_PAYLOAD = vol.Schema(vol.All(json.loads, vol.Schema({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup MQTT Sensor.""" - yield from async_add_devices([MQTTRoomSensor( + async_add_devices([MQTTRoomSensor( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_DEVICE_ID), diff --git a/homeassistant/components/sensor/random.py b/homeassistant/components/sensor/random.py index a495c4ddb8b..21251ab5f3b 100644 --- a/homeassistant/components/sensor/random.py +++ b/homeassistant/components/sensor/random.py @@ -36,7 +36,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): minimum = config.get(CONF_MINIMUM) maximum = config.get(CONF_MAXIMUM) - yield from async_add_devices([RandomSensor(name, minimum, maximum)], True) + async_add_devices([RandomSensor(name, minimum, maximum)], True) return True diff --git a/homeassistant/components/sensor/rflink.py b/homeassistant/components/sensor/rflink.py index eec21e161c1..575b2daf674 100644 --- a/homeassistant/components/sensor/rflink.py +++ b/homeassistant/components/sensor/rflink.py @@ -74,7 +74,7 @@ def devices_from_config(domain_config, hass=None): @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Rflink platform.""" - yield from async_add_devices(devices_from_config(config, hass)) + async_add_devices(devices_from_config(config, hass)) # Add new (unconfigured) devices to user desired group if config[CONF_NEW_DEVICES_GROUP]: @@ -91,7 +91,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): rflinksensor = partial(RflinkSensor, device_id, hass) device = rflinksensor(event[EVENT_KEY_SENSOR], event[EVENT_KEY_UNIT]) # Add device entity - yield from async_add_devices([device]) + async_add_devices([device]) # Register entity to listen to incoming rflink events hass.data[DATA_ENTITY_LOOKUP][ @@ -122,6 +122,11 @@ class RflinkSensor(RflinkDevice): """Domain specific event handler.""" self._state = event['value'] + @property + def entity_id(self): + """Return entity id.""" + return "sensor.{}".format(self.name) + @property def unit_of_measurement(self): """Return measurement unit.""" diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index ff2df5ef893..342724830e3 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -50,7 +50,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) - yield from async_add_devices( + async_add_devices( [StatisticsSensor(hass, entity_id, name, sampling_size)], True) return True diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index aba42519e60..42481c95510 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -69,7 +69,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No sensors added") return False - yield from async_add_devices(sensors, True) + async_add_devices(sensors, True) return True diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index 04bd8a5aa0f..9182145dc95 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -46,7 +46,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for variable in config[CONF_DISPLAY_OPTIONS]: devices.append(TimeDateSensor(variable)) - yield from async_add_devices(devices, True) + async_add_devices(devices, True) return True diff --git a/homeassistant/components/sensor/worldclock.py b/homeassistant/components/sensor/worldclock.py index bce4895e408..7f1e6429ba5 100644 --- a/homeassistant/components/sensor/worldclock.py +++ b/homeassistant/components/sensor/worldclock.py @@ -35,7 +35,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): name = config.get(CONF_NAME) time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE)) - yield from async_add_devices([WorldClockSensor(time_zone, name)], True) + async_add_devices([WorldClockSensor(time_zone, name)], True) return True diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index f5541f1bef2..047edd0b994 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -78,7 +78,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): dev = [] for sensor_type in config[CONF_MONITORED_CONDITIONS]: dev.append(YrSensor(sensor_type)) - yield from async_add_devices(dev) + async_add_devices(dev) weather = YrData(hass, coordinates, dev) # Update weather on the hour, spread seconds diff --git a/homeassistant/components/switch/hook.py b/homeassistant/components/switch/hook.py index 689ab675b5f..a21d9814768 100644 --- a/homeassistant/components/switch/hook.py +++ b/homeassistant/components/switch/hook.py @@ -74,7 +74,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if response is not None: yield from response.release() - yield from async_add_devices( + async_add_devices( HookSmartHome( hass, token, diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index d0f2524e3de..d94815a1d2e 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -43,7 +43,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if value_template is not None: value_template.hass = hass - yield from async_add_devices([MqttSwitch( + async_add_devices([MqttSwitch( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index cfa11897de9..74add400850 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -72,7 +72,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if req is not None: yield from req.release() - yield from async_add_devices( + async_add_devices( [RestSwitch(hass, name, resource, body_on, body_off, is_on_template, timeout)]) diff --git a/homeassistant/components/switch/rflink.py b/homeassistant/components/switch/rflink.py index 737554154c2..1abeb3eeada 100644 --- a/homeassistant/components/switch/rflink.py +++ b/homeassistant/components/switch/rflink.py @@ -52,7 +52,7 @@ def devices_from_config(domain_config, hass=None): @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Rflink platform.""" - yield from async_add_devices(devices_from_config(config, hass)) + async_add_devices(devices_from_config(config, hass)) class RflinkSwitch(SwitchableRflinkDevice, SwitchDevice): diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index f17d95b21b3..91ac16fe06c 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -70,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No switches added") return False - yield from async_add_devices(switches, True) + async_add_devices(switches, True) return True diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index c18a87710fe..f05fb2a9ae5 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -276,7 +276,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device = hass.data[DATA_ZWAVE_DICT].pop( discovery_info[const.DISCOVERY_DEVICE]) if device: - yield from async_add_devices([device]) + async_add_devices([device]) return True else: return False diff --git a/homeassistant/config.py b/homeassistant/config.py index 852151e83f5..388093ec37a 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -10,23 +10,27 @@ import sys from typing import Any, List, Tuple # NOQA import voluptuous as vol +from voluptuous.humanize import humanize_error from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB) -from homeassistant.core import DOMAIN as CONF_CORE +from homeassistant.core import callback, DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import get_component +from homeassistant.loader import get_component, get_platform from homeassistant.util.yaml import load_yaml import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as date_util, location as loc_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from homeassistant.helpers.entity_values import EntityValues +from homeassistant.helpers import config_per_platform, extract_domain_configs _LOGGER = logging.getLogger(__name__) +DATA_PERSISTENT_ERRORS = 'bootstrap_persistent_errors' +HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)' YAML_CONFIG_FILE = 'configuration.yaml' VERSION_FILE = '.HA_VERSION' CONFIG_DIR_NAME = '.homeassistant' @@ -274,6 +278,35 @@ def process_ha_config_upgrade(hass): outp.write(__version__) +@callback +def async_log_exception(ex, domain, config, hass): + """Generate log exception for config validation. + + This method must be run in the event loop. + """ + message = 'Invalid config for [{}]: '.format(domain) + if hass is not None: + async_notify_setup_error(hass, domain, True) + + if 'extra keys not allowed' in ex.error_message: + message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ + .format(ex.path[-1], domain, domain, + '->'.join(str(m) for m in ex.path)) + else: + message += '{}.'.format(humanize_error(config, ex)) + + domain_config = config.get(domain, config) + message += " (See {}, line {}). ".format( + getattr(domain_config, '__config_file__', '?'), + getattr(domain_config, '__line__', '?')) + + if domain != 'homeassistant': + message += ('Please check the docs at ' + 'https://home-assistant.io/components/{}/'.format(domain)) + + _LOGGER.error(message) + + @asyncio.coroutine def async_process_ha_core_config(hass, config): """Process the [homeassistant] section from the config. @@ -483,6 +516,67 @@ def merge_packages_config(config, packages): return config +@callback +def async_process_component_config(hass, config, domain): + """Check component config and return processed config. + + Raise a vol.Invalid exception on error. + + This method must be run in the event loop. + """ + component = get_component(domain) + + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + except vol.Invalid as ex: + async_log_exception(ex, domain, config, hass) + return None + + elif hasattr(component, 'PLATFORM_SCHEMA'): + platforms = [] + for p_name, p_config in config_per_platform(config, domain): + # Validate component specific platform schema + try: + p_validated = component.PLATFORM_SCHEMA(p_config) + except vol.Invalid as ex: + async_log_exception(ex, domain, config, hass) + continue + + # Not all platform components follow same pattern for platforms + # So if p_name is None we are not going to validate platform + # (the automation component is one of them) + if p_name is None: + platforms.append(p_validated) + continue + + platform = get_platform(domain, p_name) + + if platform is None: + continue + + # Validate platform specific schema + if hasattr(platform, 'PLATFORM_SCHEMA'): + # pylint: disable=no-member + try: + p_validated = platform.PLATFORM_SCHEMA(p_validated) + except vol.Invalid as ex: + async_log_exception(ex, '{}.{}'.format(domain, p_name), + p_validated, hass) + continue + + platforms.append(p_validated) + + # Create a copy of the configuration with all config for current + # component removed and add validated config back in. + filter_keys = extract_domain_configs(config, domain) + config = {key: value for key, value in config.items() + if key not in filter_keys} + config[domain] = platforms + + return config + + @asyncio.coroutine def async_check_ha_config_file(hass): """Check if HA config file valid. @@ -501,3 +595,25 @@ def async_check_ha_config_file(hass): return None return re.sub(r'\033\[[^m]*m', '', str(stdout_data, 'utf-8')) + + +@callback +def async_notify_setup_error(hass, component, link=False): + """Print a persistent notification. + + This method must be run in the event loop. + """ + from homeassistant.components import persistent_notification + + errors = hass.data.get(DATA_PERSISTENT_ERRORS) + + if errors is None: + errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + + errors[component] = errors.get(component) or link + _lst = [HA_COMPONENT_URL.format(name.replace('_', '-'), name) + if link else name for name, link in errors.items()] + message = ('The following components and platforms could not be set up:\n' + '* ' + '\n* '.join(list(_lst)) + '\nPlease check your config') + persistent_notification.async_create( + hass, message, 'Invalid config', 'invalid_config') diff --git a/homeassistant/core.py b/homeassistant/core.py index 29c61842c67..90212e86c3b 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -122,6 +122,7 @@ class HomeAssistant(object): self.loop.set_default_executor(self.executor) self.loop.set_exception_handler(async_loop_exception_handler) self._pending_tasks = [] + self._track_task = False self.bus = EventBus(self) self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) @@ -178,28 +179,7 @@ class HomeAssistant(object): self.loop.call_soon_threadsafe(self.async_add_job, target, *args) @callback - def _async_add_job(self, target: Callable[..., None], *args: Any) -> None: - """Add a job from within the eventloop. - - This method must be run in the event loop. - - target: target to call. - args: parameters for method to call. - """ - if asyncio.iscoroutine(target): - self.loop.create_task(target) - elif is_callback(target): - self.loop.call_soon(target, *args) - elif asyncio.iscoroutinefunction(target): - self.loop.create_task(target(*args)) - else: - self.loop.run_in_executor(None, target, *args) - - async_add_job = _async_add_job - - @callback - def _async_add_job_tracking(self, target: Callable[..., None], - *args: Any) -> None: + def async_add_job(self, target: Callable[..., None], *args: Any) -> None: """Add a job from within the eventloop. This method must be run in the event loop. @@ -219,19 +199,21 @@ class HomeAssistant(object): task = self.loop.run_in_executor(None, target, *args) # if a task is sheduled - if task is not None: + if self._track_task and task is not None: self._pending_tasks.append(task) + return task + @callback def async_track_tasks(self): """Track tasks so you can wait for all tasks to be done.""" - self.async_add_job = self._async_add_job_tracking + self._track_task = True @asyncio.coroutine def async_stop_track_tasks(self): """Track tasks so you can wait for all tasks to be done.""" yield from self.async_block_till_done() - self.async_add_job = self._async_add_job + self._track_task = False @callback def async_run_job(self, target: Callable[..., None], *args: Any) -> None: diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index db17b8926c1..5615f3a3199 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -63,20 +63,8 @@ def async_discover(hass, service, discovered=None, component=None, 'Cannot discover the {} component.'.format(component)) if component is not None and component not in hass.config.components: - did_lock = False - setup_lock = hass.data.get('setup_lock') - if setup_lock and setup_lock.locked(): - did_lock = True - yield from setup_lock.acquire() - - try: - # Could have been loaded while waiting for lock. - if component not in hass.config.components: - yield from bootstrap.async_setup_component(hass, component, - hass_config) - finally: - if did_lock: - setup_lock.release() + yield from bootstrap.async_setup_component( + hass, component, hass_config) data = { ATTR_SERVICE: service @@ -160,22 +148,11 @@ def async_load_platform(hass, component, platform, discovered=None, raise HomeAssistantError( 'Cannot discover the {} component.'.format(component)) - did_lock = False - setup_lock = hass.data.get('setup_lock') - if setup_lock and setup_lock.locked(): - did_lock = True - yield from setup_lock.acquire() - setup_success = True - try: - # Could have been loaded while waiting for lock. - if component not in hass.config.components: - setup_success = yield from bootstrap.async_setup_component( - hass, component, hass_config) - finally: - if did_lock: - setup_lock.release() + if component not in hass.config.components: + setup_success = yield from bootstrap.async_setup_component( + hass, component, hass_config) # No need to fire event if we could not setup component if not setup_success: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ad88045039f..1b20695b349 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -3,8 +3,7 @@ import asyncio from datetime import timedelta from homeassistant import config as conf_util -from homeassistant.bootstrap import ( - async_prepare_setup_platform, async_prepare_setup_component) +from homeassistant.bootstrap import async_prepare_setup_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, DEVICE_DEFAULT_NAME) @@ -49,12 +48,9 @@ class EntityComponent(object): def setup(self, config): """Set up a full entity component. - Loads the platforms from the config and will listen for supported - discovered platforms. + This doesn't block the executor to protect from deadlocks. """ - run_coroutine_threadsafe( - self.async_setup(config), self.hass.loop - ).result() + self.hass.add_job(self.async_setup(config)) @asyncio.coroutine def async_setup(self, config): @@ -143,14 +139,16 @@ class EntityComponent(object): if getattr(platform, 'async_setup_platform', None): yield from platform.async_setup_platform( self.hass, platform_config, - entity_platform.async_add_entities, discovery_info + entity_platform.async_schedule_add_entities, discovery_info ) else: yield from self.hass.loop.run_in_executor( None, platform.setup_platform, self.hass, platform_config, - entity_platform.add_entities, discovery_info + entity_platform.schedule_add_entities, discovery_info ) + yield from entity_platform.async_block_entities_done() + self.hass.config.components.add( '{}.{}'.format(self.domain, platform_type)) except Exception: # pylint: disable=broad-except @@ -275,7 +273,7 @@ class EntityComponent(object): self.logger.error(err) return None - conf = yield from async_prepare_setup_component( + conf = conf_util.async_process_component_config( self.hass, conf, self.domain) if conf is None: @@ -295,9 +293,40 @@ class EntityPlatform(object): self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.platform_entities = [] + self._tasks = [] self._async_unsub_polling = None self._process_updates = asyncio.Lock(loop=component.hass.loop) + @asyncio.coroutine + def async_block_entities_done(self): + """Wait until all entities add to hass.""" + if self._tasks: + pending = [task for task in self._tasks if not task.done()] + self._tasks.clear() + + if pending: + yield from asyncio.wait(pending, loop=self.component.hass.loop) + + def schedule_add_entities(self, new_entities, update_before_add=False): + """Add entities for a single platform.""" + if update_before_add: + for entity in new_entities: + entity.update() + + run_callback_threadsafe( + self.component.hass.loop, + self.async_schedule_add_entities, list(new_entities), False + ).result() + + @callback + def async_schedule_add_entities(self, new_entities, + update_before_add=False): + """Add entities for a single platform async.""" + self._tasks.append(self.component.hass.async_add_job( + self.async_add_entities( + new_entities, update_before_add=update_before_add) + )) + def add_entities(self, new_entities, update_before_add=False): """Add entities for a single platform.""" if update_before_add: @@ -306,8 +335,7 @@ class EntityPlatform(object): run_coroutine_threadsafe( self.async_add_entities(list(new_entities), False), - self.component.hass.loop - ).result() + self.component.hass.loop).result() @asyncio.coroutine def async_add_entities(self, new_entities, update_before_add=False): @@ -319,8 +347,16 @@ class EntityPlatform(object): if not new_entities: return - tasks = [self._async_process_entity(entity, update_before_add) - for entity in new_entities] + @asyncio.coroutine + def async_process_entity(new_entity): + """Add entities to StateMachine.""" + ret = yield from self.component.async_add_entity( + new_entity, self, update_before_add=update_before_add + ) + if ret: + self.platform_entities.append(new_entity) + + tasks = [async_process_entity(entity) for entity in new_entities] yield from asyncio.wait(tasks, loop=self.component.hass.loop) yield from self.component.async_update_group() @@ -334,15 +370,6 @@ class EntityPlatform(object): self.component.hass, self._update_entity_states, self.scan_interval ) - @asyncio.coroutine - def _async_process_entity(self, new_entity, update_before_add): - """Add entities to StateMachine.""" - ret = yield from self.component.async_add_entity( - new_entity, self, update_before_add=update_before_add - ) - if ret: - self.platform_entities.append(new_entity) - @asyncio.coroutine def async_reset(self): """Remove all entities and reset data. diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 60ba924f46c..a24f89c0e3f 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -170,41 +170,6 @@ def get_component(comp_name) -> Optional[ModuleType]: return None -def load_order_components(components: Sequence[str]) -> OrderedSet: - """Take in a list of components we want to load. - - - filters out components we cannot load - - filters out components that have invalid/circular dependencies - - Will make sure the recorder component is loaded first - - Will ensure that all components that do not directly depend on - the group component will be loaded before the group component. - - returns an OrderedSet load order. - - Makes sure MQTT eventstream is available for publish before - components start updating states. - - Async friendly. - """ - _check_prepared() - - load_order = OrderedSet() - - # Sort the list of modules on if they depend on group component or not. - # Components that do not depend on the group usually set up states. - # Components that depend on group usually use states in their setup. - for comp_load_order in sorted((load_order_component(component) - for component in components), - key=lambda order: 'group' in order): - load_order.update(comp_load_order) - - # Push some to first place in load order - for comp in ('mqtt_eventstream', 'mqtt', 'recorder', - 'introduction', 'logger'): - if comp in load_order: - load_order.promote(comp) - - return load_order - - def load_order_component(comp_name: str) -> OrderedSet: """Return an OrderedSet of components in the correct order of loading. diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 154754c667a..38138c87883 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -26,8 +26,8 @@ MOCKS = { 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml), 'get': ("homeassistant.loader.get_component", loader.get_component), 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml), - 'except': ("homeassistant.bootstrap.async_log_exception", - bootstrap.async_log_exception), + 'except': ("homeassistant.config.async_log_exception", + config_util.async_log_exception), 'package_error': ("homeassistant.config._log_pkg_error", config_util._log_pkg_error), } @@ -211,7 +211,7 @@ def check(config_path): def mock_except(ex, domain, config, # pylint: disable=unused-variable hass=None): - """Mock bootstrap.log_exception.""" + """Mock config.log_exception.""" MOCKS['except'][1](ex, domain, config, hass) res['except'][domain] = config.get(domain, config) diff --git a/tests/common.py b/tests/common.py index 55d6896d410..a1635e3387c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -12,8 +12,8 @@ from contextlib import contextmanager from aiohttp import web from homeassistant import core as ha, loader -from homeassistant.bootstrap import ( - setup_component, async_prepare_setup_component) +from homeassistant.bootstrap import setup_component, DATA_SETUP +from homeassistant.config import async_process_component_config from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE @@ -93,13 +93,16 @@ def async_test_home_assistant(loop): hass = ha.HomeAssistant(loop) + orig_async_add_job = hass.async_add_job + def async_add_job(target, *args): """Add a magic mock.""" if isinstance(target, MagicMock): return - hass._async_add_job_tracking(target, *args) + return orig_async_add_job(target, *args) hass.async_add_job = async_add_job + hass.async_track_tasks() hass.config.location_name = 'test home' hass.config.config_dir = get_test_config_dir() @@ -230,7 +233,7 @@ def mock_state_change_event(hass, new_state, old_state=None): def mock_http_component(hass, api_password=None): """Mock the HTTP component.""" hass.http = MagicMock(api_password=api_password) - hass.config.components.add('http') + mock_component(hass, 'http') hass.http.views = {} def mock_register_view(view): @@ -268,6 +271,19 @@ def mock_mqtt_component(hass): return mock_mqtt +def mock_component(hass, component): + """Mock a component is setup.""" + setup_tasks = hass.data.get(DATA_SETUP) + if setup_tasks is None: + setup_tasks = hass.data[DATA_SETUP] = {} + + if component not in setup_tasks: + AssertionError("Component {} is already setup".format(component)) + + hass.config.components.add(component) + setup_tasks[component] = asyncio.Task(mock_coro(True), loop=hass.loop) + + class MockModule(object): """Representation of a fake module.""" @@ -439,10 +455,10 @@ def assert_setup_component(count, domain=None): """ config = {} - @asyncio.coroutine + @ha.callback def mock_psc(hass, config_input, domain): """Mock the prepare_setup_component to capture config.""" - res = yield from async_prepare_setup_component( + res = async_process_component_config( hass, config_input, domain) config[domain] = None if res is None else res.get(domain) _LOGGER.debug('Configuration for %s, Validated: %s, Original %s', @@ -450,7 +466,7 @@ def assert_setup_component(count, domain=None): return res assert isinstance(config, dict) - with patch('homeassistant.bootstrap.async_prepare_setup_component', + with patch('homeassistant.config.async_process_component_config', mock_psc): yield config diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index f1bbb711848..2fe9e05d9d5 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -30,7 +30,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_fail_setup_without_state_topic(self): """Test for failing with no state topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(0) as config: assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { @@ -42,7 +41,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_fail_setup_without_command_topic(self): """Test failing with no command topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(0): assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { @@ -53,7 +51,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_update_state_via_state_topic(self): """Test updating with via state topic.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -77,7 +74,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_ignore_update_state_if_unknown_via_state_topic(self): """Test ignoring updates via state topic.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -98,7 +94,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_arm_home_publishes_mqtt(self): """Test publishing of MQTT messages while armed.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -115,7 +110,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_arm_home_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -133,7 +127,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_arm_away_publishes_mqtt(self): """Test publishing of MQTT messages while armed.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -150,7 +143,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_arm_away_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -168,7 +160,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_disarm_publishes_mqtt(self): """Test publishing of MQTT messages while disarmed.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', @@ -185,7 +176,6 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): def test_disarm_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index 18e112fc498..c032c72446a 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -5,7 +5,7 @@ from homeassistant.core import callback from homeassistant.bootstrap import setup_component import homeassistant.components.automation as automation -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component # pylint: disable=invalid-name @@ -15,7 +15,7 @@ class TestAutomationEvent(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') self.calls = [] @callback diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index ca8eef4fc0d..fa7658f3407 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant, assert_setup_component, \ - fire_time_changed + fire_time_changed, mock_component # pylint: disable=invalid-name @@ -21,7 +21,7 @@ class TestAutomation(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') self.calls = [] @callback diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index a2746728fe3..df8baced090 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -5,7 +5,8 @@ from homeassistant.core import callback from homeassistant.bootstrap import setup_component import homeassistant.components.automation as automation from tests.common import ( - mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) + mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, + mock_component) # pylint: disable=invalid-name @@ -15,7 +16,7 @@ class TestAutomationMQTT(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') mock_mqtt_component(self.hass) self.calls = [] diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 85842ccf5eb..8862303da5f 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -5,7 +5,7 @@ from homeassistant.core import callback from homeassistant.bootstrap import setup_component import homeassistant.components.automation as automation -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component # pylint: disable=invalid-name @@ -15,7 +15,7 @@ class TestAutomationNumericState(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') self.calls = [] @callback diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 00048f1f577..f375aec4666 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -10,7 +10,8 @@ import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation from tests.common import ( - fire_time_changed, get_test_home_assistant, assert_setup_component) + fire_time_changed, get_test_home_assistant, assert_setup_component, + mock_component) # pylint: disable=invalid-name @@ -20,7 +21,7 @@ class TestAutomationState(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') self.hass.states.set('test.entity', 'hello') self.calls = [] diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index bad592a740a..47bbf6b680c 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -10,7 +10,8 @@ from homeassistant.components import sun import homeassistant.components.automation as automation import homeassistant.util.dt as dt_util -from tests.common import fire_time_changed, get_test_home_assistant +from tests.common import ( + fire_time_changed, get_test_home_assistant, mock_component) # pylint: disable=invalid-name @@ -20,8 +21,8 @@ class TestAutomationSun(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') - self.hass.config.components.add('sun') + mock_component(self.hass, 'group') + mock_component(self.hass, 'sun') self.calls = [] diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index 1971fb26d31..8bdf9f8f439 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -5,7 +5,8 @@ from homeassistant.core import callback from homeassistant.bootstrap import setup_component import homeassistant.components.automation as automation -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) # pylint: disable=invalid-name @@ -15,7 +16,7 @@ class TestAutomationTemplate(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') self.hass.states.set('test.entity', 'hello') self.calls = [] diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 8f323dd4b37..6a76bb887b8 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -9,7 +9,8 @@ import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation from tests.common import ( - fire_time_changed, get_test_home_assistant, assert_setup_component) + fire_time_changed, get_test_home_assistant, assert_setup_component, + mock_component) # pylint: disable=invalid-name @@ -19,7 +20,7 @@ class TestAutomationTime(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') self.calls = [] @callback diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index f2b304070b4..ea216b12a26 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -5,7 +5,7 @@ from homeassistant.core import callback from homeassistant.bootstrap import setup_component from homeassistant.components import automation, zone -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component # pylint: disable=invalid-name @@ -15,7 +15,7 @@ class TestAutomationZone(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') assert setup_component(self.hass, zone.DOMAIN, { 'zone': { 'name': 'test', diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index f9630ae4b25..1b756f72f61 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -3,10 +3,10 @@ import unittest from homeassistant.bootstrap import setup_component import homeassistant.components.binary_sensor as binary_sensor -from tests.common import mock_mqtt_component, fire_mqtt_message from homeassistant.const import (STATE_OFF, STATE_ON) -from tests.common import get_test_home_assistant +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) class TestSensorMQTT(unittest.TestCase): @@ -23,7 +23,6 @@ class TestSensorMQTT(unittest.TestCase): def test_setting_sensor_value_via_mqtt_message(self): """Test the setting of the value via MQTT.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, binary_sensor.DOMAIN, { binary_sensor.DOMAIN: { 'platform': 'mqtt', @@ -49,7 +48,6 @@ class TestSensorMQTT(unittest.TestCase): def test_valid_device_class(self): """Test the setting of a valid sensor class.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, binary_sensor.DOMAIN, { binary_sensor.DOMAIN: { 'platform': 'mqtt', @@ -64,7 +62,6 @@ class TestSensorMQTT(unittest.TestCase): def test_invalid_device_class(self): """Test the setting of an invalid sensor class.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, binary_sensor.DOMAIN, { binary_sensor.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index c5b8b6a9f78..cd11321baa4 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -9,7 +9,7 @@ from uvcclient import nvr from homeassistant.bootstrap import setup_component from homeassistant.components.camera import uvc -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_http_component class TestUVCSetup(unittest.TestCase): @@ -18,8 +18,7 @@ class TestUVCSetup(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.http = mock.MagicMock() - self.hass.config.components = set(['http']) + mock_http_component(self.hass) def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 5fad8e16aed..846ecdc320f 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -1,10 +1,11 @@ """The tests for the generic_thermostat.""" +import asyncio import datetime import unittest from unittest import mock from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.bootstrap import setup_component, async_setup_component from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, SERVICE_TURN_OFF, @@ -105,23 +106,6 @@ class TestClimateGenericThermostat(unittest.TestCase): self.assertEqual(35, state.attributes.get('max_temp')) self.assertEqual(None, state.attributes.get('temperature')) - def test_custom_setup_params(self): - """Test the setup with custom parameters.""" - self.hass.config.components.remove(climate.DOMAIN) - assert setup_component(self.hass, climate.DOMAIN, {'climate': { - 'platform': 'generic_thermostat', - 'name': 'test', - 'heater': ENT_SWITCH, - 'target_sensor': ENT_SENSOR, - 'min_temp': MIN_TEMP, - 'max_temp': MAX_TEMP, - 'target_temp': TARGET_TEMP, - }}) - state = self.hass.states.get(ENTITY) - self.assertEqual(MIN_TEMP, state.attributes.get('min_temp')) - self.assertEqual(MAX_TEMP, state.attributes.get('max_temp')) - self.assertEqual(TARGET_TEMP, state.attributes.get('temperature')) - def test_set_target_temp(self): """Test the setting of the target temperature.""" climate.set_temperature(self.hass, 30) @@ -538,3 +522,23 @@ class TestClimateGenericThermostatMinCycle(unittest.TestCase): self.hass.services.register('switch', SERVICE_TURN_ON, log_call) self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +@asyncio.coroutine +def test_custom_setup_params(hass): + """Test the setup with custom parameters.""" + result = yield from async_setup_component( + hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_temp': MIN_TEMP, + 'max_temp': MAX_TEMP, + 'target_temp': TARGET_TEMP, + }}) + assert result + state = hass.states.get(ENTITY) + assert state.attributes.get('min_temp') == MIN_TEMP + assert state.attributes.get('max_temp') == MAX_TEMP + assert state.attributes.get('temperature') == TARGET_TEMP diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index fa5629e88c4..b9c2a1739c5 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -15,6 +15,9 @@ def test_validate_config_ok(hass, test_client): with patch.object(config, 'SECTIONS', ['core']): yield from async_setup_component(hass, 'config', {}) + # yield from hass.async_block_till_done() + yield from asyncio.sleep(0.1, loop=hass.loop) + hass.http.views[CheckConfigView.name].register(app.router) client = yield from test_client(app) diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 07baec9e3ae..1c37683969b 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -8,7 +8,7 @@ from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.bootstrap import async_setup_component, ATTR_COMPONENT from homeassistant.components import config -from tests.common import mock_http_component, mock_coro +from tests.common import mock_http_component, mock_coro, mock_component @pytest.fixture(autouse=True) @@ -27,7 +27,7 @@ def test_config_setup(hass, loop): @asyncio.coroutine def test_load_on_demand_already_loaded(hass, test_client): """Test getting suites.""" - hass.config.components.add('zwave') + mock_component(hass, 'zwave') with patch.object(config, 'SECTIONS', []), \ patch.object(config, 'ON_DEMAND', ['zwave']), \ diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 81518458e0e..1d670d81b6e 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -4,9 +4,9 @@ import unittest from homeassistant.bootstrap import setup_component from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN import homeassistant.components.cover as cover -from tests.common import mock_mqtt_component, fire_mqtt_message -from tests.common import get_test_home_assistant +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) class TestCoverMQTT(unittest.TestCase): @@ -23,7 +23,6 @@ class TestCoverMQTT(unittest.TestCase): def test_state_via_state_topic(self): """Test the controlling state via topic.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -72,7 +71,6 @@ class TestCoverMQTT(unittest.TestCase): def test_state_via_template(self): """Test the controlling state via topic.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -101,7 +99,6 @@ class TestCoverMQTT(unittest.TestCase): def test_optimistic_state_change(self): """Test changing state optimistically.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -132,7 +129,6 @@ class TestCoverMQTT(unittest.TestCase): def test_send_open_cover_command(self): """Test the sending of open_cover.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -156,7 +152,6 @@ class TestCoverMQTT(unittest.TestCase): def test_send_close_cover_command(self): """Test the sending of close_cover.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -180,7 +175,6 @@ class TestCoverMQTT(unittest.TestCase): def test_send_stop__cover_command(self): """Test the sending of stop_cover.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', @@ -204,7 +198,6 @@ class TestCoverMQTT(unittest.TestCase): def test_current_cover_position(self): """Test the current cover position.""" - self.hass.config.components = set(['mqtt']) self.assertTrue(setup_component(self.hass, cover.DOMAIN, { cover.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/cover/test_rfxtrx.py b/tests/components/cover/test_rfxtrx.py index 18e2051afd6..2d11e03cb41 100644 --- a/tests/components/cover/test_rfxtrx.py +++ b/tests/components/cover/test_rfxtrx.py @@ -6,7 +6,7 @@ import pytest from homeassistant.bootstrap import setup_component from homeassistant.components import rfxtrx as rfxtrx_core -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component @pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'") @@ -16,7 +16,7 @@ class TestCoverRfxtrx(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['rfxtrx']) + mock_component('rfxtrx') def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 48160cbb3d5..406087b7b99 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -17,7 +17,8 @@ from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) from tests.common import ( - get_test_home_assistant, get_test_config_dir, assert_setup_component) + get_test_home_assistant, get_test_config_dir, assert_setup_component, + mock_component) FAKEFILE = None @@ -43,7 +44,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): def setup_method(self, _): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['zone']) + mock_component(self.hass, 'zone') def teardown_method(self, _): """Stop everything that was started.""" diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py index 340bac254b1..a0433b04d01 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -16,7 +16,8 @@ from homeassistant.components.device_tracker import DOMAIN from homeassistant.util import slugify from tests.common import ( - get_test_home_assistant, assert_setup_component, load_fixture) + get_test_home_assistant, assert_setup_component, load_fixture, + mock_component) from ...test_util.aiohttp import mock_aiohttp_client @@ -39,7 +40,7 @@ class TestDdwrt(unittest.TestCase): def setup_method(self, _): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['zone']) + mock_component(self.hass, 'zone') def teardown_method(self, _): """Stop everything that was started.""" diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 3ce3a358b87..583b9b86383 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -43,7 +43,6 @@ class TestComponentsDeviceTrackerMQTT(unittest.TestCase): dev_id = 'paulus' topic = '/location/paulus' - self.hass.config.components = set(['mqtt', 'zone']) assert setup_component(self.hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt', diff --git a/tests/components/device_tracker/test_upc_connect.py b/tests/components/device_tracker/test_upc_connect.py index 010c597cc31..7a1e14a7dfc 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -13,7 +13,8 @@ import homeassistant.components.device_tracker.upc_connect as platform from homeassistant.util.async import run_coroutine_threadsafe from tests.common import ( - get_test_home_assistant, assert_setup_component, load_fixture) + get_test_home_assistant, assert_setup_component, load_fixture, + mock_component) _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,7 @@ class TestUPCConnect(object): def setup_method(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['zone']) + mock_component(self.hass, 'zone') self.host = "127.0.0.1" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5fa37012c7a..36f434664d7 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,7 +1,6 @@ """The tests for the Home Assistant HTTP component.""" import asyncio import requests -from unittest.mock import MagicMock from homeassistant import bootstrap, const import homeassistant.components.http as http @@ -157,46 +156,48 @@ def test_registering_view_while_running(hass, test_client): assert text == 'hello' -def test_api_base_url(loop): +@asyncio.coroutine +def test_api_base_url_with_domain(hass): """Test setting api url.""" - hass = MagicMock() - hass.loop = loop - - assert loop.run_until_complete( - bootstrap.async_setup_component(hass, 'http', { - 'http': { - 'base_url': 'example.com' - } - }) - ) - + result = yield from bootstrap.async_setup_component(hass, 'http', { + 'http': { + 'base_url': 'example.com' + } + }) + assert result assert hass.config.api.base_url == 'http://example.com' - assert loop.run_until_complete( - bootstrap.async_setup_component(hass, 'http', { - 'http': { - 'server_host': '1.1.1.1' - } - }) - ) +@asyncio.coroutine +def test_api_base_url_with_ip(hass): + """Test setting api url.""" + result = yield from bootstrap.async_setup_component(hass, 'http', { + 'http': { + 'server_host': '1.1.1.1' + } + }) + assert result assert hass.config.api.base_url == 'http://1.1.1.1:8123' - assert loop.run_until_complete( - bootstrap.async_setup_component(hass, 'http', { - 'http': { - 'server_host': '1.1.1.1' - } - }) - ) - assert hass.config.api.base_url == 'http://1.1.1.1:8123' +@asyncio.coroutine +def test_api_base_url_with_ip_port(hass): + """Test setting api url.""" + result = yield from bootstrap.async_setup_component(hass, 'http', { + 'http': { + 'base_url': '1.1.1.1:8124' + } + }) + assert result + assert hass.config.api.base_url == 'http://1.1.1.1:8124' - assert loop.run_until_complete( - bootstrap.async_setup_component(hass, 'http', { - 'http': { - } - }) - ) +@asyncio.coroutine +def test_api_no_base_url(hass): + """Test setting api url.""" + result = yield from bootstrap.async_setup_component(hass, 'http', { + 'http': { + } + }) + assert result assert hass.config.api.base_url == 'http://127.0.0.1:8123' diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index f8b46579187..391a6d05903 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -8,7 +8,7 @@ from homeassistant.bootstrap import setup_component, async_setup_component import homeassistant.components.light as light from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component ENTITY_LIGHT = 'light.bed_light' @@ -68,7 +68,7 @@ class TestDemoLight(unittest.TestCase): @asyncio.coroutine def test_restore_state(hass): """Test state gets restored.""" - hass.config.components.add('recorder') + mock_component(hass, 'recorder') hass.state = CoreState.starting hass.data[DATA_RESTORE_CACHE] = { 'light.bed_light': State('light.bed_light', 'on', { diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 4f0d4a273b6..410f947178c 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -100,7 +100,6 @@ class TestLightMQTT(unittest.TestCase): def test_fail_setup_if_no_command_topic(self): """Test if command fails with command topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(0): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -113,7 +112,6 @@ class TestLightMQTT(unittest.TestCase): def test_no_color_or_brightness_or_color_temp_if_no_topics(self): \ # pylint: disable=invalid-name """Test if there is no color and brightness if no topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -158,7 +156,6 @@ class TestLightMQTT(unittest.TestCase): 'payload_off': 0 }} - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, config) @@ -214,7 +211,6 @@ class TestLightMQTT(unittest.TestCase): def test_controlling_scale(self): """Test the controlling scale.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -277,7 +273,6 @@ class TestLightMQTT(unittest.TestCase): 'rgb_value_template': '{{ value_json.hello | join(",") }}', }} - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, config) @@ -317,7 +312,6 @@ class TestLightMQTT(unittest.TestCase): 'payload_off': 'off' }} - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, config) @@ -367,7 +361,6 @@ class TestLightMQTT(unittest.TestCase): 'state_topic': 'test_light_rgb/status', }} - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, config) @@ -392,7 +385,6 @@ class TestLightMQTT(unittest.TestCase): 'state_topic': 'test_light_rgb/status' }} - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, config) diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 4f48181a917..55c437cdc79 100755 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -53,7 +53,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_fail_setup_if_no_command_topic(self): \ # pylint: disable=invalid-name """Test if setup fails with no command topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(0): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -66,7 +65,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_no_color_or_brightness_if_no_config(self): \ # pylint: disable=invalid-name """Test if there is no color and brightness if they aren't defined.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { 'platform': 'mqtt_json', @@ -92,7 +90,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_controlling_state_via_topic(self): \ # pylint: disable=invalid-name """Test the controlling of the state via topic.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { 'platform': 'mqtt_json', @@ -152,7 +149,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_sending_mqtt_commands_and_optimistic(self): \ # pylint: disable=invalid-name """Test the sending of command in optimistic mode.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { 'platform': 'mqtt_json', @@ -208,7 +204,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_flash_short_and_long(self): \ # pylint: disable=invalid-name """Test for flash length being sent when included.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { 'platform': 'mqtt_json', @@ -250,7 +245,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_transition(self): """Test for transition time being sent when included.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { 'platform': 'mqtt_json', @@ -292,7 +286,6 @@ class TestLightMQTTJSON(unittest.TestCase): def test_invalid_color_and_brightness_values(self): \ # pylint: disable=invalid-name """Test that invalid color/brightness values are ignored.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { 'platform': 'mqtt_json', diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index e097aba92a9..020ded1bd80 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -45,7 +45,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_setup_fails(self): \ # pylint: disable=invalid-name """Test that setup fails with missing required configuration items.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(0): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -58,7 +57,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_state_change_via_topic(self): \ # pylint: disable=invalid-name """Test state change via topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -93,7 +91,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_state_brightness_color_effect_change_via_topic(self): \ # pylint: disable=invalid-name """Test state, brightness, color and effect change via topic.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -170,7 +167,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_optimistic(self): \ # pylint: disable=invalid-name """Test optimistic mode.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -232,7 +228,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_flash(self): \ # pylint: disable=invalid-name """Test flash.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -276,7 +271,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_transition(self): """Test for transition time being sent when included.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -320,7 +314,6 @@ class TestLightMQTTTemplate(unittest.TestCase): def test_invalid_values(self): \ # pylint: disable=invalid-name """Test that invalid values are ignored.""" - self.hass.config.components = set(['mqtt']) with assert_setup_component(1): assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { diff --git a/tests/components/light/test_rfxtrx.py b/tests/components/light/test_rfxtrx.py index ca50a9cc925..135e51380cd 100644 --- a/tests/components/light/test_rfxtrx.py +++ b/tests/components/light/test_rfxtrx.py @@ -6,7 +6,7 @@ import pytest from homeassistant.bootstrap import setup_component from homeassistant.components import rfxtrx as rfxtrx_core -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component @pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'") @@ -16,7 +16,7 @@ class TestLightRfxtrx(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['rfxtrx']) + mock_component(self.hass, 'rfxtrx') def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index c858d58dfa7..14714e9a3d1 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -23,7 +23,6 @@ class TestLockMQTT(unittest.TestCase): def test_controlling_state_via_topic(self): """Test the controlling state via topic.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, lock.DOMAIN, { lock.DOMAIN: { 'platform': 'mqtt', @@ -53,7 +52,6 @@ class TestLockMQTT(unittest.TestCase): def test_sending_mqtt_commands_and_optimistic(self): """Test the sending MQTT commands in optimistic mode.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, lock.DOMAIN, { lock.DOMAIN: { 'platform': 'mqtt', @@ -87,7 +85,6 @@ class TestLockMQTT(unittest.TestCase): def test_controlling_state_via_topic_and_json_message(self): """Test the controlling state via topic and JSON message.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, lock.DOMAIN, { lock.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index 1ca0846b1fd..3ccfcd7eb64 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -1,5 +1,4 @@ """The tests for the Universal Media player platform.""" -import asyncio from copy import copy import unittest @@ -258,7 +257,6 @@ class TestMediaPlayer(unittest.TestCase): bad_config = {'platform': 'universal'} entities = [] - @asyncio.coroutine def add_devices(new_entities): """Add devices to list.""" for dev in new_entities: diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index cfef8ebcc16..db9e963d84c 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -4,7 +4,8 @@ from unittest.mock import Mock, MagicMock, patch from homeassistant.bootstrap import setup_component import homeassistant.components.mqtt as mqtt -from tests.common import get_test_home_assistant, mock_coro +from tests.common import ( + get_test_home_assistant, mock_coro, mock_http_component) class TestMQTT: @@ -13,7 +14,7 @@ class TestMQTT: def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('http') + mock_http_component(self.hass, 'super_secret') def teardown_method(self, method): """Stop everything that was started.""" @@ -33,13 +34,21 @@ class TestMQTT: self.hass.config.api = MagicMock(api_password=password) assert setup_component(self.hass, mqtt.DOMAIN, {}) assert mock_mqtt.called + from pprint import pprint + pprint(mock_mqtt.mock_calls) assert mock_mqtt.mock_calls[1][1][5] == 'homeassistant' assert mock_mqtt.mock_calls[1][1][6] == password - mock_mqtt.reset_mock() + @patch('passlib.apps.custom_app_context', Mock(return_value='')) + @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) + @patch('homeassistant.components.mqtt.MQTT') + def test_creating_config_with_http_no_pass(self, mock_mqtt): + """Test if the MQTT server gets started and subscribe/publish msg.""" mock_mqtt().async_connect.return_value = mock_coro(True) + self.hass.bus.listen_once = MagicMock() - self.hass.config.components = set(['http']) self.hass.config.api = MagicMock(api_password=None) assert setup_component(self.hass, mqtt.DOMAIN, {}) assert mock_mqtt.called diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index de13f678ae0..43c5e78c5da 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -1,5 +1,4 @@ """The tests for the notify demo platform.""" -import asyncio import unittest from unittest.mock import patch @@ -17,12 +16,6 @@ CONFIG = { } -@asyncio.coroutine -def mock_setup_platform(): - """Mock prepare_setup_platform.""" - return None - - class TestNotifyDemo(unittest.TestCase): """Test the demo notify.""" @@ -52,15 +45,6 @@ class TestNotifyDemo(unittest.TestCase): """Test setup.""" self._setup_notify() - @patch('homeassistant.bootstrap.async_prepare_setup_platform', - return_value=mock_setup_platform()) - def test_no_prepare_setup_platform(self, mock_prep_setup_platform): - """Test missing notify platform.""" - with assert_setup_component(0): - setup_component(self.hass, notify.DOMAIN, CONFIG) - - assert mock_prep_setup_platform.called - @patch('homeassistant.components.notify.demo.get_service', autospec=True) def test_no_notify_service(self, mock_demo_get_service): """Test missing platform notify service instance.""" diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 771aa999210..1de9d2f731a 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -5,7 +5,7 @@ from homeassistant.bootstrap import setup_component import homeassistant.components.sensor as sensor from tests.common import mock_mqtt_component, fire_mqtt_message -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component class TestSensorMQTT(unittest.TestCase): @@ -22,7 +22,7 @@ class TestSensorMQTT(unittest.TestCase): def test_setting_sensor_value_via_mqtt_message(self): """Test the setting of the value via MQTT.""" - self.hass.config.components = set(['mqtt']) + mock_component(self.hass, 'mqtt') assert setup_component(self.hass, sensor.DOMAIN, { sensor.DOMAIN: { 'platform': 'mqtt', @@ -42,7 +42,7 @@ class TestSensorMQTT(unittest.TestCase): def test_setting_sensor_value_via_mqtt_json_message(self): """Test the setting of the value via MQTT with JSON playload.""" - self.hass.config.components = set(['mqtt']) + mock_component(self.hass, 'mqtt') assert setup_component(self.hass, sensor.DOMAIN, { sensor.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/sensor/test_pilight.py b/tests/components/sensor/test_pilight.py index 2bade2af1a3..35b6924a35a 100644 --- a/tests/components/sensor/test_pilight.py +++ b/tests/components/sensor/test_pilight.py @@ -5,7 +5,8 @@ from homeassistant.bootstrap import setup_component import homeassistant.components.sensor as sensor from homeassistant.components import pilight -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) HASS = None @@ -23,7 +24,7 @@ def setup_function(): global HASS HASS = get_test_home_assistant() - HASS.config.components = set(['pilight']) + mock_component(HASS, 'pilight') # pylint: disable=invalid-name diff --git a/tests/components/sensor/test_rfxtrx.py b/tests/components/sensor/test_rfxtrx.py index 092a9b60f85..96b5623b7b1 100644 --- a/tests/components/sensor/test_rfxtrx.py +++ b/tests/components/sensor/test_rfxtrx.py @@ -7,7 +7,7 @@ from homeassistant.bootstrap import setup_component from homeassistant.components import rfxtrx as rfxtrx_core from homeassistant.const import TEMP_CELSIUS -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component @pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'") @@ -17,7 +17,7 @@ class TestSensorRfxtrx(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['rfxtrx']) + mock_component(self.hass, 'rfxtrx') def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 3a5502c8150..33de6de52a9 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -22,7 +22,6 @@ class TestSensorMQTT(unittest.TestCase): def test_controlling_state_via_topic(self): """Test the controlling state via topic.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, switch.DOMAIN, { switch.DOMAIN: { 'platform': 'mqtt', @@ -52,7 +51,6 @@ class TestSensorMQTT(unittest.TestCase): def test_sending_mqtt_commands_and_optimistic(self): """Test the sending MQTT commands in optimistic mode.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, switch.DOMAIN, { switch.DOMAIN: { 'platform': 'mqtt', @@ -86,7 +84,6 @@ class TestSensorMQTT(unittest.TestCase): def test_controlling_state_via_topic_and_json_message(self): """Test the controlling state via topic and JSON message.""" - self.hass.config.components = set(['mqtt']) assert setup_component(self.hass, switch.DOMAIN, { switch.DOMAIN: { 'platform': 'mqtt', diff --git a/tests/components/switch/test_rfxtrx.py b/tests/components/switch/test_rfxtrx.py index 26af42be4a9..b4eb1259515 100644 --- a/tests/components/switch/test_rfxtrx.py +++ b/tests/components/switch/test_rfxtrx.py @@ -6,7 +6,7 @@ import pytest from homeassistant.bootstrap import setup_component from homeassistant.components import rfxtrx as rfxtrx_core -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component @pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'") @@ -16,7 +16,7 @@ class TestSwitchRfxtrx(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components = set(['rfxtrx']) + mock_component(self.hass, 'rfxtrx') def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py index c22c431ed03..62b9f681703 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -4,7 +4,7 @@ import asyncio import unittest import logging -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component from homeassistant.core import CoreState, State from homeassistant.bootstrap import setup_component, async_setup_component @@ -118,7 +118,7 @@ def test_restore_state(hass): } hass.state = CoreState.starting - hass.config.components.add('recorder') + mock_component(hass, 'recorder') yield from async_setup_component(hass, DOMAIN, { DOMAIN: { diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py index b07c62e441f..46de75bf8bd 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -39,6 +39,7 @@ class TestPanelCustom(unittest.TestCase): path = self.hass.config.path(panel_custom.PANEL_DIR) os.mkdir(path) + self.hass.data.pop(bootstrap.DATA_SETUP) with open(os.path.join(path, 'todomvc.html'), 'a'): assert bootstrap.setup_component(self.hass, 'panel_custom', config) @@ -66,6 +67,8 @@ class TestPanelCustom(unittest.TestCase): ) assert not mock_register.called + self.hass.data.pop(bootstrap.DATA_SETUP) + with patch('os.path.isfile', Mock(return_value=True)): with patch('os.access', Mock(return_value=True)): assert bootstrap.setup_component( diff --git a/tests/components/test_rfxtrx.py b/tests/components/test_rfxtrx.py index 7e47dfb6a50..a1041777ebc 100644 --- a/tests/components/test_rfxtrx.py +++ b/tests/components/test_rfxtrx.py @@ -50,8 +50,8 @@ class TestRFXTRX(unittest.TestCase): '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0', 'dummy': True}})) - self.hass.config.components.remove('rfxtrx') - + def test_valid_config2(self): + """Test configuration.""" self.assertTrue(setup_component(self.hass, 'rfxtrx', { 'rfxtrx': { 'device': '/dev/serial/by-id/usb' + diff --git a/tests/components/test_script.py b/tests/components/test_script.py index 4e8d94ade21..14aa75eb963 100644 --- a/tests/components/test_script.py +++ b/tests/components/test_script.py @@ -6,7 +6,7 @@ from homeassistant.core import callback from homeassistant.bootstrap import setup_component from homeassistant.components import script -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component ENTITY_ID = 'script.test' @@ -19,7 +19,7 @@ class TestScriptComponent(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - self.hass.config.components.add('group') + mock_component(self.hass, 'group') # pylint: disable=invalid-name def tearDown(self): diff --git a/tests/components/test_zone.py b/tests/components/test_zone.py index 4eefe8c0031..b0d4f06688d 100644 --- a/tests/components/test_zone.py +++ b/tests/components/test_zone.py @@ -63,11 +63,12 @@ class TestComponentZone(unittest.TestCase): }, ] }) - + self.hass.block_till_done() active = zone.active_zone(self.hass, 32.880600, -117.237561) assert active is None - self.hass.config.components.remove('zone') + def test_active_zone_skips_passive_zones_2(self): + """Test active and passive zones.""" assert bootstrap.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { @@ -78,7 +79,7 @@ class TestComponentZone(unittest.TestCase): }, ] }) - + self.hass.block_till_done() active = zone.active_zone(self.hass, 32.880700, -117.237561) assert 'zone.active_zone' == active.entity_id @@ -106,7 +107,10 @@ class TestComponentZone(unittest.TestCase): active = zone.active_zone(self.hass, latitude, longitude) assert 'zone.small_zone' == active.entity_id - self.hass.config.components.remove('zone') + def test_active_zone_prefers_smaller_zone_if_same_distance_2(self): + """Test zone size preferences.""" + latitude = 32.880600 + longitude = -117.237561 assert bootstrap.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index b2f60cd0a2e..5e3f9cd8c88 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -1,6 +1,5 @@ """Test discovery helpers.""" import asyncio -from collections import OrderedDict from unittest.mock import patch import pytest @@ -9,7 +8,6 @@ from homeassistant import loader, bootstrap from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery -from homeassistant.util.async import run_coroutine_threadsafe from tests.common import ( get_test_home_assistant, MockModule, MockPlatform, mock_coro) @@ -145,10 +143,6 @@ class TestHelpersDiscovery: }], }) - # We wait for the setup_lock to finish - run_coroutine_threadsafe( - self.hass.data['setup_lock'].acquire(), self.hass.loop).result() - self.hass.block_till_done() # test_component will only be setup once @@ -171,6 +165,7 @@ class TestHelpersDiscovery: def component1_setup(hass, config): """Setup mock component.""" + print('component1 setup') discovery.discover(hass, 'test_component2', component='test_component2') return True @@ -188,15 +183,15 @@ class TestHelpersDiscovery: 'test_component2', MockModule('test_component2', setup=component2_setup)) - config = OrderedDict() - config['test_component1'] = {} - config['test_component2'] = {} - - self.hass.loop.run_until_complete = \ - lambda _: self.hass.block_till_done() - - bootstrap.from_config_dict(config, self.hass) + @callback + def setup(): + """Setup 2 components.""" + self.hass.async_add_job(bootstrap.async_setup_component( + self.hass, 'test_component1', {})) + self.hass.async_add_job(bootstrap.async_setup_component( + self.hass, 'test_component2', {})) + self.hass.add_job(setup) self.hass.block_till_done() # test_component will only be setup once diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index d5ae60cc18e..d95ec3a87f8 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -272,6 +272,7 @@ class TestHelpersEntityComponent(unittest.TestCase): } }) + self.hass.block_till_done() assert component_setup.called assert platform_setup.called @@ -294,6 +295,7 @@ class TestHelpersEntityComponent(unittest.TestCase): ("{} 3".format(DOMAIN), {'platform': 'mod2'}), ])) + self.hass.block_till_done() assert platform1_setup.called assert platform2_setup.called @@ -336,6 +338,7 @@ class TestHelpersEntityComponent(unittest.TestCase): } }) + self.hass.block_till_done() assert mock_track.called assert timedelta(seconds=30) == mock_track.call_args[0][2] @@ -360,6 +363,7 @@ class TestHelpersEntityComponent(unittest.TestCase): } }) + self.hass.block_till_done() assert mock_track.called assert timedelta(seconds=30) == mock_track.call_args[0][2] @@ -385,6 +389,8 @@ class TestHelpersEntityComponent(unittest.TestCase): } }) + self.hass.block_till_done() + assert sorted(self.hass.states.entity_ids()) == \ ['test_domain.yummy_beer', 'test_domain.yummy_unnamed_device'] diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 59598823911..f46f33c333f 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -13,13 +13,14 @@ from homeassistant.helpers.restore_state import ( from homeassistant.components.recorder.models import RecorderRuns, States from tests.common import ( - get_test_home_assistant, mock_coro, init_recorder_component) + get_test_home_assistant, mock_coro, init_recorder_component, + mock_component) @asyncio.coroutine def test_caching_data(hass): """Test that we cache data.""" - hass.config.components.add('recorder') + mock_component(hass, 'recorder') hass.state = CoreState.starting states = [ diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 410f1636a88..173cea1957a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -7,12 +7,10 @@ import threading import logging import voluptuous as vol -import pytest from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.config as config_util -from homeassistant.exceptions import HomeAssistantError from homeassistant import bootstrap, loader import homeassistant.util.dt as dt_util from homeassistant.helpers.config_validation import PLATFORM_SCHEMA @@ -79,23 +77,8 @@ class TestBootstrap: patch_yaml_files(files, True): self.hass = bootstrap.from_config_file('config.yaml') - components.add('group') assert components == self.hass.config.components - def test_handle_setup_circular_dependency(self): - """Test the setup of circular dependencies.""" - loader.set_component('comp_b', MockModule('comp_b', ['comp_a'])) - - def setup_a(hass, config): - """Setup the another component.""" - bootstrap.setup_component(hass, 'comp_b') - return True - - loader.set_component('comp_a', MockModule('comp_a', setup=setup_a)) - - bootstrap.setup_component(self.hass, 'comp_a') - assert set(['comp_a']) == self.hass.config.components - def test_validate_component_config(self): """Test validating component configuration.""" config_schema = vol.Schema({ @@ -109,16 +92,22 @@ class TestBootstrap: with assert_setup_component(0): assert not bootstrap.setup_component(self.hass, 'comp_conf', {}) + self.hass.data.pop(bootstrap.DATA_SETUP) + with assert_setup_component(0): assert not bootstrap.setup_component(self.hass, 'comp_conf', { 'comp_conf': None }) + self.hass.data.pop(bootstrap.DATA_SETUP) + with assert_setup_component(0): assert not bootstrap.setup_component(self.hass, 'comp_conf', { 'comp_conf': {} }) + self.hass.data.pop(bootstrap.DATA_SETUP) + with assert_setup_component(0): assert not bootstrap.setup_component(self.hass, 'comp_conf', { 'comp_conf': { @@ -127,6 +116,8 @@ class TestBootstrap: } }) + self.hass.data.pop(bootstrap.DATA_SETUP) + with assert_setup_component(1): assert bootstrap.setup_component(self.hass, 'comp_conf', { 'comp_conf': { @@ -154,6 +145,7 @@ class TestBootstrap: } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(1): @@ -167,6 +159,7 @@ class TestBootstrap: } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(0): @@ -177,6 +170,7 @@ class TestBootstrap: } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(1): @@ -187,6 +181,7 @@ class TestBootstrap: } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(1): @@ -197,6 +192,7 @@ class TestBootstrap: }] }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') # Any falsey platform config will be ignored (None, {}, etc) @@ -244,22 +240,27 @@ class TestBootstrap: def test_component_not_setup_twice_if_loaded_during_other_setup(self): """Test component setup while waiting for lock is not setup twice.""" - loader.set_component('comp', MockModule('comp')) - result = [] + @asyncio.coroutine + def async_setup(hass, config): + """Tracking Setup.""" + result.append(1) + + loader.set_component( + 'comp', MockModule('comp', async_setup=async_setup)) + def setup_component(): """Setup the component.""" - result.append(bootstrap.setup_component(self.hass, 'comp')) + bootstrap.setup_component(self.hass, 'comp') thread = threading.Thread(target=setup_component) thread.start() - self.hass.config.components.add('comp') + bootstrap.setup_component(self.hass, 'comp') thread.join() assert len(result) == 1 - assert result[0] def test_component_not_setup_missing_dependencies(self): """Test we do not setup a component if not all dependencies loaded.""" @@ -269,8 +270,9 @@ class TestBootstrap: assert not bootstrap.setup_component(self.hass, 'comp', {}) assert 'comp' not in self.hass.config.components - loader.set_component('non_existing', MockModule('non_existing')) + self.hass.data.pop(bootstrap.DATA_SETUP) + loader.set_component('non_existing', MockModule('non_existing')) assert bootstrap.setup_component(self.hass, 'comp', {}) def test_component_failing_setup(self): @@ -349,6 +351,7 @@ class TestBootstrap: }) assert mock_setup.call_count == 0 + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('switch') with assert_setup_component(0): @@ -361,6 +364,7 @@ class TestBootstrap: }) assert mock_setup.call_count == 0 + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('switch') with assert_setup_component(1): @@ -382,6 +386,7 @@ class TestBootstrap: assert loader.get_component('disabled_component') is None assert 'disabled_component' not in self.hass.config.components + self.hass.data.pop(bootstrap.DATA_SETUP) loader.set_component( 'disabled_component', MockModule('disabled_component', setup=lambda hass, config: False)) @@ -390,6 +395,7 @@ class TestBootstrap: assert loader.get_component('disabled_component') is not None assert 'disabled_component' not in self.hass.config.components + self.hass.data.pop(bootstrap.DATA_SETUP) loader.set_component( 'disabled_component', MockModule('disabled_component', setup=lambda hass, config: True)) @@ -435,35 +441,16 @@ class TestBootstrap: self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, track_start) - self.hass.loop.run_until_complete = \ - lambda _: self.hass.block_till_done() - - bootstrap.from_config_dict({'test_component1': None}, self.hass) - + self.hass.add_job(bootstrap.async_setup_component( + self.hass, 'test_component1', {})) + self.hass.block_till_done() self.hass.start() - assert call_order == [1, 1, 2] @asyncio.coroutine def test_component_cannot_depend_config(hass): """Test config is not allowed to be a dependency.""" - loader.set_component( - 'test_component1', - MockModule('test_component1', dependencies=['config'])) - - with pytest.raises(HomeAssistantError): - yield from bootstrap.async_from_config_dict( - {'test_component1': None}, hass) - - -@asyncio.coroutine -def test_platform_cannot_depend_config(): - """Test config is not allowed to be a dependency.""" - loader.set_component( - 'test_component1.test', - MockPlatform('whatever', dependencies=['config'])) - - with pytest.raises(HomeAssistantError): - yield from bootstrap.async_prepare_setup_platform( - mock.MagicMock(), {}, 'test_component1', 'test') + result = yield from bootstrap._async_process_dependencies( + hass, None, 'test', ['config']) + assert not result diff --git a/tests/test_loader.py b/tests/test_loader.py index 93e24b57205..0b3f9653faa 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -54,33 +54,3 @@ class TestLoader(unittest.TestCase): # Try to get load order for non-existing component self.assertEqual([], loader.load_order_component('mod1')) - - def test_load_order_components(self): - """Setup loading order of components.""" - loader.set_component('mod1', MockModule('mod1', ['group'])) - loader.set_component('mod2', MockModule('mod2', ['mod1', 'sun'])) - loader.set_component('mod3', MockModule('mod3', ['mod2'])) - loader.set_component('mod4', MockModule('mod4', ['group'])) - - self.assertEqual( - ['group', 'mod4', 'mod1', 'sun', 'mod2', 'mod3'], - loader.load_order_components(['mod4', 'mod3', 'mod2'])) - - loader.set_component('mod1', MockModule('mod1')) - loader.set_component('mod2', MockModule('mod2', ['group'])) - - self.assertEqual( - ['mod1', 'group', 'mod2'], - loader.load_order_components(['mod2', 'mod1'])) - - # Add a non existing one - self.assertEqual( - ['mod1', 'group', 'mod2'], - loader.load_order_components(['mod2', 'nonexisting', 'mod1'])) - - # Depend on a non existing one - loader.set_component('mod1', MockModule('mod1', ['nonexisting'])) - - self.assertEqual( - ['group', 'mod2'], - loader.load_order_components(['mod2', 'mod1'])) From 7bc2e1238dc17d22eb81f970f1006b0aefbf90ee Mon Sep 17 00:00:00 2001 From: ericgingras Date: Tue, 28 Feb 2017 22:34:40 -0600 Subject: [PATCH 067/198] Convert kpH and mpH to kph and mph (#6316) There is no reason for the H to be capitalized. Changing it to lowercase increases consistency with other components and allows for use of the min/max sensor, which throws an error if the units of measurement are not the same. --- homeassistant/components/sensor/wunderground.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 67e19f225d5..93e747cd16f 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -68,10 +68,10 @@ SENSOR_TYPES = { 'weather': ['Weather Summary', None], 'wind_degrees': ['Wind Degrees', None], 'wind_dir': ['Wind Direction', None], - 'wind_gust_kph': ['Wind Gust', 'kpH'], - 'wind_gust_mph': ['Wind Gust', 'mpH'], - 'wind_kph': ['Wind Speed', 'kpH'], - 'wind_mph': ['Wind Speed', 'mpH'], + 'wind_gust_kph': ['Wind Gust', 'kph'], + 'wind_gust_mph': ['Wind Gust', 'mph'], + 'wind_kph': ['Wind Speed', 'kph'], + 'wind_mph': ['Wind Speed', 'mph'], 'wind_string': ['Wind Summary', None], } From a0256e194766f33539c323dc3a7868137bdff1c3 Mon Sep 17 00:00:00 2001 From: jumpkick Date: Tue, 28 Feb 2017 23:37:56 -0500 Subject: [PATCH 068/198] Rollback netdisco to 0.8.2 to resolve #6165 (#6314) * Rollback netdisco to 0.8.2 to resolve #6165 * Rollback netdisco to 0.8.2 to resolve #6165 --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 284e8c042da..a3444958e62 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.helpers.discovery import load_platform, discover -REQUIREMENTS = ['netdisco==0.8.3'] +REQUIREMENTS = ['netdisco==0.8.2'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 9f69acee1b4..a66163b40d1 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -365,7 +365,7 @@ mutagen==1.36.2 myusps==1.0.3 # homeassistant.components.discovery -netdisco==0.8.3 +netdisco==0.8.2 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From ac49298c8da539606b1f54b25d97c416063dd9c1 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Wed, 1 Mar 2017 06:56:23 +0200 Subject: [PATCH 069/198] Log errors when loading yaml (#6257) --- homeassistant/bootstrap.py | 3 ++- homeassistant/config.py | 6 +++++- homeassistant/scripts/check_config.py | 13 ++++++++++--- tests/scripts/test_check_config.py | 12 ++++++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b1233594f89..db9f4600261 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -428,7 +428,8 @@ def async_from_config_file(config_path: str, try: config_dict = yield from hass.loop.run_in_executor( None, conf_util.load_yaml_config_file, config_path) - except HomeAssistantError: + except HomeAssistantError as err: + _LOGGER.error('Error loading %s: %s', config_path, err) return None finally: clear_secret_cache() diff --git a/homeassistant/config.py b/homeassistant/config.py index 388093ec37a..3968ea571c5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -239,7 +239,11 @@ def load_yaml_config_file(config_path): This method needs to run in an executor. """ - conf_dict = load_yaml(config_path) + try: + conf_dict = load_yaml(config_path) + except FileNotFoundError as err: + raise HomeAssistantError("Config file not found: {}".format( + getattr(err, 'filename', err))) if not isinstance(conf_dict, dict): msg = 'The configuration file {} does not contain a dictionary'.format( diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 38138c87883..eac0df8bc90 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,6 +30,8 @@ MOCKS = { config_util.async_log_exception), 'package_error': ("homeassistant.config._log_pkg_error", config_util._log_pkg_error), + 'logger_exception': ("homeassistant.bootstrap._LOGGER.error", + bootstrap._LOGGER.error), } SILENCE = ( 'homeassistant.bootstrap.clear_secret_cache', @@ -180,9 +182,9 @@ def check(config_path): if module is None: # Ensure list - res['except'][ERROR_STR] = res['except'].get(ERROR_STR, []) - res['except'][ERROR_STR].append('{} not found: {}'.format( - 'Platform' if '.' in comp_name else 'Component', comp_name)) + msg = '{} not found: {}'.format( + 'Platform' if '.' in comp_name else 'Component', comp_name) + res['except'].setdefault(ERROR_STR, []).append(msg) return None # Test if platform/component and overwrite setup @@ -224,6 +226,11 @@ def check(config_path): res['except'][pkg_key] = config.get('homeassistant', {}) \ .get('packages', {}).get(package) + def mock_logger_exception(msg, *params): + """Log logger.exceptions.""" + res['except'].setdefault(ERROR_STR, []).append(msg % params) + MOCKS['logger_exception'][1](msg, *params) + # Patches to skip functions for sil in SILENCE: PATCHES[sil] = patch(sil) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 23dde3a8244..63812e1c593 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -85,6 +85,7 @@ class TestCheckConfig(unittest.TestCase): change_yaml_files(res) self.assertDictEqual({}, res['components']) + res['except'].pop(check_config.ERROR_STR) self.assertDictEqual( {'http': {'password': 'err123'}}, res['except'] @@ -111,6 +112,7 @@ class TestCheckConfig(unittest.TestCase): 'light': []}, res['components'] ) + res['except'].pop(check_config.ERROR_STR) self.assertDictEqual( {'light.mqtt_json': {'platform': 'mqtt_json'}}, res['except'] @@ -138,10 +140,12 @@ class TestCheckConfig(unittest.TestCase): res = check_config.check(get_test_config_dir('badplatform.yaml')) change_yaml_files(res) - self.assertDictEqual({'light': []}, res['components']) - self.assertDictEqual({check_config.ERROR_STR: - ['Platform not found: light.beer']}, - res['except']) + assert res['components'] == {'light': []} + assert res['except'] == { + check_config.ERROR_STR: [ + 'Platform not found: light.beer', + 'Unable to find platform light.beer' + ]} self.assertDictEqual({}, res['secret_cache']) self.assertDictEqual({}, res['secrets']) self.assertListEqual(['.../badplatform.yaml'], res['yaml_files']) From 84f30d9ef879450521684a8962449982644ec3e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Feb 2017 23:42:31 -0800 Subject: [PATCH 070/198] Bootstrap tweaks tests (#6326) * Update strings/fix component not found message. * Fix tests * More tweak text --- homeassistant/bootstrap.py | 18 +++++++++++------- tests/scripts/test_check_config.py | 10 +++++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index db9f4600261..c0ed6db11f7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -145,26 +145,30 @@ def _async_setup_component(hass: core.HomeAssistant, domain: Domain of component to setup. config: The Home Assistant configuration. """ - def log_error(msg): + def log_error(msg, link=True): """Log helper.""" _LOGGER.error('Setup failed for %s: %s', domain, msg) - async_notify_setup_error(hass, domain, True) + async_notify_setup_error(hass, domain, link) + + component = loader.get_component(domain) + + if not component: + log_error('Component not found.', False) + return False # Validate no circular dependencies components = loader.load_order_component(domain) # OrderedSet is empty if component or dependencies could not be resolved if not components: - log_error('Unable to resolve component or dependencies') + log_error('Unable to resolve component or dependencies.') return False - component = loader.get_component(domain) - processed_config = \ conf_util.async_process_component_config(hass, config, domain) if processed_config is None: - log_error('Invalid config') + log_error('Invalid config.') return False if not hass.config.skip_pip and hasattr(component, 'REQUIREMENTS'): @@ -234,7 +238,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, # Not found if platform is None: - log_error('Unable to find platform') + log_error('Platform not found.') return None # Already loaded diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 63812e1c593..250a8ccc23a 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -112,7 +112,6 @@ class TestCheckConfig(unittest.TestCase): 'light': []}, res['components'] ) - res['except'].pop(check_config.ERROR_STR) self.assertDictEqual( {'light.mqtt_json': {'platform': 'mqtt_json'}}, res['except'] @@ -131,9 +130,11 @@ class TestCheckConfig(unittest.TestCase): res = check_config.check(get_test_config_dir('badcomponent.yaml')) change_yaml_files(res) self.assertDictEqual({}, res['components']) - self.assertDictEqual({check_config.ERROR_STR: - ['Component not found: beer']}, - res['except']) + self.assertDictEqual({ + check_config.ERROR_STR: [ + 'Component not found: beer', + 'Setup failed for beer: Component not found.'] + }, res['except']) self.assertDictEqual({}, res['secret_cache']) self.assertDictEqual({}, res['secrets']) self.assertListEqual(['.../badcomponent.yaml'], res['yaml_files']) @@ -144,7 +145,6 @@ class TestCheckConfig(unittest.TestCase): assert res['except'] == { check_config.ERROR_STR: [ 'Platform not found: light.beer', - 'Unable to find platform light.beer' ]} self.assertDictEqual({}, res['secret_cache']) self.assertDictEqual({}, res['secrets']) From 30bed8341afbc695af63867a1196708cf8af53df Mon Sep 17 00:00:00 2001 From: Stefano Scipioni Date: Wed, 1 Mar 2017 12:15:16 +0100 Subject: [PATCH 071/198] Telegram webhooks new text event (#6301) * new TELEGRAM_TEXT * telegram command event renamed in 'telegram_command' * fire telegram_text event anyway --- homeassistant/components/telegram_webhooks.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_webhooks.py b/homeassistant/components/telegram_webhooks.py index b403c191925..f952145f822 100644 --- a/homeassistant/components/telegram_webhooks.py +++ b/homeassistant/components/telegram_webhooks.py @@ -24,7 +24,8 @@ REQUIREMENTS = ['python-telegram-bot==5.3.0'] _LOGGER = logging.getLogger(__name__) -EVENT_TELEGRAM_COMMAND = 'telegram.command' +EVENT_TELEGRAM_COMMAND = 'telegram_command' +EVENT_TELEGRAM_TEXT = 'telegram_text' TELEGRAM_HANDLER_URL = '/api/telegram_webhooks' @@ -40,6 +41,7 @@ DEFAULT_TRUSTED_NETWORKS = [ ] ATTR_COMMAND = 'command' +ATTR_TEXT = 'text' ATTR_USER_ID = 'user_id' ATTR_ARGS = 'args' @@ -118,15 +120,24 @@ class BotPushReceiver(HomeAssistantView): return self.json_message('Invalid user', HTTP_BAD_REQUEST) _LOGGER.debug("Received telegram data: %s", data) - if not data['text'] or data['text'][:1] != '/': - _LOGGER.warning('no command') + if not data['text']: + _LOGGER.warning('no text') return self.json({}) - pieces = data['text'].split(' ') + if data['text'][:1] == '/': + # telegram command "/blabla arg1 arg2 ..." + pieces = data['text'].split(' ') - request.app['hass'].bus.async_fire(EVENT_TELEGRAM_COMMAND, { - ATTR_COMMAND: pieces[0], - ATTR_ARGS: " ".join(pieces[1:]), + request.app['hass'].bus.async_fire(EVENT_TELEGRAM_COMMAND, { + ATTR_COMMAND: pieces[0], + ATTR_ARGS: " ".join(pieces[1:]), + ATTR_USER_ID: data['from']['id'], + }) + + # telegram text "bla bla" + request.app['hass'].bus.async_fire(EVENT_TELEGRAM_TEXT, { + ATTR_TEXT: data['text'], ATTR_USER_ID: data['from']['id'], }) + return self.json({}) From 4e96e461f7c160ad798142b89d82cdb3fff8d086 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 1 Mar 2017 16:37:48 +0100 Subject: [PATCH 072/198] Cleanup component track_point_in_utc_time usage (#6330) --- .../device_tracker/bluetooth_le_tracker.py | 2 +- .../components/device_tracker/bluetooth_tracker.py | 3 ++- homeassistant/components/device_tracker/ping.py | 2 +- homeassistant/components/tellduslive.py | 12 ++++-------- homeassistant/components/volvooncall.py | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index a4a933fe778..7b7454d0a28 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -110,7 +110,7 @@ def setup_scanner(hass, config, see, discovery_info=None): _LOGGER.info("Discovered Bluetooth LE device %s", address) see_device(address, devs[address], new_device=True) - track_point_in_utc_time(hass, update_ble, now + interval) + track_point_in_utc_time(hass, update_ble, dt_util.utcnow() + interval) update_ble(dt_util.utcnow()) diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index 1de0629c7c5..f71f8c4271a 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -82,7 +82,8 @@ def setup_scanner(hass, config, see, discovery_info=None): see_device((mac, result)) except bluetooth.BluetoothError: _LOGGER.exception('Error looking up bluetooth device!') - track_point_in_utc_time(hass, update_bluetooth, now + interval) + track_point_in_utc_time( + hass, update_bluetooth, dt_util.utcnow() + interval) update_bluetooth(dt_util.utcnow()) diff --git a/homeassistant/components/device_tracker/ping.py b/homeassistant/components/device_tracker/ping.py index 2af400ba89c..04537dd6e4d 100644 --- a/homeassistant/components/device_tracker/ping.py +++ b/homeassistant/components/device_tracker/ping.py @@ -86,7 +86,7 @@ def setup_scanner(hass, config, see, discovery_info=None): """Update all the hosts on every interval time.""" for host in hosts: host.update(see) - track_point_in_utc_time(hass, update, now + interval) + track_point_in_utc_time(hass, update, util.dt.utcnow() + interval) return True return update(util.dt.utcnow()) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 78d4eadc7ab..eb2957d7b4a 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -98,9 +98,8 @@ class TelldusLiveClient(object): try: self._sync() finally: - track_point_in_utc_time(self._hass, - self.update, - now + self._interval) + track_point_in_utc_time( + self._hass, self.update, utcnow() + self._interval) def _sync(self): """Update local list of devices.""" @@ -123,11 +122,8 @@ class TelldusLiveClient(object): def discover(device_id, component): """Discover the component.""" - discovery.load_platform(self._hass, - component, - DOMAIN, - [device_id], - self._config) + discovery.load_platform( + self._hass, component, DOMAIN, [device_id], self._config) known_ids = set([entity.device_id for entity in self.entities]) for device in self._client.devices: diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 52fe6f69c93..05627e8fb53 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -109,7 +109,7 @@ def setup(hass, config): return True finally: - track_point_in_utc_time(hass, update, now + interval) + track_point_in_utc_time(hass, update, utcnow() + interval) _LOGGER.info('Logging in to service') return update(utcnow()) From 0ac4a152bec0f6d0bd3f5d3314514b5ec9ca8932 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Mar 2017 07:38:49 -0800 Subject: [PATCH 073/198] Discovery fix (#6321) * Fix incorrect import * Create own discovery service * Fix tests * Fix hdmi_cec bad import --- homeassistant/components/discovery.py | 84 +++++++++------ homeassistant/components/hdmi_cec.py | 2 +- homeassistant/components/maxcube.py | 2 +- requirements_all.txt | 2 +- tests/components/test_discovery.py | 148 ++++++++++++++------------ 5 files changed, 135 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index a3444958e62..4ef4317e22e 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -6,20 +6,23 @@ Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered. Knows which components handle certain types, will make sure they are loaded before the EVENT_PLATFORM_DISCOVERED is fired. """ +import asyncio +from datetime import timedelta import logging -import threading import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.helpers.discovery import load_platform, discover +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.discovery import async_load_platform, async_discover +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==0.8.2'] +REQUIREMENTS = ['netdisco==0.9.0'] DOMAIN = 'discovery' -SCAN_INTERVAL = 300 # seconds +SCAN_INTERVAL = timedelta(seconds=300) SERVICE_NETGEAR = 'netgear_router' SERVICE_WEMO = 'belkin_wemo' SERVICE_HASS_IOS_APP = 'hass_ios' @@ -49,18 +52,20 @@ SERVICE_HANDLERS = { CONF_IGNORE = 'ignore' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ + vol.Required(DOMAIN): vol.Schema({ vol.Optional(CONF_IGNORE, default=[]): vol.All(cv.ensure_list, [vol.In(SERVICE_HANDLERS)]) }), }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Start a discovery service.""" - logger = logging.getLogger(__name__) + from netdisco.discovery import NetworkDiscovery - from netdisco.service import DiscoveryService + logger = logging.getLogger(__name__) + netdisco = NetworkDiscovery() # Disable zeroconf logging, it spams logging.getLogger('zeroconf').setLevel(logging.CRITICAL) @@ -68,37 +73,56 @@ def setup(hass, config): # Platforms ignore by config ignored_platforms = config[DOMAIN][CONF_IGNORE] - lock = threading.Lock() - - def new_service_listener(service, info): + @asyncio.coroutine + def new_service_found(service, info): """Called when a new service is found.""" if service in ignored_platforms: logger.info("Ignoring service: %s %s", service, info) return - with lock: - logger.info("Found new service: %s %s", service, info) + logger.info("Found new service: %s %s", service, info) - comp_plat = SERVICE_HANDLERS.get(service) + comp_plat = SERVICE_HANDLERS.get(service) - # We do not know how to handle this service. - if not comp_plat: - return + # We do not know how to handle this service. + if not comp_plat: + return - component, platform = comp_plat + component, platform = comp_plat - if platform is None: - discover(hass, service, info, component, config) - else: - load_platform(hass, component, platform, info, config) + if platform is None: + yield from async_discover(hass, service, info, component, config) + else: + yield from async_load_platform( + hass, component, platform, info, config) - # pylint: disable=unused-argument - def start_discovery(event): - """Start discovering.""" - netdisco = DiscoveryService(SCAN_INTERVAL) - netdisco.add_listener(new_service_listener) - netdisco.start() + @asyncio.coroutine + def scan_devices(_): + """Scan for devices.""" + results = yield from hass.loop.run_in_executor( + None, _discover, netdisco) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery) + for result in results: + hass.async_add_job(new_service_found(*result)) + + async_track_point_in_utc_time(hass, scan_devices, + dt_util.utcnow() + SCAN_INTERVAL) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, scan_devices) return True + + +def _discover(netdisco): + """Discover devices.""" + results = [] + try: + netdisco.scan() + + for disc in netdisco.discover(): + for service in netdisco.get_info(disc): + results.append((disc, service)) + finally: + netdisco.stop() + + return results diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index b7d6f04c440..7b966e25022 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -13,7 +13,7 @@ from functools import reduce import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components import discovery +from homeassistant.helpers import discovery from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.config import load_yaml_config_file diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py index bc201825e83..c0c9bd16674 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -10,7 +10,7 @@ import logging import time from threading import Lock -from homeassistant.components.discovery import load_platform +from homeassistant.helpers.discovery import load_platform from homeassistant.const import CONF_HOST, CONF_PORT import homeassistant.helpers.config_validation as cv import voluptuous as vol diff --git a/requirements_all.txt b/requirements_all.txt index ba0594b00d8..199005803d8 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -365,7 +365,7 @@ mutagen==1.36.2 myusps==1.0.3 # homeassistant.components.discovery -netdisco==0.8.2 +netdisco==0.9.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index f4bf307df6f..bc2be3ed463 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -1,14 +1,13 @@ """The tests for the discovery component.""" -import unittest +import asyncio -from unittest import mock from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.const import EVENT_HOMEASSISTANT_START -from tests.common import get_test_home_assistant +from tests.common import mock_coro # One might consider to "mock" services, but it's easy enough to just use # what is already available. @@ -34,87 +33,96 @@ IGNORE_CONFIG = { } -@patch('netdisco.service.DiscoveryService') -@patch('homeassistant.components.discovery.load_platform') -@patch('homeassistant.components.discovery.discover') -class DiscoveryTest(unittest.TestCase): - """Test the discovery component.""" +@asyncio.coroutine +def test_unknown_service(hass): + """Test that unknown service is ignored.""" + result = yield from async_setup_component(hass, 'discovery', { + 'discovery': {}, + }) + assert result - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.netdisco = mock.Mock() + def discover(netdisco): + """Fake discovery.""" + return [('this_service_will_never_be_supported', {'info': 'some'})] - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + with patch.object(discovery, '_discover', discover), \ + patch('homeassistant.components.discovery.async_discover', + return_value=mock_coro()) as mock_discover, \ + patch('homeassistant.components.discovery.async_load_platform', + return_value=mock_coro()) as mock_platform: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + yield from hass.async_block_till_done() - def setup_discovery_component(self, discovery_service, config): - """Setup the discovery component with mocked netdisco.""" - discovery_service.return_value = self.netdisco + assert not mock_discover.called + assert not mock_platform.called - setup_component(self.hass, discovery.DOMAIN, config) - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - self.hass.block_till_done() +@asyncio.coroutine +def test_load_platform(hass): + """Test load a platform.""" + result = yield from async_setup_component(hass, 'discovery', BASE_CONFIG) + assert result - def discover_service(self, discovery_service, name): - """Simulate that netdisco discovered a new service.""" - self.assertTrue(self.netdisco.add_listener.called) + def discover(netdisco): + """Fake discovery.""" + return [(SERVICE, SERVICE_INFO)] - # Extract a refernce to the service listener - args, _ = self.netdisco.add_listener.call_args - listener = args[0] + with patch.object(discovery, '_discover', discover), \ + patch('homeassistant.components.discovery.async_discover', + return_value=mock_coro()) as mock_discover, \ + patch('homeassistant.components.discovery.async_load_platform', + return_value=mock_coro()) as mock_platform: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + yield from hass.async_block_till_done() - # Call the listener (just like netdisco does) - listener(name, SERVICE_INFO) + assert not mock_discover.called + assert mock_platform.called + mock_platform.assert_called_with( + hass, SERVICE_COMPONENT, SERVICE, SERVICE_INFO, BASE_CONFIG) - def test_netdisco_is_started( - self, discover, load_platform, discovery_service): - """Test that netdisco is started.""" - self.setup_discovery_component(discovery_service, BASE_CONFIG) - self.assertTrue(self.netdisco.start.called) - def test_unknown_service( - self, discover, load_platform, discovery_service): - """Test that unknown service is ignored.""" - self.setup_discovery_component(discovery_service, BASE_CONFIG) - self.discover_service(discovery_service, UNKNOWN_SERVICE) +@asyncio.coroutine +def test_load_component(hass): + """Test load a component.""" + result = yield from async_setup_component(hass, 'discovery', BASE_CONFIG) + assert result - self.assertFalse(load_platform.called) - self.assertFalse(discover.called) + def discover(netdisco): + """Fake discovery.""" + return [(SERVICE_NO_PLATFORM, SERVICE_INFO)] - def test_load_platform( - self, discover, load_platform, discovery_service): - """Test load a supported platform.""" - self.setup_discovery_component(discovery_service, BASE_CONFIG) - self.discover_service(discovery_service, SERVICE) + with patch.object(discovery, '_discover', discover), \ + patch('homeassistant.components.discovery.async_discover', + return_value=mock_coro()) as mock_discover, \ + patch('homeassistant.components.discovery.async_load_platform', + return_value=mock_coro()) as mock_platform: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + yield from hass.async_block_till_done() - load_platform.assert_called_with(self.hass, - SERVICE_COMPONENT, - SERVICE, - SERVICE_INFO, - BASE_CONFIG) + assert mock_discover.called + assert not mock_platform.called + mock_discover.assert_called_with( + hass, SERVICE_NO_PLATFORM, SERVICE_INFO, + SERVICE_NO_PLATFORM_COMPONENT, BASE_CONFIG) - def test_discover_platform( - self, discover, load_platform, discovery_service): - """Test discover a supported platform.""" - self.setup_discovery_component(discovery_service, BASE_CONFIG) - self.discover_service(discovery_service, SERVICE_NO_PLATFORM) - discover.assert_called_with(self.hass, - SERVICE_NO_PLATFORM, - SERVICE_INFO, - SERVICE_NO_PLATFORM_COMPONENT, - BASE_CONFIG) +@asyncio.coroutine +def test_ignore_service(hass): + """Test ignore service.""" + result = yield from async_setup_component(hass, 'discovery', IGNORE_CONFIG) + assert result - def test_ignore_platforms( - self, discover, load_platform, discovery_service): - """Test that ignored platforms are not setup.""" - self.setup_discovery_component(discovery_service, IGNORE_CONFIG) + def discover(netdisco): + """Fake discovery.""" + return [(SERVICE_NO_PLATFORM, SERVICE_INFO)] - self.discover_service(discovery_service, SERVICE_NO_PLATFORM) - self.assertFalse(discover.called) + with patch.object(discovery, '_discover', discover), \ + patch('homeassistant.components.discovery.async_discover', + return_value=mock_coro()) as mock_discover, \ + patch('homeassistant.components.discovery.async_load_platform', + return_value=mock_coro()) as mock_platform: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + yield from hass.async_block_till_done() - self.discover_service(discovery_service, SERVICE) - self.assertTrue(load_platform.called) + assert not mock_discover.called + assert not mock_platform.called From 64cb3390ea7460bc7746795895576691aaca1592 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Mar 2017 08:53:40 -0800 Subject: [PATCH 074/198] Test against 3.6-dev (#6324) --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 2de101af24b..864699a2fbd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,8 @@ matrix: env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 + - python: "3.6-dev" + env: TOXENV=py36 # allow_failures: # - python: "3.5" # env: TOXENV=typing From 67f3910f039e0cd91980ac001190b336ebf030b2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 1 Mar 2017 17:57:23 +0100 Subject: [PATCH 075/198] Bugfix ZigBee / Move from eventbus to dispatcher (#6333) * Bugfix ZigBee / Move from eventbus to dispatcher * fix lint --- .../components/binary_sensor/zigbee.py | 3 +- homeassistant/components/sensor/zigbee.py | 4 +- homeassistant/components/zigbee.py | 45 ++++++++----------- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py index 2eb508304d4..935d4b4bb3f 100644 --- a/homeassistant/components/binary_sensor/zigbee.py +++ b/homeassistant/components/binary_sensor/zigbee.py @@ -24,7 +24,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the ZigBee binary sensor platform.""" - add_devices([ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))]) + add_devices( + [ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))], True) class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice): diff --git a/homeassistant/components/sensor/zigbee.py b/homeassistant/components/sensor/zigbee.py index 42ae64a2b1f..f3e8d5480a8 100644 --- a/homeassistant/components/sensor/zigbee.py +++ b/homeassistant/components/sensor/zigbee.py @@ -44,7 +44,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.exception("Unknown ZigBee sensor type: %s", typ) return - add_devices([sensor_class(hass, config_class(config))]) + add_devices([sensor_class(hass, config_class(config))], True) class ZigBeeTemperatureSensor(Entity): @@ -54,8 +54,6 @@ class ZigBeeTemperatureSensor(Entity): """Initialize the sensor.""" self._config = config self._temp = None - # Get initial state - self.schedule_update_ha_state(True) @property def name(self): diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py index 66ef19a5b99..817e7e432db 100644 --- a/homeassistant/components/zigbee.py +++ b/homeassistant/components/zigbee.py @@ -4,10 +4,9 @@ Support for ZigBee devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zigbee/ """ +import asyncio import logging -import pickle from binascii import hexlify, unhexlify -from base64 import b64encode, b64decode import voluptuous as vol @@ -15,6 +14,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_PIN) from homeassistant.helpers.entity import Entity from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) REQUIREMENTS = ['xbee-helper==0.0.7'] @@ -22,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'zigbee' -EVENT_ZIGBEE_FRAME_RECEIVED = 'zigbee_frame_received' +SIGNAL_ZIGBEE_FRAME_RECEIVED = 'zigbee_frame_received' CONF_ADDRESS = 'address' CONF_BAUD = 'baud' @@ -102,9 +103,7 @@ def setup(hass, config): Pickles the frame, then encodes it into base64 since it contains non JSON serializable binary. """ - hass.bus.fire( - EVENT_ZIGBEE_FRAME_RECEIVED, - {ATTR_FRAME: b64encode(pickle.dumps(frame)).decode("ascii")}) + dispatcher_send(hass, SIGNAL_ZIGBEE_FRAME_RECEIVED, frame) DEVICE.add_frame_rx_handler(_frame_received) @@ -125,16 +124,6 @@ def frame_is_relevant(entity, frame): return True -def subscribe(hass, callback): - """Subscribe to incoming ZigBee frames.""" - def zigbee_frame_subscriber(event): - """Decode and unpickle the frame from the event bus, and call back.""" - frame = pickle.loads(b64decode(event.data[ATTR_FRAME])) - callback(frame) - - hass.bus.listen(EVENT_ZIGBEE_FRAME_RECEIVED, zigbee_frame_subscriber) - - class ZigBeeConfig(object): """Handle the fetching of configuration from the config file.""" @@ -288,6 +277,9 @@ class ZigBeeDigitalIn(Entity): self._config = config self._state = False + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" def handle_frame(frame): """Handle an incoming frame. @@ -302,12 +294,10 @@ class ZigBeeDigitalIn(Entity): # Doesn't contain information about our pin return self._state = self._config.state2bool[sample[pin_name]] - self.update_ha_state() + self.schedule_update_ha_state() - subscribe(hass, handle_frame) - - # Get initial state - self.schedule_update_ha_state(True) + async_dispatcher_connect( + self.hass, SIGNAL_ZIGBEE_FRAME_RECEIVED, handle_frame) @property def name(self): @@ -373,7 +363,7 @@ class ZigBeeDigitalOut(ZigBeeDigitalIn): return self._state = state if not self.should_poll: - self.update_ha_state() + self.schedule_update_ha_state() def turn_on(self, **kwargs): """Set the digital output to its 'on' state.""" @@ -410,6 +400,9 @@ class ZigBeeAnalogIn(Entity): self._config = config self._value = None + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" def handle_frame(frame): """Handle an incoming frame. @@ -428,12 +421,10 @@ class ZigBeeAnalogIn(Entity): ADC_PERCENTAGE, self._config.max_voltage ) - self.update_ha_state() + self.schedule_update_ha_state() - subscribe(hass, handle_frame) - - # Get initial state - hass.add_job(self.async_update_ha_state, True) + async_dispatcher_connect( + self.hass, SIGNAL_ZIGBEE_FRAME_RECEIVED, handle_frame) @property def name(self): From 4ccd819ec54d8cc4c808c80311e4578195df4185 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Mar 2017 09:05:05 -0800 Subject: [PATCH 076/198] Bump netdisco to 0.9.1 (#6338) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 4ef4317e22e..ac68cfaf367 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -18,7 +18,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==0.9.0'] +REQUIREMENTS = ['netdisco==0.9.1'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 199005803d8..bdf98c7edcc 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -365,7 +365,7 @@ mutagen==1.36.2 myusps==1.0.3 # homeassistant.components.discovery -netdisco==0.9.0 +netdisco==0.9.1 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From e23aa1ccf83701bd7f4f2064596d3e5c189031be Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Wed, 1 Mar 2017 22:57:37 +0100 Subject: [PATCH 077/198] sensor.dovado: compute state in update (#6340) --- homeassistant/components/sensor/dovado.py | 43 ++++++++++++----------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/sensor/dovado.py b/homeassistant/components/sensor/dovado.py index 8182c8ccf39..639dfa01ec6 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -16,8 +16,7 @@ from homeassistant.util import slugify import homeassistant.helpers.config_validation as cv from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, - CONF_SENSORS, STATE_UNKNOWN, - DEVICE_DEFAULT_NAME) + CONF_SENSORS, DEVICE_DEFAULT_NAME) from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) _LOGGER = logging.getLogger(__name__) @@ -88,7 +87,7 @@ class Dovado: number, message) self._dovado.send_sms(number, message) - if self.state["sms"] == "enabled": + if self.state.get("sms") == "enabled": service_name = slugify("{} {}".format(self.name, "send_sms")) hass.services.register(DOMAIN, service_name, send_sms) @@ -125,10 +124,29 @@ class DovadoSensor(Entity): """Initialize the sensor.""" self._dovado = dovado self._sensor = sensor + self._state = self._compute_state() + + def _compute_state(self): + state = self._dovado.state.get(SENSORS[self._sensor][0]) + if self._sensor == SENSOR_NETWORK: + match = re.search(r"\((.+)\)", state) + return match.group(1) if match else None + elif self._sensor == SENSOR_SIGNAL: + try: + return int(state.split()[0]) + except ValueError: + return 0 + elif self._sensor == SENSOR_SMS_UNREAD: + return int(state) + elif self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + return round(float(state) / 1e6, 1) + else: + return state def update(self): """Update sensor values.""" self._dovado.update() + self._state = self._compute_state() @property def name(self): @@ -139,24 +157,7 @@ class DovadoSensor(Entity): @property def state(self): """Return the sensor state.""" - key = SENSORS[self._sensor][0] - result = self._dovado.state.get(key) - if not result: - return STATE_UNKNOWN - elif self._sensor == SENSOR_NETWORK: - match = re.search(r"\((.+)\)", result) - return match.group(1) if match else STATE_UNKNOWN - elif self._sensor == SENSOR_SIGNAL: - try: - return int(result.split()[0]) - except ValueError: - return 0 - elif self._sensor == SENSOR_SMS_UNREAD: - return int(result) - elif self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: - return round(float(result) / 1e6, 1) - else: - return result + return self._state @property def icon(self): From bafa0cc3b82d49b66539401305450ddf0d3b6473 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 1 Mar 2017 23:09:27 +0100 Subject: [PATCH 078/198] Fix mysensors callback race (#6311) * Fix possible race at startup in mysensors callback * Update devices via persistence before starting gateway to avoid two threads calling the same callback at the same time. * Call add_devices max once per callback --- homeassistant/components/mysensors.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index d9c8584a5e9..f9438962274 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -186,12 +186,12 @@ def setup(hass, config): def gw_start(event): """Callback to trigger start of gateway and any persistence.""" - gateway.start() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: gateway.stop()) if persistence: for node_id in gateway.sensors: gateway.event_callback('persistence', node_id) + gateway.start() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: gateway.stop()) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, gw_start) @@ -251,6 +251,7 @@ def pf_callback_factory(map_sv_types, devices, entity_class, add_devices=None): _LOGGER.info('No sketch_name: node %s', node_id) return + new_devices = [] for child in gateway.sensors[node_id].children.values(): for value_type in child.values.keys(): key = node_id, child.id, value_type @@ -272,11 +273,12 @@ def pf_callback_factory(map_sv_types, devices, entity_class, add_devices=None): devices[key] = device_class( gateway, node_id, child.id, name, value_type, child.type) if add_devices: - _LOGGER.info('Adding new devices: %s', devices[key]) - add_devices([devices[key]]) - devices[key].schedule_update_ha_state(True) + new_devices.append(devices[key]) else: devices[key].update() + if add_devices and new_devices: + _LOGGER.info('Adding new devices: %s', new_devices) + add_devices(new_devices, True) return mysensors_callback From 0fe41ffb00751e2722b2a1c744a282e72a0bb1e0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 2 Mar 2017 05:57:51 +0100 Subject: [PATCH 079/198] Upgrade TwitterAPI to 2.4.5 (#6351) --- homeassistant/components/notify/twitter.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 60aa2aebd35..21388c292eb 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME -REQUIREMENTS = ['TwitterAPI==2.4.4'] +REQUIREMENTS = ['TwitterAPI==2.4.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index bdf98c7edcc..3aea86fce93 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -31,7 +31,7 @@ PyMata==2.13 SoCo==0.12 # homeassistant.components.notify.twitter -TwitterAPI==2.4.4 +TwitterAPI==2.4.5 # homeassistant.components.sensor.dnsip aiodns==1.1.1 From 435f253be881efe5b88bba8be26f377815a482ff Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 2 Mar 2017 05:58:03 +0100 Subject: [PATCH 080/198] Upgrade py-cpuinfo to 0.2.6 (#6335) --- homeassistant/components/sensor/cpuspeed.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/cpuspeed.py b/homeassistant/components/sensor/cpuspeed.py index 51a9226e1b0..7eb9cdc3051 100644 --- a/homeassistant/components/sensor/cpuspeed.py +++ b/homeassistant/components/sensor/cpuspeed.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['py-cpuinfo==0.2.3'] +REQUIREMENTS = ['py-cpuinfo==0.2.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3aea86fce93..180dc77112d 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -436,7 +436,7 @@ pushetta==1.0.15 pwaqi==2.0 # homeassistant.components.sensor.cpuspeed -py-cpuinfo==0.2.3 +py-cpuinfo==0.2.6 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From 6cb8a36cf1538065bb620133dfe19c9088e480a5 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Mar 2017 06:38:19 +0100 Subject: [PATCH 081/198] Template sensor change flow / add restore (#6336) --- homeassistant/components/sensor/template.py | 29 ++++++++--- tests/components/sensor/test_template.py | 58 ++++++++++++++++++++- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 42481c95510..51a7bc82a85 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -13,11 +13,12 @@ from homeassistant.core import callback from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - ATTR_ENTITY_ID, CONF_SENSORS) + ATTR_ENTITY_ID, CONF_SENSORS, EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.event import async_track_state_change -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -69,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -88,14 +89,30 @@ class SensorTemplate(Entity): self._state = None self._icon_template = icon_template self._icon = None + self._entities = entity_ids + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state: + self._state = state.state @callback def template_sensor_state_listener(entity, old_state, new_state): """Called when the target device changes state.""" - hass.async_add_job(self.async_update_ha_state, True) + self.hass.async_add_job(self.async_update_ha_state(True)) - async_track_state_change( - hass, entity_ids, template_sensor_state_listener) + @callback + def template_sensor_startup(event): + """Update template on startup.""" + async_track_state_change( + self.hass, self._entities, template_sensor_state_listener) + + self.hass.async_add_job(self.async_update_ha_state(True)) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_sensor_startup) @property def name(self): diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 0f5e863f328..7ba4ca136e0 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -1,7 +1,12 @@ """The test for the Template sensor platform.""" -from homeassistant.bootstrap import setup_component +import asyncio -from tests.common import get_test_home_assistant, assert_setup_component +from homeassistant.core import CoreState, State +from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) class TestTemplateSensor: @@ -33,6 +38,8 @@ class TestTemplateSensor: } }) + self.hass.start() + state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'It .' @@ -60,6 +67,8 @@ class TestTemplateSensor: } }) + self.hass.start() + state = self.hass.states.get('sensor.test_template_sensor') assert 'icon' not in state.attributes @@ -82,6 +91,8 @@ class TestTemplateSensor: } } }) + + self.hass.start() assert self.hass.states.all() == [] def test_template_attribute_missing(self): @@ -99,6 +110,8 @@ class TestTemplateSensor: } }) + self.hass.start() + state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'unknown' @@ -116,6 +129,8 @@ class TestTemplateSensor: } } }) + + self.hass.start() assert self.hass.states.all() == [] def test_invalid_sensor_does_not_create(self): @@ -129,6 +144,8 @@ class TestTemplateSensor: } } }) + + self.hass.start() assert self.hass.states.all() == [] def test_no_sensors_does_not_create(self): @@ -139,6 +156,8 @@ class TestTemplateSensor: 'platform': 'template' } }) + + self.hass.start() assert self.hass.states.all() == [] def test_missing_template_does_not_create(self): @@ -155,4 +174,39 @@ class TestTemplateSensor: } } }) + + self.hass.start() assert self.hass.states.all() == [] + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + hass.data[DATA_RESTORE_CACHE] = { + 'sensor.test_template_sensor': + State('sensor.test_template_sensor', 'It Test.'), + } + + hass.state = CoreState.starting + mock_component(hass, 'recorder') + + yield from async_setup_component(hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': + "It {{ states.sensor.test_state.state }}." + } + } + } + }) + + state = hass.states.get('sensor.test_template_sensor') + assert state.state == 'It Test.' + + yield from hass.async_start() + yield from hass.async_block_till_done() + + state = hass.states.get('sensor.test_template_sensor') + assert state.state == 'It .' From 354007f2657007146f3f6674cb57f1934d55018c Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 2 Mar 2017 08:41:19 +0200 Subject: [PATCH 082/198] Zwave optimize value_added (#6210) * Make zwave devices listen on less network changes. * Convert more platforms * Remove printouts. * Fix copy-paste * Change default dependent list to empty list --- homeassistant/components/climate/zwave.py | 5 ++ homeassistant/components/cover/zwave.py | 36 ++++++--- homeassistant/components/light/zwave.py | 10 ++- homeassistant/components/lock/zwave.py | 5 ++ homeassistant/components/zwave/__init__.py | 86 +++++++++++++++++----- 5 files changed, 113 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index ad6c89bcea1..e4c586965a6 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -246,3 +246,8 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): if self._fan_state: data[ATTR_FAN_STATE] = self._fan_state return data + + @property + def dependent_value_ids(self): + """List of value IDs a device depends on.""" + return None diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index aa2cdf858fd..46f23a68515 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -41,6 +41,7 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): self._node = value.node self._open_id = None self._close_id = None + self._current_position_id = None self._current_position = None self._workaround = workaround.get_device_mapping(value) @@ -48,20 +49,35 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): _LOGGER.debug("Using workaround %s", self._workaround) self.update_properties() + @property + def dependent_value_ids(self): + """List of value IDs a device depends on.""" + if not self._node.is_ready: + return None + return [self._current_position_id] + def update_properties(self): """Callback on data changes for node values.""" # Position value - self._current_position = self.get_value( - class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL, - label=['Level'], member='data') - self._open_id = self.get_value( - class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL, - label=['Open', 'Up'], member='value_id') - self._close_id = self.get_value( - class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL, - label=['Close', 'Down'], member='value_id') - if self._workaround == workaround.WORKAROUND_REVERSE_OPEN_CLOSE: + if not self._node.is_ready: + if self._current_position_id is None: + self._current_position_id = self.get_value( + class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL, + label=['Level'], member='value_id') + if self._open_id is None: + self._open_id = self.get_value( + class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL, + label=['Open', 'Up'], member='value_id') + if self._close_id is None: + self._close_id = self.get_value( + class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL, + label=['Close', 'Down'], member='value_id') + if self._open_id and self._close_id and \ + self._workaround == workaround.WORKAROUND_REVERSE_OPEN_CLOSE: self._open_id, self._close_id = self._close_id, self._open_id + self._workaround = None + self._current_position = self._node.get_dimmer_level( + self._current_position_id) @property def is_closed(self): diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 84aebffab0e..7e23a68b887 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -107,7 +107,7 @@ class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): # Brightness self._brightness, self._state = brightness_state(self._value) - def value_changed(self, value): + def value_changed(self): """Called when a value for this entity's node has changed.""" if self._refresh_value: if self._refreshing: @@ -124,7 +124,7 @@ class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): self._timer = Timer(self._delay, _refresh_value) self._timer.start() return - super().value_changed(value) + super().value_changed() @property def brightness(self): @@ -188,6 +188,12 @@ class ZwaveColorLight(ZwaveDimmer): self._value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED) self._get_color_values() + @property + def dependent_value_ids(self): + """List of value IDs a device depends on.""" + return [val.value_id for val in [ + self._value_color, self._value_color_channels] if val] + def _get_color_values(self): """Search for color values available on this node.""" from openzwave.network import ZWaveNetwork diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index ba1df32130d..cfafe955e2c 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -292,3 +292,8 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if self._lock_status: data[ATTR_LOCK_STATUS] = self._lock_status return data + + @property + def dependent_value_ids(self): + """List of value IDs a device depends on.""" + return None diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index f05fb2a9ae5..dacc7549c58 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -708,6 +708,10 @@ class ZWaveDeviceEntity(Entity): self._value = value self._value.set_change_verified(False) self.entity_id = "{}.{}".format(domain, self._object_id()) + + self._wakeup_value_id = None + self._battery_value_id = None + self._power_value_id = None self._update_attributes() dispatcher.connect( @@ -715,13 +719,19 @@ class ZWaveDeviceEntity(Entity): def network_value_changed(self, value): """Called when a value has changed on the network.""" - if self._value.value_id == value.value_id or \ - self._value.node == value.node: - _LOGGER.debug('Value changed for label %s', self._value.label) - self.value_changed(value) + if self._value.value_id == value.value_id: + return self.value_changed() - def value_changed(self, value): + dependent_ids = self._get_dependent_value_ids() + if dependent_ids is None and self._value.node == value.node: + return self.value_changed() + if dependent_ids is not None and value.value_id in dependent_ids: + return self.value_changed() + + def value_changed(self): """Called when a value for this entity's node has changed.""" + if not self._value.node.is_ready: + self._update_ids() self._update_attributes() self.update_properties() # If value changed after device was created but before setup_platform @@ -729,25 +739,64 @@ class ZWaveDeviceEntity(Entity): if self.hass: self.schedule_update_ha_state() + def _update_ids(self): + """Update value_ids from which to pull attributes.""" + if self._wakeup_value_id is None: + self._wakeup_value_id = self.get_value( + class_id=const.COMMAND_CLASS_WAKE_UP, member='value_id') + if self._battery_value_id is None: + self._battery_value_id = self.get_value( + class_id=const.COMMAND_CLASS_BATTERY, member='value_id') + if self._power_value_id is None: + self._power_value_id = self.get_value( + class_id=[const.COMMAND_CLASS_SENSOR_MULTILEVEL, + const.COMMAND_CLASS_METER], + label=['Power'], member='value_id', + instance=self._value.instance) + + @property + def dependent_value_ids(self): + """List of value IDs a device depends on. + + None if depends on the whole node. + """ + return [] + + def _get_dependent_value_ids(self): + """Return a list of value_ids this device depend on. + + Return None if it depends on the whole node. + """ + if self.dependent_value_ids is None: + # Device depends on node. + return None + if not self._value.node.is_ready: + # Node is not ready, so depend on the whole node. + return None + + return [val for val in (self.dependent_value_ids + [ + self._wakeup_value_id, self._battery_value_id, + self._power_value_id]) if val] + def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" self.node_id = self._value.node.node_id self.location = self._value.node.location - self.battery_level = self._value.node.get_battery_level() + self.battery_level = self._value.node.get_battery_level( + self._battery_value_id) self.wakeup_interval = None - if self._value.node.can_wake_up(): - self.wakeup_interval = self.get_value( - class_id=const.COMMAND_CLASS_WAKE_UP, - member='data') - power_value = self.get_value( - class_id=[const.COMMAND_CLASS_SENSOR_MULTILEVEL, - const.COMMAND_CLASS_METER], - label=['Power']) + if self._wakeup_value_id: + self.wakeup_interval = self._value.node.values[ + self._wakeup_value_id].data + power_value = None + if self._power_value_id: + power_value = self._value.node.values[self._power_value_id] self.power_consumption = round( power_value.data, power_value.precision) if power_value else None def _value_handler(self, method=None, class_id=None, index=None, - label=None, data=None, member=None, **kwargs): + label=None, data=None, member=None, instance=None, + **kwargs): """Get the values for a given command_class with arguments. May only be used inside callback. @@ -763,8 +812,9 @@ class ZWaveDeviceEntity(Entity): values.extend(self._value.node.get_values( class_id=cid, **kwargs).values()) _LOGGER.debug('method=%s, class_id=%s, index=%s, label=%s, data=%s,' - ' member=%s, kwargs=%s', - method, class_id, index, label, data, member, kwargs) + ' member=%s, instance=%d, kwargs=%s', + method, class_id, index, label, data, member, instance, + kwargs) _LOGGER.debug('values=%s', values) results = None for value in values: @@ -783,6 +833,8 @@ class ZWaveDeviceEntity(Entity): return if data is not None and value.data != data: continue + if instance is not None and value.instance != instance: + continue if member is not None: results = getattr(value, member) else: From 44ec6b056e97a0067fb2656c9b1e4cad1ede03e4 Mon Sep 17 00:00:00 2001 From: Alexander Fortin Date: Thu, 2 Mar 2017 08:15:30 +0100 Subject: [PATCH 083/198] Update Vagrant provision.sh (#6236) - Bugfix: with f63a79ee we removed `script/home-assistant@.service` systemd unit file, which is used by Vagrant box to start/stop hass - simplify interaction with Vagrant, provision.sh now is the only entry point and doesn't need the user to touch/remove files in order to change provisioner behavior --- virtualization/vagrant/Vagrantfile | 6 +- .../vagrant/home-assistant@.service | 20 +++++++ virtualization/vagrant/provision.sh | 58 +++++++++++-------- virtualization/vagrant/run_tests | 0 4 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 virtualization/vagrant/home-assistant@.service mode change 100644 => 100755 virtualization/vagrant/provision.sh delete mode 100644 virtualization/vagrant/run_tests diff --git a/virtualization/vagrant/Vagrantfile b/virtualization/vagrant/Vagrantfile index 7c67baa2ce4..21d5bd04adc 100644 --- a/virtualization/vagrant/Vagrantfile +++ b/virtualization/vagrant/Vagrantfile @@ -6,7 +6,11 @@ Vagrant.configure(2) do |config| config.vm.synced_folder "../../", "/home-assistant" config.vm.synced_folder "./config", "/root/.homeassistant" config.vm.network "forwarded_port", guest: 8123, host: 8123 - config.vm.provision "shell" do |shell| + config.vm.provision "fix-no-tty", type: "shell" do |shell| shell.path = "provision.sh" end + config.vm.provider :virtualbox do |vb| + vb.cpus = 2 + vb.customize ['modifyvm', :id, '--memory', '1024'] + end end diff --git a/virtualization/vagrant/home-assistant@.service b/virtualization/vagrant/home-assistant@.service new file mode 100644 index 00000000000..8e520952db9 --- /dev/null +++ b/virtualization/vagrant/home-assistant@.service @@ -0,0 +1,20 @@ +# This is a simple service file for systems with systemd to tun HA as user. +# +# For details please check https://home-assistant.io/getting-started/autostart/ +# +[Unit] +Description=Home Assistant for %i +After=network.target + +[Service] +Type=simple +User=%i +# Enable the following line if you get network-related HA errors during boot +#ExecStartPre=/usr/bin/sleep 60 +# Use `whereis hass` to determine the path of hass +ExecStart=/usr/bin/hass --runner +SendSIGKILL=no +RestartForceExitStatus=100 + +[Install] +WantedBy=multi-user.target diff --git a/virtualization/vagrant/provision.sh b/virtualization/vagrant/provision.sh old mode 100644 new mode 100755 index 69414cb9200..da5d48c6f18 --- a/virtualization/vagrant/provision.sh +++ b/virtualization/vagrant/provision.sh @@ -7,30 +7,21 @@ readonly RESTART='/home-assistant/virtualization/vagrant/restart' usage() { echo '############################################################ -############################################################ -############################################################ -Use `vagrant provision` to either run tests or restart HASS: +Use `./provision.sh` to interact with HASS. E.g: -`touch run_tests && vagrant provision` +- setup the environment: `./provision.sh start` +- restart HASS process: `./provision.sh restart` +- run test suit: `./provision.sh tests` +- destroy the host and start anew: `./provision.sh recreate` -or +Official documentation at https://home-assistant.io/docs/installation/vagrant/ -`touch restart && vagrant provision` - -To destroy the host and start anew: - -`vagrant destroy -f ; rm setup_done; vagrant up` - -############################################################ -############################################################ ############################################################' } print_done() { echo '############################################################ -############################################################ -############################################################ HASS running => http://localhost:8123/ @@ -43,9 +34,7 @@ setup_error() { Something is off... maybe setup did not complete properly? Please ensure setup did run correctly at least once. -To run setup again: - -`rm setup_done; vagrant provision` +To run setup again: `./provision.sh setup` ############################################################' exit 1 @@ -55,13 +44,14 @@ setup() { local hass_path='/root/venv/bin/hass' local systemd_bin_path='/usr/bin/hass' # Setup systemd - cp /home-assistant/script/home-assistant@.service \ + cp /home-assistant/virtualization/vagrant/home-assistant@.service \ /etc/systemd/system/home-assistant.service systemctl --system daemon-reload systemctl enable home-assistant + systemctl stop home-assistant # Install packages apt-get update - apt-get install -y git rsync python3-dev python3-pip + apt-get install -y git rsync python3-dev python3-pip libssl-dev libffi-dev pip3 install --upgrade virtualenv virtualenv ~/venv source ~/venv/bin/activate @@ -76,6 +66,9 @@ setup() { } run_tests() { + rm -f $RUN_TESTS + echo '############################################################' + echo; echo "Running test suite, hang on..."; echo; echo if ! systemctl stop home-assistant; then setup_error fi @@ -84,24 +77,41 @@ run_tests() { --exclude='*.tox' \ --exclude='*.git' \ /home-assistant/ /home-assistant-tests/ - cd /home-assistant-tests && tox - rm $RUN_TESTS + cd /home-assistant-tests && tox || true + echo '############################################################' } restart() { + echo "Restarting Home Assistant..." if ! systemctl restart home-assistant; then setup_error + else + echo "done" fi rm $RESTART } main() { + # If a parameter is provided, we assume it's the user interacting + # with the provider script... + case $1 in + "setup") rm -f setup_done; vagrant up --provision && touch setup_done; exit ;; + "tests") touch run_tests; vagrant provision ; exit ;; + "restart") touch restart; vagrant provision ; exit ;; + "start") vagrant up --provision ; exit ;; + "stop") vagrant halt ; exit ;; + "destroy") vagrant destroy -f ; exit ;; + "recreate") rm -f setup_done restart; vagrant destroy -f; \ + vagrant up --provision; exit ;; + esac + # ...otherwise we assume it's the Vagrant provisioner + if [ $(hostname) != "contrib-jessie" ]; then usage; exit; fi if ! [ -f $SETUP_DONE ]; then setup; fi - if [ -f $RUN_TESTS ]; then run_tests; fi if [ -f $RESTART ]; then restart; fi + if [ -f $RUN_TESTS ]; then run_tests; fi if ! systemctl start home-assistant; then setup_error fi } -main +main $* diff --git a/virtualization/vagrant/run_tests b/virtualization/vagrant/run_tests deleted file mode 100644 index e69de29bb2d..00000000000 From 46f5a65e68470323f91868d9722d7cba822855a4 Mon Sep 17 00:00:00 2001 From: Mitko Masarliev Date: Thu, 2 Mar 2017 09:39:33 +0200 Subject: [PATCH 084/198] Update Adafruit_Python_DHT to support new raspberry kernel (#6325) * Update Adafruit_Python_DHT to support new raspberry kernel * update Adafruit Python DHT --- homeassistant/components/sensor/dht.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 0e10199134c..1b4b6c63156 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -19,7 +19,7 @@ from homeassistant.util.temperature import celsius_to_fahrenheit # Update this requirement to upstream as soon as it supports Python 3. REQUIREMENTS = ['http://github.com/adafruit/Adafruit_Python_DHT/archive/' - '310c59b0293354d07d94375f1365f7b9b9110c7d.zip' + 'da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip' '#Adafruit_DHT==1.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 180dc77112d..4cc886d90cb 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -210,7 +210,7 @@ heatmiserV3==0.9.1 hikvision==0.4 # homeassistant.components.sensor.dht -# http://github.com/adafruit/Adafruit_Python_DHT/archive/310c59b0293354d07d94375f1365f7b9b9110c7d.zip#Adafruit_DHT==1.3.0 +# http://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.0 # homeassistant.components.switch.dlink https://github.com/LinuxChristian/pyW215/archive/v0.4.zip#pyW215==0.4 From c03022efa370332e7b98c091726802be59f278f5 Mon Sep 17 00:00:00 2001 From: Reed Riley Date: Thu, 2 Mar 2017 02:41:31 -0500 Subject: [PATCH 085/198] Add fallback for name if userdevicename isn't set using old serialnumber logic (#6265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- homeassistant/components/media_player/roku.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 08a3eec17e8..a33f331b737 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -123,7 +123,10 @@ class RokuDevice(MediaPlayerDevice): @property def name(self): """Return the name of the device.""" - return self.device_info.userdevicename + if self.device_info.userdevicename: + return self.device_info.userdevicename + else: + return "roku_" + self.roku.device_info.sernum @property def state(self): From 31bf5b8ff0f86e8a1c2200b387f9ec8b22cfc603 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Thu, 2 Mar 2017 02:49:49 -0500 Subject: [PATCH 086/198] Improve Honeywell US climate component (#5313) * Improve Honeywell US climate component * Fix tests * Fix tests * Add cool_away_temp and heat_away_temp for honeywell US * Fix honeywell tests * Fix PR comments --- homeassistant/components/climate/honeywell.py | 164 +++++++++++++++--- tests/components/climate/test_honeywell.py | 57 ++++-- 2 files changed, 191 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 3387baf76d8..7b65ed4f077 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -6,10 +6,15 @@ https://home-assistant.io/components/climate.honeywell/ """ import logging import socket +import datetime import voluptuous as vol +import requests -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) +from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA, + ATTR_FAN_MODE, ATTR_FAN_LIST, + ATTR_OPERATION_MODE, + ATTR_OPERATION_LIST) from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) @@ -21,27 +26,35 @@ REQUIREMENTS = ['evohomeclient==0.2.5', _LOGGER = logging.getLogger(__name__) ATTR_FAN = 'fan' -ATTR_FANMODE = 'fanmode' ATTR_SYSTEM_MODE = 'system_mode' +ATTR_CURRENT_OPERATION = 'equipment_output_status' CONF_AWAY_TEMPERATURE = 'away_temperature' +CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature' +CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature' CONF_REGION = 'region' DEFAULT_AWAY_TEMPERATURE = 16 +DEFAULT_COOL_AWAY_TEMPERATURE = 30 +DEFAULT_HEAT_AWAY_TEMPERATURE = 16 DEFAULT_REGION = 'eu' REGIONS = ['eu', 'us'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_AWAY_TEMPERATURE, default=DEFAULT_AWAY_TEMPERATURE): - vol.Coerce(float), + vol.Optional(CONF_AWAY_TEMPERATURE, + default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_COOL_AWAY_TEMPERATURE, + default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float), + vol.Optional(CONF_HEAT_AWAY_TEMPERATURE, + default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float), vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the HoneywelL thermostat.""" + """Setup the Honeywell thermostat.""" username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) region = config.get(CONF_REGION) @@ -88,8 +101,11 @@ def _setup_us(username, password, config, add_devices): dev_id = config.get('thermostat') loc_id = config.get('location') + cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE) + heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE) - add_devices([HoneywellUSThermostat(client, device) + add_devices([HoneywellUSThermostat(client, device, cool_away_temp, + heat_away_temp, username, password) for location in client.locations_by_id.values() for device in location.devices_by_id.values() if ((not loc_id or location.locationid == loc_id) and @@ -160,7 +176,7 @@ class RoundThermostat(ClimateDevice): def turn_away_mode_on(self): """Turn away on. - Evohome does have a proprietary away mode, but it doesn't really work + Honeywell does have a proprietary away mode, but it doesn't really work the way it should. For example: If you set a temperature manually it doesn't get overwritten when away mode is switched on. """ @@ -199,10 +215,16 @@ class RoundThermostat(ClimateDevice): class HoneywellUSThermostat(ClimateDevice): """Representation of a Honeywell US Thermostat.""" - def __init__(self, client, device): + def __init__(self, client, device, cool_away_temp, + heat_away_temp, username, password): """Initialize the thermostat.""" self._client = client self._device = device + self._cool_away_temp = cool_away_temp + self._heat_away_temp = heat_away_temp + self._away = False + self._username = username + self._password = password @property def is_fan_on(self): @@ -236,7 +258,10 @@ class HoneywellUSThermostat(ClimateDevice): @property def current_operation(self: ClimateDevice) -> str: """Return current operation ie. heat, cool, idle.""" - return getattr(self._device, ATTR_SYSTEM_MODE, None) + oper = getattr(self._device, ATTR_CURRENT_OPERATION, None) + if oper == "off": + oper = "idle" + return oper def set_temperature(self, **kwargs): """Set target temperature.""" @@ -245,29 +270,84 @@ class HoneywellUSThermostat(ClimateDevice): return import somecomfort try: - if self._device.system_mode == 'cool': - self._device.setpoint_cool = temperature - else: - self._device.setpoint_heat = temperature + # Get current mode + mode = self._device.system_mode + # Set hold if this is not the case + if getattr(self._device, "hold_{}".format(mode)) is False: + # Get next period key + next_period_key = '{}NextPeriod'.format(mode.capitalize()) + # Get next period raw value + next_period = self._device.raw_ui_data.get(next_period_key) + # Get next period time + hour, minute = divmod(next_period * 15, 60) + # Set hold time + setattr(self._device, + "hold_{}".format(mode), + datetime.time(hour, minute)) + # Set temperature + setattr(self._device, + "setpoint_{}".format(mode), + temperature) except somecomfort.SomeComfortError: _LOGGER.error('Temperature %.1f out of range', temperature) @property def device_state_attributes(self): """Return the device specific state attributes.""" - return { + import somecomfort + data = { ATTR_FAN: (self.is_fan_on and 'running' or 'idle'), - ATTR_FANMODE: self._device.fan_mode, - ATTR_SYSTEM_MODE: self._device.system_mode, + ATTR_FAN_MODE: self._device.fan_mode, + ATTR_OPERATION_MODE: self._device.system_mode, } + data[ATTR_FAN_LIST] = somecomfort.FAN_MODES + data[ATTR_OPERATION_LIST] = somecomfort.SYSTEM_MODES + return data + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away def turn_away_mode_on(self): - """Turn away on.""" - pass + """Turn away on. + + Somecomfort does have a proprietary away mode, but it doesn't really + work the way it should. For example: If you set a temperature manually + it doesn't get overwritten when away mode is switched on. + """ + self._away = True + import somecomfort + try: + # Get current mode + mode = self._device.system_mode + except somecomfort.SomeComfortError: + _LOGGER.error('Can not get system mode') + return + try: + + # Set permanent hold + setattr(self._device, + "hold_{}".format(mode), + True) + # Set temperature + setattr(self._device, + "setpoint_{}".format(mode), + getattr(self, "_{}_away_temp".format(mode))) + except somecomfort.SomeComfortError: + _LOGGER.error('Temperature %.1f out of range', + getattr(self, "_{}_away_temp".format(mode))) def turn_away_mode_off(self): """Turn away off.""" - pass + self._away = False + import somecomfort + try: + # Disabling all hold modes + self._device.hold_cool = False + self._device.hold_heat = False + except somecomfort.SomeComfortError: + _LOGGER.error('Can not stop hold mode') def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: """Set the system mode (Cool, Heat, etc).""" @@ -276,4 +356,48 @@ class HoneywellUSThermostat(ClimateDevice): def update(self): """Update the state.""" - self._device.refresh() + import somecomfort + retries = 3 + while retries > 0: + try: + self._device.refresh() + break + except (somecomfort.client.APIRateLimited, OSError, + requests.exceptions.ReadTimeout) as exp: + retries -= 1 + if retries == 0: + raise exp + if not self._retry(): + raise exp + _LOGGER.error("SomeComfort update failed, Retrying " + "- Error: %s", exp) + + def _retry(self): + """Recreate a new somecomfort client. + + When we got an error, the best way to be sure that the next query + will succeed, is to recreate a new somecomfort client. + """ + import somecomfort + try: + self._client = somecomfort.SomeComfort(self._username, + self._password) + except somecomfort.AuthError: + _LOGGER.error('Failed to login to honeywell account %s', + self._username) + return False + except somecomfort.SomeComfortError as ex: + _LOGGER.error('Failed to initialize honeywell client: %s', + str(ex)) + return False + + devices = [device + for location in self._client.locations_by_id.values() + for device in location.devices_by_id.values() + if device.name == self._device.name] + + if len(devices) != 1: + _LOGGER.error('Failed to find device %s', self._device.name) + return False + + self._device = devices[0] diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py index 13d7eb65257..a4cdda2adc4 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -8,6 +8,9 @@ import somecomfort from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.components.climate import ( + ATTR_FAN_MODE, ATTR_OPERATION_MODE, ATTR_FAN_LIST, ATTR_OPERATION_LIST) + import homeassistant.components.climate.honeywell as honeywell @@ -22,15 +25,21 @@ class TestHoneywell(unittest.TestCase): config = { CONF_USERNAME: 'user', CONF_PASSWORD: 'pass', + honeywell.CONF_COOL_AWAY_TEMPERATURE: 18, + honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28, honeywell.CONF_REGION: 'us', } bad_pass_config = { CONF_USERNAME: 'user', + honeywell.CONF_COOL_AWAY_TEMPERATURE: 18, + honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28, honeywell.CONF_REGION: 'us', } bad_region_config = { CONF_USERNAME: 'user', CONF_PASSWORD: 'pass', + honeywell.CONF_COOL_AWAY_TEMPERATURE: 18, + honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28, honeywell.CONF_REGION: 'un', } @@ -65,9 +74,12 @@ class TestHoneywell(unittest.TestCase): self.assertEqual(mock_sc.call_count, 1) self.assertEqual(mock_sc.call_args, mock.call('user', 'pass')) mock_ht.assert_has_calls([ - mock.call(mock_sc.return_value, devices_1[0]), - mock.call(mock_sc.return_value, devices_2[0]), - mock.call(mock_sc.return_value, devices_2[1]), + mock.call(mock_sc.return_value, devices_1[0], 18, 28, + 'user', 'pass'), + mock.call(mock_sc.return_value, devices_2[0], 18, 28, + 'user', 'pass'), + mock.call(mock_sc.return_value, devices_2[1], 18, 28, + 'user', 'pass'), ]) @mock.patch('somecomfort.SomeComfort') @@ -324,8 +336,12 @@ class TestHoneywellUS(unittest.TestCase): """Test the setup method.""" self.client = mock.MagicMock() self.device = mock.MagicMock() + self.cool_away_temp = 18 + self.heat_away_temp = 28 self.honeywell = honeywell.HoneywellUSThermostat( - self.client, self.device) + self.client, self.device, + self.cool_away_temp, self.heat_away_temp, + 'user', 'password') self.device.fan_running = True self.device.name = 'test' @@ -369,11 +385,9 @@ class TestHoneywellUS(unittest.TestCase): def test_set_operation_mode(self: unittest.TestCase) -> None: """Test setting the operation mode.""" self.honeywell.set_operation_mode('cool') - self.assertEqual('cool', self.honeywell.current_operation) self.assertEqual('cool', self.device.system_mode) self.honeywell.set_operation_mode('heat') - self.assertEqual('heat', self.honeywell.current_operation) self.assertEqual('heat', self.device.system_mode) def test_set_temp_fail(self): @@ -386,8 +400,10 @@ class TestHoneywellUS(unittest.TestCase): """Test the attributes.""" expected = { honeywell.ATTR_FAN: 'running', - honeywell.ATTR_FANMODE: 'auto', - honeywell.ATTR_SYSTEM_MODE: 'heat', + ATTR_FAN_MODE: 'auto', + ATTR_OPERATION_MODE: 'heat', + ATTR_FAN_LIST: somecomfort.FAN_MODES, + ATTR_OPERATION_LIST: somecomfort.SYSTEM_MODES, } self.assertEqual(expected, self.honeywell.device_state_attributes) expected['fan'] = 'idle' @@ -400,7 +416,28 @@ class TestHoneywellUS(unittest.TestCase): self.device.fan_mode = None expected = { honeywell.ATTR_FAN: 'idle', - honeywell.ATTR_FANMODE: None, - honeywell.ATTR_SYSTEM_MODE: 'heat', + ATTR_FAN_MODE: None, + ATTR_OPERATION_MODE: 'heat', + ATTR_FAN_LIST: somecomfort.FAN_MODES, + ATTR_OPERATION_LIST: somecomfort.SYSTEM_MODES, } self.assertEqual(expected, self.honeywell.device_state_attributes) + + def test_heat_away_mode(self): + """Test setting the heat away mode.""" + self.honeywell.set_operation_mode('heat') + self.assertFalse(self.honeywell.is_away_mode_on) + self.honeywell.turn_away_mode_on() + self.assertTrue(self.honeywell.is_away_mode_on) + self.assertEqual(self.device.setpoint_heat, self.heat_away_temp) + self.assertEqual(self.device.hold_heat, True) + + self.honeywell.turn_away_mode_off() + self.assertFalse(self.honeywell.is_away_mode_on) + self.assertEqual(self.device.hold_heat, False) + + def test_retry(self): + """Test retry connection.""" + old_device = self.honeywell._device + self.honeywell._retry() + self.assertEqual(self.honeywell._device, old_device) From f3870a8a48535132ac8b3a434a7ee1215a08bdb0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Mar 2017 08:50:41 +0100 Subject: [PATCH 087/198] Template binary_sensor change flow / add restore (#6343) * Template binary_sensor change flow / add restore * fix lint --- .../components/binary_sensor/template.py | 30 +++++-- .../components/binary_sensor/test_template.py | 82 +++++++++++++++---- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 35666e0ea55..fbdfa2eb4de 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -15,12 +15,14 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, - CONF_SENSOR_CLASS, CONF_SENSORS, CONF_DEVICE_CLASS) + CONF_SENSOR_CLASS, CONF_SENSORS, CONF_DEVICE_CLASS, + EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import get_deprecated +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -83,14 +85,30 @@ class BinarySensorTemplate(BinarySensorDevice): self._device_class = device_class self._template = value_template self._state = None + self._entities = entity_ids + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state: + self._state = state.state @callback def template_bsensor_state_listener(entity, old_state, new_state): """Called when the target device changes state.""" - hass.async_add_job(self.async_update_ha_state, True) + self.hass.async_add_job(self.async_update_ha_state(True)) - async_track_state_change( - hass, entity_ids, template_bsensor_state_listener) + @callback + def template_bsensor_startup(event): + """Update template on startup.""" + async_track_state_change( + self.hass, self._entities, template_bsensor_state_listener) + + self.hass.async_add_job(self.async_update_ha_state(True)) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_bsensor_startup) @property def name(self): diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index cb526851710..77818c339e2 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,15 +1,19 @@ """The tests for the Template Binary sensor platform.""" +import asyncio import unittest from unittest import mock -from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL +from homeassistant.core import CoreState, State +from homeassistant.const import MATCH_ALL import homeassistant.bootstrap as bootstrap from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr from homeassistant.util.async import run_callback_threadsafe +from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) class TestBinarySensorTemplate(unittest.TestCase): @@ -26,8 +30,7 @@ class TestBinarySensorTemplate(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - @mock.patch.object(template, 'BinarySensorTemplate') - def test_setup(self, mock_template): + def test_setup(self): """"Test the setup.""" config = { 'binary_sensor': { @@ -117,18 +120,34 @@ class TestBinarySensorTemplate(unittest.TestCase): def test_event(self): """"Test the event.""" - vs = run_callback_threadsafe( - self.hass.loop, template.BinarySensorTemplate, - self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL - ).result() - vs.update_ha_state() + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + }, + }, + }, + } + with assert_setup_component(1): + assert bootstrap.setup_component( + self.hass, 'binary_sensor', config) + + self.hass.start() self.hass.block_till_done() - 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 + state = self.hass.states.get('binary_sensor.test') + assert state.state == 'off' + + self.hass.states.set('sensor.test_state', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + assert state.state == 'on' @mock.patch('homeassistant.helpers.template.Template.render') def test_update_template_error(self, mock_render): @@ -143,3 +162,38 @@ class TestBinarySensorTemplate(unittest.TestCase): mock_render.side_effect = TemplateError( "UndefinedError: 'None' has no attribute") vs.update() + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + hass.data[DATA_RESTORE_CACHE] = { + 'binary_sensor.test': State('binary_sensor.test', 'on'), + } + + hass.state = CoreState.starting + mock_component(hass, 'recorder') + + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + }, + }, + }, + } + yield from bootstrap.async_setup_component(hass, 'binary_sensor', config) + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + yield from hass.async_start() + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' From e14d6f11c643ed80f11b9dc7dcbbd090c50fdd9f Mon Sep 17 00:00:00 2001 From: Duoxilian Date: Thu, 2 Mar 2017 01:52:31 -0600 Subject: [PATCH 088/198] Additional support for ecobee hold mode (#6258) * Integrate suggestion in #5590 by nordlead2005. This change has been sitting in limbo for over a month, but it is a good idea. I don't mean to step on nordlead2005's toes, but we need to make progress. * Use defined constant for TEMPERATURE_HOLD * Integrate handling of vacation into hold mode. Canceling vacation hold requires an update to the external pyecobee library. Creation of vacation is not supported (it would be straightforward in the code, but a complex user interface would be required, similar to what is now done in the ecobee thermostat). * Add capability to retrieve list of defined climates from ecobee. * The mode() method used to return the system mode in internal representation. However, the user sees a different notation in the ecobee thermostat. Seeing some internal name is particularly weired with user-defined climates, where these are named "smart1", "smart2", etc., instead of the name the user has defined. Return the user-defined name instead. This change might break some user interfaces but is easily remedied (e.g., use "Away" instead of "away"). * Simplify is_away_mode_on(). * Correction of erroneously indented else statement. * Change comment as flake8 gets confused. --- homeassistant/components/climate/ecobee.py | 108 +++++++++++---------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index 18ccff459b0..c9403fbf2ed 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -25,6 +25,8 @@ ATTR_FAN_MIN_ON_TIME = 'fan_min_on_time' ATTR_RESUME_ALL = 'resume_all' DEFAULT_RESUME_ALL = False +TEMPERATURE_HOLD = 'temp' +VACATION_HOLD = 'vacation' DEPENDENCIES = ['ecobee'] @@ -112,6 +114,8 @@ class Thermostat(ClimateDevice): self.thermostat_index) self._name = self.thermostat['name'] self.hold_temp = hold_temp + self.vacation = None + self._climate_list = self.climate_list self._operation_list = ['auto', 'auxHeatOnly', 'cool', 'heat', 'off'] self.update_without_throttle = False @@ -187,29 +191,30 @@ class Thermostat(ClimateDevice): def current_hold_mode(self): """Return current hold mode.""" events = self.thermostat['events'] - if any((event['holdClimateRef'] == 'away' and - int(event['endDate'][0:4])-int(event['startDate'][0:4]) <= 1) - or event['type'] == 'autoAway' - for event in events): - # away hold is auto away or a temporary hold from away climate - hold = 'away' - elif any(event['holdClimateRef'] == 'away' and - int(event['endDate'][0:4])-int(event['startDate'][0:4]) > 1 - for event in events): - # a permanent away is not considered a hold, but away_mode - hold = None - elif any(event['holdClimateRef'] == 'home' or - event['type'] == 'autoHome' - for event in events): - # home mode is auto home or any home hold - hold = 'home' - elif any(event['type'] == 'hold' and event['running'] - for event in events): - hold = 'temp' - # temperature hold is any other hold not based on climate - else: - hold = None - return hold + for event in events: + if event['running']: + if event['type'] == 'hold': + if event['holdClimateRef'] == 'away': + if int(event['endDate'][0:4]) - \ + int(event['startDate'][0:4]) <= 1: + # a temporary hold from away climate is a hold + return 'away' + else: + # a premanent hold from away climate is away_mode + return None + elif event['holdClimateRef'] != "": + # any other hold based on climate + return event['holdClimateRef'] + else: + # any hold not based on a climate is a temp hold + return TEMPERATURE_HOLD + elif event['type'].startswith('auto'): + # all auto modes are treated as holds + return event['type'][4:].lower() + elif event['type'] == 'vacation': + self.vacation = event['name'] + return VACATION_HOLD + return None @property def current_operation(self): @@ -232,8 +237,11 @@ class Thermostat(ClimateDevice): @property def mode(self): - """Return current mode ie. home, away, sleep.""" - return self.thermostat['program']['currentClimateRef'] + """Return current mode, as the user-visible name.""" + cur = self.thermostat['program']['currentClimateRef'] + climates = self.thermostat['program']['climates'] + current = list(filter(lambda x: x['climateRef'] == cur, climates)) + return current[0]['name'] @property def fan_min_on_time(self): @@ -261,52 +269,44 @@ class Thermostat(ClimateDevice): "fan": self.fan, "mode": self.mode, "operation": operation, + "climate_list": self.climate_list, "fan_min_on_time": self.fan_min_on_time } - def is_vacation_on(self): - """Return true if vacation mode is on.""" - events = self.thermostat['events'] - return any(event['type'] == 'vacation' and event['running'] - for event in events) - @property def is_away_mode_on(self): """Return true if away mode is on.""" - events = self.thermostat['events'] - return any(event['holdClimateRef'] == 'away' and - int(event['endDate'][0:4])-int(event['startDate'][0:4]) > 1 - for event in events) + return self.current_hold_mode == 'away' def turn_away_mode_on(self): """Turn away on.""" - self.data.ecobee.set_climate_hold(self.thermostat_index, - "away", 'indefinite') - self.update_without_throttle = True + self.set_hold_mode('away') def turn_away_mode_off(self): """Turn away off.""" - self.data.ecobee.resume_program(self.thermostat_index) - self.update_without_throttle = True + self.set_hold_mode(None) def set_hold_mode(self, hold_mode): - """Set hold mode (away, home, temp).""" + """Set hold mode (away, home, temp, sleep, etc.).""" hold = self.current_hold_mode if hold == hold_mode: # no change, so no action required return - elif hold_mode == 'away': - self.data.ecobee.set_climate_hold(self.thermostat_index, - "away", self.hold_preference()) - elif hold_mode == 'home': - self.data.ecobee.set_climate_hold(self.thermostat_index, - "home", self.hold_preference()) - elif hold_mode == 'temp': - self.set_temp_hold(int(self.current_temperature)) + elif hold_mode == 'None' or hold_mode is None: + if hold == VACATION_HOLD: + self.data.ecobee.delete_vacation(self.thermostat_index, + self.vacation) + else: + self.data.ecobee.resume_program(self.thermostat_index) else: - self.data.ecobee.resume_program(self.thermostat_index) - self.update_without_throttle = True + if hold_mode == TEMPERATURE_HOLD: + self.set_temp_hold(int(self.current_temperature)) + else: + self.data.ecobee.set_climate_hold(self.thermostat_index, + hold_mode, + self.hold_preference()) + self.update_without_throttle = True def set_auto_temp_hold(self, heat_temp, cool_temp): """Set temperature hold in auto mode.""" @@ -382,3 +382,9 @@ class Thermostat(ClimateDevice): # as an indefinite away hold is interpreted as away_mode else: return 'nextTransition' + + @property + def climate_list(self): + """Return the list of climates currently available.""" + climates = self.thermostat['program']['climates'] + return list(map((lambda x: x['name']), climates)) From edd5db296d63a93b63bf5712c768625c73019e8f Mon Sep 17 00:00:00 2001 From: dramamoose Date: Thu, 2 Mar 2017 00:54:45 -0700 Subject: [PATCH 089/198] Update Formulas in Convert XY to RGB (#6322) * Update to Current RGB D65 Conversion As per Philips Hue https://developers.meethue.com/documentation/color-conversions-rgb-xy * Update the source of the XYZ to RGB formulas * Fix Whitespace * Update Whitespace * Update Tests for new Formulas * Update Tests * Update XY_Brightness_to_hsv tests * Update test_color.py --- homeassistant/util/color.py | 11 +++++------ tests/components/light/test_demo.py | 2 +- tests/util/test_color.py | 12 ++++++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 5a7c3b12e04..52d7a9f63aa 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -217,9 +217,8 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]: return round(x, 3), round(y, 3), brightness -# taken from -# https://github.com/benknight/hue-python-rgb-converter/blob/master/rgb_cie.py -# Copyright (c) 2014 Benjamin Knight / MIT License. +# Converted to Python from Obj-C, original source from: +# http://www.developers.meethue.com/documentation/color-conversions-rgb-xy def color_xy_brightness_to_RGB(vX: float, vY: float, ibrightness: int) -> Tuple[int, int, int]: """Convert from XYZ to RGB.""" @@ -236,9 +235,9 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, Z = (Y / vY) * (1 - vX - vY) # Convert to RGB using Wide RGB D65 conversion. - r = X * 1.612 - Y * 0.203 - Z * 0.302 - g = -X * 0.509 + Y * 1.412 + Z * 0.066 - b = X * 0.026 - Y * 0.072 + Z * 0.962 + r = X * 1.656492 - Y * 0.354851 - Z * 0.255038 + g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152 + b = X * 0.051713 - Y * 0.121364 + Z * 1.011530 # Apply reverse gamma correction. r, g, b = map( diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index 391a6d05903..de89d434e89 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -39,7 +39,7 @@ class TestDemoLight(unittest.TestCase): self.assertEqual((.4, .6), state.attributes.get(light.ATTR_XY_COLOR)) self.assertEqual(25, state.attributes.get(light.ATTR_BRIGHTNESS)) self.assertEqual( - (82, 91, 0), state.attributes.get(light.ATTR_RGB_COLOR)) + (76, 95, 0), state.attributes.get(light.ATTR_RGB_COLOR)) self.assertEqual('rainbow', state.attributes.get(light.ATTR_EFFECT)) light.turn_on( self.hass, ENTITY_LIGHT, rgb_color=(251, 252, 253), diff --git a/tests/util/test_color.py b/tests/util/test_color.py index ada7ccc072e..d7560d4f7bf 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -27,16 +27,16 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((0, 0, 0), color_util.color_xy_brightness_to_RGB(1, 1, 0)) - self.assertEqual((255, 235, 214), + self.assertEqual((255, 243, 222), color_util.color_xy_brightness_to_RGB(.35, .35, 255)) - self.assertEqual((255, 0, 45), + self.assertEqual((255, 0, 60), color_util.color_xy_brightness_to_RGB(1, 0, 255)) self.assertEqual((0, 255, 0), color_util.color_xy_brightness_to_RGB(0, 1, 255)) - self.assertEqual((0, 83, 255), + self.assertEqual((0, 63, 255), color_util.color_xy_brightness_to_RGB(0, 0, 255)) def test_color_RGB_to_hsv(self): @@ -61,16 +61,16 @@ class TestColorUtil(unittest.TestCase): self.assertEqual(color_util.color_RGB_to_hsv(0, 0, 0), color_util.color_xy_brightness_to_hsv(1, 1, 0)) - self.assertEqual(color_util.color_RGB_to_hsv(255, 235, 214), + self.assertEqual(color_util.color_RGB_to_hsv(255, 243, 222), color_util.color_xy_brightness_to_hsv(.35, .35, 255)) - self.assertEqual(color_util.color_RGB_to_hsv(255, 0, 45), + self.assertEqual(color_util.color_RGB_to_hsv(255, 0, 60), color_util.color_xy_brightness_to_hsv(1, 0, 255)) self.assertEqual(color_util.color_RGB_to_hsv(0, 255, 0), color_util.color_xy_brightness_to_hsv(0, 1, 255)) - self.assertEqual(color_util.color_RGB_to_hsv(0, 83, 255), + self.assertEqual(color_util.color_RGB_to_hsv(0, 63, 255), color_util.color_xy_brightness_to_hsv(0, 0, 255)) def test_rgb_hex_to_rgb_list(self): From e2aa024a059b8c6622e6f7b1f8183e3b6345e958 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Mar 2017 00:06:18 -0800 Subject: [PATCH 090/198] Update coveragerc --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index c88856c724e..cbe868954b4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -263,6 +263,7 @@ omit = homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py + homeassistant/components/notify/ciscospark.py homeassistant/components/notify/discord.py homeassistant/components/notify/facebook.py homeassistant/components/notify/free_mobile.py From bf7aecce901ef4553892fc28e894cde4583a3c9b Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Thu, 2 Mar 2017 03:07:50 -0500 Subject: [PATCH 091/198] Use dynamic ports for test instances (#6232) --- tests/common.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/common.py b/tests/common.py index a1635e3387c..34cd9765695 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,6 +10,7 @@ import threading from contextlib import contextmanager from aiohttp import web +from aiohttp.test_utils import unused_port as get_test_instance_port # noqa from homeassistant import core as ha, loader from homeassistant.bootstrap import setup_component, DATA_SETUP @@ -23,7 +24,7 @@ import homeassistant.util.yaml as yaml from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, - ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_STOP) + ATTR_DISCOVERED, EVENT_HOMEASSISTANT_STOP) from homeassistant.components import sun, mqtt, recorder from homeassistant.components.http.auth import auth_middleware from homeassistant.components.http.const import ( @@ -31,7 +32,6 @@ from homeassistant.components.http.const import ( from homeassistant.util.async import ( run_callback_threadsafe, run_coroutine_threadsafe) -_TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) INST_COUNT = 0 @@ -139,18 +139,6 @@ def async_test_home_assistant(loop): return hass -def get_test_instance_port(): - """Return unused port for running test instance. - - The socket that holds the default port does not get released when we stop - HA in a different test case. Until I have figured out what is going on, - let's run each test on a different port. - """ - global _TEST_INSTANCE_PORT - _TEST_INSTANCE_PORT += 1 - return _TEST_INSTANCE_PORT - - def mock_service(hass, domain, service): """Setup a fake service. From a08539d88d7f15ecc9a0435ecd84d1c2861b83dd Mon Sep 17 00:00:00 2001 From: martinfrancois Date: Thu, 2 Mar 2017 09:10:49 +0100 Subject: [PATCH 092/198] Added support for multiple codes executed in a row (#5908) * Added support for multiple codes executed in a row now codes can be specified either by simply providing a single code, which will then be sent like usual, or multiple codes can be executed in a row, specified in a comma delimited format in the configuration.yaml. For example: 111111,222222,333333,444444 would mean 111111 would be sent first, followed by 222222 and 333333 and 444444. * rpi_rf: added line breaks to not exceed 79 characters per line * include validation for correct formatting of codes added regex which only allows either a single number (like 1252456245) or a sequence of commas followed by another number. * added line breaks to not exceed 79 characters per line * fix for 'continuation line under-indented for visual indent' * another try at 'continuation line under-indented for visual indent' * changed from regex to list for easier maintainability * removed unnecessary splitting of strings --- homeassistant/components/switch/rpi_rf.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 866fea0df0b..0a6d487c331 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -27,8 +27,10 @@ DEFAULT_PROTOCOL = 1 DEFAULT_SIGNAL_REPETITIONS = 10 SWITCH_SCHEMA = vol.Schema({ - vol.Required(CONF_CODE_OFF): cv.positive_int, - vol.Required(CONF_CODE_ON): cv.positive_int, + vol.Required(CONF_CODE_OFF): + vol.All(cv.ensure_list_csv, [cv.positive_int]), + vol.Required(CONF_CODE_ON): + vol.All(cv.ensure_list_csv, [cv.positive_int]), vol.Optional(CONF_PULSELENGTH): cv.positive_int, vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS): cv.positive_int, @@ -101,13 +103,12 @@ class RPiRFSwitch(SwitchDevice): """Return true if device is on.""" return self._state - def _send_code(self, code, protocol, pulselength): - """Send the code with a specified pulselength.""" - _LOGGER.info("Sending code: %s", code) - res = self._rfdevice.tx_code(code, protocol, pulselength) - if not res: - _LOGGER.error("Sending code %s failed", code) - return res + def _send_code(self, code_list, protocol, pulselength): + """Send the code(s) with a specified pulselength.""" + _LOGGER.info("Sending code(s): %s", code_list) + for code in code_list: + self._rfdevice.tx_code(code, protocol, pulselength) + return True def turn_on(self): """Turn the switch on.""" From bae6333c26d866f60ee893637272fa0a5e8b1909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Thu, 2 Mar 2017 09:12:55 +0100 Subject: [PATCH 093/198] Use push updates in Apple TV (#6323) * Use push updates in Apple TV * Fix review comments --- .../components/media_player/apple_tv.py | 84 +++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 566ad7d6933..ad0adfb008a 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -8,7 +8,6 @@ import asyncio import logging import hashlib -import aiohttp import voluptuous as vol from homeassistant.core import callback @@ -19,13 +18,13 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_VIDEO, MEDIA_TYPE_TVSHOW) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, CONF_HOST, - STATE_OFF, CONF_NAME) + STATE_OFF, CONF_NAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyatv==0.1.4'] +REQUIREMENTS = ['pyatv==0.2.1'] _LOGGER = logging.getLogger(__name__) @@ -73,7 +72,14 @@ def async_setup_platform(hass, config, async_add_entities, atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session) entity = AppleTvDevice(atv, name, start_off) - yield from async_add_entities([entity], update_before_add=True) + @callback + def on_hass_stop(event): + """Stop push updates when hass stops.""" + atv.push_updater.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + + yield from async_add_entities([entity]) class AppleTvDevice(MediaPlayerDevice): @@ -86,18 +92,34 @@ class AppleTvDevice(MediaPlayerDevice): self._is_off = is_off self._playing = None self._artwork_hash = None + self._atv.push_updater.listener = self + + @asyncio.coroutine + def async_added_to_hass(self): + """Called when entity is about to be added to HASS.""" + self._atv.push_updater.start() @callback def _set_power_off(self, is_off): self._playing = None self._artwork_hash = None self._is_off = is_off + if is_off: + self._atv.push_updater.stop() + else: + self._atv.push_updater.start() + self.hass.async_add_job(self.async_update_ha_state()) @property def name(self): """Return the name of the device.""" return self._name + @property + def should_poll(self): + """No polling needed.""" + return False + @property def state(self): """Return the state of the device.""" @@ -120,29 +142,19 @@ class AppleTvDevice(MediaPlayerDevice): else: return STATE_STANDBY # Bad or unknown state? - @asyncio.coroutine - def async_update(self): - """Retrieve latest state.""" - if self._is_off: - return + @callback + def playstatus_update(self, updater, playing): + """Print what is currently playing when it changes.""" + if self.state == STATE_IDLE: + self._artwork_hash = None + elif self._has_playing_media_changed(playing): + base = str(playing.title) + str(playing.artist) + \ + str(playing.album) + str(playing.total_time) + self._artwork_hash = hashlib.md5( + base.encode('utf-8')).hexdigest() - from pyatv import exceptions - try: - playing = yield from self._atv.metadata.playing() - - if self._has_playing_media_changed(playing): - base = str(playing.title) + str(playing.artist) + \ - str(playing.album) + str(playing.total_time) - self._artwork_hash = hashlib.md5( - base.encode('utf-8')).hexdigest() - - self._playing = playing - except exceptions.AuthenticationError as ex: - _LOGGER.warning('%s (bad login id?)', str(ex)) - except aiohttp.errors.ClientOSError as ex: - _LOGGER.error('failed to connect to Apple TV (%s)', str(ex)) - except asyncio.TimeoutError: - _LOGGER.warning('timed out while connecting to Apple TV') + self._playing = playing + self.hass.async_add_job(self.async_update_ha_state()) def _has_playing_media_changed(self, new_playing): if self._playing is None: @@ -151,6 +163,21 @@ class AppleTvDevice(MediaPlayerDevice): return new_playing.media_type != old_playing.media_type or \ new_playing.title != old_playing.title + @callback + def playstatus_error(self, updater, exception): + """Inform about an error and restart push updates.""" + _LOGGER.warning('A %s error occurred: %s', + exception.__class__, exception) + + # This will wait 10 seconds before restarting push updates. If the + # connection continues to fail, it will flood the log (every 10 + # seconds) until it succeeds. A better approach should probably be + # implemented here later. + updater.start(initial_delay=10) + self._playing = None + self._artwork_hash = None + self.hass.async_add_job(self.async_update_ha_state()) + @property def media_content_type(self): """Content type of current playing media.""" @@ -191,7 +218,8 @@ class AppleTvDevice(MediaPlayerDevice): @property def media_image_hash(self): """Hash value for media image.""" - return self._artwork_hash + if self.state != STATE_IDLE: + return self._artwork_hash @asyncio.coroutine def async_get_media_image(self): @@ -207,6 +235,8 @@ class AppleTvDevice(MediaPlayerDevice): title = self._playing.title return title if title else "No title" + return 'Not connected to Apple TV' + @property def supported_features(self): """Flag media player features that are supported.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4cc886d90cb..e1591ceb40c 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -454,7 +454,7 @@ pyasn1-modules==0.0.8 pyasn1==0.2.2 # homeassistant.components.media_player.apple_tv -pyatv==0.1.4 +pyatv==0.2.1 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From 72fe50bef691f3601fc206d54298f8de3f734d76 Mon Sep 17 00:00:00 2001 From: Jeff Wilson Date: Thu, 2 Mar 2017 03:14:20 -0500 Subject: [PATCH 094/198] Fix command sudo not found error in dev Dockerfile (#6346) --- virtualization/Docker/Dockerfile.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 4c75db36acc..62c9f9f6596 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -30,7 +30,7 @@ RUN pip3 install --no-cache-dir -r requirements_all.txt && \ # BEGIN: Development additions # Install nodejs -RUN curl -sL https://deb.nodesource.com/setup_7.x | sudo -E bash - && \ +RUN curl -sL https://deb.nodesource.com/setup_7.x | bash - && \ apt-get install -y nodejs # Install tox From 8743f23f1369d75d43eb77fe17cc3281f32da7c1 Mon Sep 17 00:00:00 2001 From: Alan Fischer Date: Thu, 2 Mar 2017 01:22:38 -0700 Subject: [PATCH 095/198] Fix calendar authentication text, and handle calendar events without summaries. (#6337) * Fixed google authorization text * Let calendar handle events without a summary --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/google.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 1aefc11d9c0..70477198ea0 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -155,7 +155,7 @@ class CalendarEventDevice(Entity): start = _get_date(self.data.event['start']) end = _get_date(self.data.event['end']) - summary = self.data.event['summary'] + summary = self.data.event.get('summary', '') # check if we have an offset tag in the message # time is HH:MM or MM diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 10a335ff7a2..e72eca9e7fa 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -118,8 +118,8 @@ def do_authentication(hass, config): return False persistent_notification.create( - hass, 'In order to authorize Home-Assistant to view your calendars' - 'You must visit: {} and enter' + hass, 'In order to authorize Home-Assistant to view your calendars ' + 'you must visit: {} and enter ' 'code: {}'.format(dev_flow.verification_url, dev_flow.verification_url, dev_flow.user_code), From 50887e7e2ce146791a87e1014864cf731e51bf4b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Mar 2017 10:20:57 +0100 Subject: [PATCH 096/198] Move dispatcher out of init. (#6355) --- homeassistant/components/alarm_control_panel/envisalink.py | 7 +++++-- homeassistant/components/binary_sensor/envisalink.py | 5 ++++- homeassistant/components/sensor/envisalink.py | 7 +++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index 248b0124d77..25f9257f393 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -94,10 +94,13 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): _LOGGER.debug("Setting up alarm: %s", alarm_name) super().__init__(alarm_name, info, controller) + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" async_dispatcher_connect( - hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) + self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) async_dispatcher_connect( - hass, SIGNAL_PARTITION_UPDATE, self._update_callback) + self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback) @callback def _update_callback(self, partition): diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index acc71da3f46..22a3256f9fe 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -52,8 +52,11 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): _LOGGER.debug('Setting up zone: ' + zone_name) super().__init__(zone_name, info, controller) + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" async_dispatcher_connect( - hass, SIGNAL_ZONE_UPDATE, self._update_callback) + self.hass, SIGNAL_ZONE_UPDATE, self._update_callback) @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/envisalink.py b/homeassistant/components/sensor/envisalink.py index 1a870114d65..9803f675913 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -49,10 +49,13 @@ class EnvisalinkSensor(EnvisalinkDevice, Entity): _LOGGER.debug('Setting up sensor for partition: ' + partition_name) super().__init__(partition_name + ' Keypad', info, controller) + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" async_dispatcher_connect( - hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) + self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) async_dispatcher_connect( - hass, SIGNAL_PARTITION_UPDATE, self._update_callback) + self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback) @property def icon(self): From 597ae2e71630e83423d8cbe389b7ef62bdb73410 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 2 Mar 2017 13:36:40 +0200 Subject: [PATCH 097/198] Zwave: Add remove/replace failed node services. (#6248) * Zwave: Add remove/replace failed node services. * Fix text --- homeassistant/components/zwave/__init__.py | 25 ++++++++++++++++++-- homeassistant/components/zwave/const.py | 2 ++ homeassistant/components/zwave/services.yaml | 12 ++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index dacc7549c58..bd6394867c2 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -156,7 +156,7 @@ PRINT_CONFIG_PARAMETER_SCHEMA = vol.Schema({ vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), }) -PRINT_NODE_SCHEMA = vol.Schema({ +NODE_SERVICE_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), }) @@ -525,6 +525,18 @@ def setup(hass, config): _LOGGER.info( "Renamed ZWave node %d to %s", node_id, name) + def remove_failed_node(service): + """Remove failed node.""" + node_id = service.data.get(const.ATTR_NODE_ID) + _LOGGER.info('Trying to remove zwave node %d', node_id) + NETWORK.controller.remove_failed_node(node_id) + + def replace_failed_node(service): + """Replace failed node.""" + node_id = service.data.get(const.ATTR_NODE_ID) + _LOGGER.info('Trying to replace zwave node %d', node_id) + NETWORK.controller.replace_failed_node(node_id) + def set_config_parameter(service): """Set a config parameter to a node.""" node_id = service.data.get(const.ATTR_NODE_ID) @@ -671,6 +683,15 @@ def setup(hass, config): descriptions[ const.SERVICE_PRINT_CONFIG_PARAMETER], schema=PRINT_CONFIG_PARAMETER_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_REMOVE_FAILED_NODE, + remove_failed_node, + descriptions[const.SERVICE_REMOVE_FAILED_NODE], + schema=NODE_SERVICE_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_REPLACE_FAILED_NODE, + replace_failed_node, + descriptions[const.SERVICE_REPLACE_FAILED_NODE], + schema=NODE_SERVICE_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_CHANGE_ASSOCIATION, change_association, descriptions[ @@ -685,7 +706,7 @@ def setup(hass, config): print_node, descriptions[ const.SERVICE_PRINT_NODE], - schema=PRINT_NODE_SCHEMA) + schema=NODE_SERVICE_SCHEMA) # Setup autoheal if autoheal: diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index 881f20cd0fc..52ccdc0a752 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -28,6 +28,8 @@ SERVICE_TEST_NETWORK = "test_network" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_PRINT_CONFIG_PARAMETER = "print_config_parameter" SERVICE_PRINT_NODE = "print_node" +SERVICE_REMOVE_FAILED_NODE = "remove_failed_node" +SERVICE_REPLACE_FAILED_NODE = "replace_failed_node" SERVICE_SET_WAKEUP = "set_wakeup" SERVICE_STOP_NETWORK = "stop_network" SERVICE_START_NETWORK = "start_network" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 852146421e9..08cd8069d83 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -27,6 +27,18 @@ heal_network: remove_node: description: Remove a node from the Z-Wave network. Refer to OZW.log for details. +remove_failed_node: + descsription: This command will remove a failed node from the network. The node should be on the controllers failed nodes list, otherwise this command will fail. Refer to OZW.log for details. + fields: + node_id: + description: Node id of the device to remove (integer). + +replace_failed_node: + descsription: Replace a failed node with another. If the node is not in the controller's failed nodes list, or the node responds, this command will fail. Refer to OZW.log for details. + fields: + node_id: + description: Node id of the device to replace (integer). + set_config_parameter: description: Set a config parameter to a node on the Z-Wave network. fields: From 55dc483c912e52f616a6bf77faeac9b8bbf3f154 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Mar 2017 14:09:53 +0100 Subject: [PATCH 098/198] Template switch change flow / add restore (#6356) * Template switch change flow / add restore * fix tests * fix binary_sensor template --- .../components/binary_sensor/template.py | 4 +- homeassistant/components/switch/template.py | 27 ++++- tests/components/sensor/test_template.py | 11 +++ tests/components/switch/test_template.py | 98 ++++++++++++++++++- 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index fbdfa2eb4de..396f591923b 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_SENSOR_CLASS, CONF_SENSORS, CONF_DEVICE_CLASS, - EVENT_HOMEASSISTANT_START) + EVENT_HOMEASSISTANT_START, STATE_ON) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import get_deprecated @@ -92,7 +92,7 @@ class BinarySensorTemplate(BinarySensorDevice): """Register callbacks.""" state = yield from async_get_last_state(self.hass, self.entity_id) if state: - self._state = state.state + self._state = state.state == STATE_ON @callback def template_bsensor_state_listener(entity, old_state, new_state): diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 91ac16fe06c..4ea2d82388d 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -14,12 +14,13 @@ from homeassistant.components.switch import ( ENTITY_ID_FORMAT, SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, - ATTR_ENTITY_ID, CONF_SWITCHES) + ATTR_ENTITY_ID, CONF_SWITCHES, EVENT_HOMEASSISTANT_START) from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.script import Script -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) _VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] @@ -88,14 +89,30 @@ class SwitchTemplate(SwitchDevice): self._on_script = Script(hass, on_action) self._off_script = Script(hass, off_action) self._state = False + self._entities = entity_ids + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state: + self._state = state.state == STATE_ON @callback def template_switch_state_listener(entity, old_state, new_state): """Called when the target device changes state.""" - hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_update_ha_state(True)) - async_track_state_change( - hass, entity_ids, template_switch_state_listener) + @callback + def template_switch_startup(event): + """Update template on startup.""" + async_track_state_change( + self.hass, self._entities, template_switch_state_listener) + + self.hass.async_add_job(self.async_update_ha_state(True)) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_switch_startup) @property def name(self): diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 7ba4ca136e0..adfdc08d510 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -39,6 +39,7 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'It .' @@ -68,6 +69,7 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() state = self.hass.states.get('sensor.test_template_sensor') assert 'icon' not in state.attributes @@ -93,6 +95,7 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() assert self.hass.states.all() == [] def test_template_attribute_missing(self): @@ -111,6 +114,7 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'unknown' @@ -131,6 +135,8 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_invalid_sensor_does_not_create(self): @@ -146,6 +152,7 @@ class TestTemplateSensor: }) self.hass.start() + assert self.hass.states.all() == [] def test_no_sensors_does_not_create(self): @@ -158,6 +165,8 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_missing_template_does_not_create(self): @@ -176,6 +185,8 @@ class TestTemplateSensor: }) self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index 2f67564e6e8..dabdaa2b4d7 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -1,12 +1,14 @@ """The tests for the Template switch platform.""" -from homeassistant.core import callback +import asyncio + +from homeassistant.core import callback, State, CoreState import homeassistant.bootstrap as bootstrap import homeassistant.components as core -from homeassistant.const import ( - STATE_ON, - STATE_OFF) +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) class TestTemplateSwitch: @@ -55,6 +57,9 @@ class TestTemplateSwitch: } }) + self.hass.start() + self.hass.block_till_done() + state = self.hass.states.set('switch.test_state', STATE_ON) self.hass.block_till_done() @@ -90,6 +95,9 @@ class TestTemplateSwitch: } }) + self.hass.start() + self.hass.block_till_done() + state = self.hass.states.get('switch.test_template_switch') assert state.state == STATE_ON @@ -116,6 +124,9 @@ class TestTemplateSwitch: } }) + self.hass.start() + self.hass.block_till_done() + state = self.hass.states.get('switch.test_template_switch') assert state.state == STATE_OFF @@ -141,6 +152,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_invalid_name_does_not_create(self): @@ -165,6 +180,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_invalid_switch_does_not_create(self): @@ -178,6 +197,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_no_switches_does_not_create(self): @@ -188,6 +211,10 @@ class TestTemplateSwitch: 'platform': 'template' } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_missing_template_does_not_create(self): @@ -212,6 +239,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_missing_on_does_not_create(self): @@ -236,6 +267,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_missing_off_does_not_create(self): @@ -260,6 +295,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_on_action(self): @@ -282,6 +321,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + self.hass.states.set('switch.test_state', STATE_OFF) self.hass.block_till_done() @@ -314,6 +357,10 @@ class TestTemplateSwitch: } } }) + + self.hass.start() + self.hass.block_till_done() + self.hass.states.set('switch.test_state', STATE_ON) self.hass.block_till_done() @@ -324,3 +371,44 @@ class TestTemplateSwitch: self.hass.block_till_done() assert len(self.calls) == 1 + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + hass.data[DATA_RESTORE_CACHE] = { + 'switch.test_template_switch': + State('switch.test_template_switch', 'on'), + } + + hass.state = CoreState.starting + mock_component(hass, 'recorder') + + yield from bootstrap.async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'template', + 'switches': { + 'test_template_switch': { + 'value_template': + "{{ states.switch.test_state.state }}", + 'turn_on': { + 'service': 'switch.turn_on', + 'entity_id': 'switch.test_state' + }, + 'turn_off': { + 'service': 'switch.turn_off', + 'entity_id': 'switch.test_state' + }, + } + } + } + }) + + state = hass.states.get('switch.test_template_switch') + assert state.state == 'on' + + yield from hass.async_start() + yield from hass.async_block_till_done() + + state = hass.states.get('switch.test_template_switch') + assert state.state == 'unavailable' From c32300a3868aceb1c4f2ba5a17f69d6ba9651baa Mon Sep 17 00:00:00 2001 From: Jan Losinski Date: Thu, 2 Mar 2017 14:18:44 +0100 Subject: [PATCH 099/198] Bump limitlessled dependency to 1.0.5. (#6334) This fixes issue #6295. --- homeassistant/components/light/limitlessled.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 86d72baeada..23d0716e0b4 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['limitlessled==1.0.4'] +REQUIREMENTS = ['limitlessled==1.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e1591ceb40c..4bf689d904c 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -337,7 +337,7 @@ libsoundtouch==0.1.0 liffylights==0.9.4 # homeassistant.components.light.limitlessled -limitlessled==1.0.4 +limitlessled==1.0.5 # homeassistant.components.media_player.liveboxplaytv liveboxplaytv==1.4.9 From 09ff9cb08e44c9d52481f2dc058eca9becbe7454 Mon Sep 17 00:00:00 2001 From: Rowan Date: Thu, 2 Mar 2017 14:58:35 +0000 Subject: [PATCH 100/198] Updated to catch timeout error --- .../components/sensor/steam_online.py | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index c5427e7b8ba..8a0289306a8 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -4,6 +4,8 @@ Sensor for Steam account status. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.steam_online/ """ +import logging + import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -13,6 +15,8 @@ import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['steamodd==4.21'] +_LOGGER = logging.getLogger(__name__) + CONF_ACCOUNTS = 'accounts' ICON = 'mdi:steam' @@ -46,7 +50,7 @@ class SteamSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self._profile.persona + return self._name @property def entity_id(self): @@ -61,19 +65,28 @@ class SteamSensor(Entity): # pylint: disable=no-member def update(self): """Update device state.""" - self._profile = self._steamod.user.profile(self._account) - if self._profile.current_game[2] is None: - self._game = 'None' - else: - self._game = self._profile.current_game[2] - self._state = { - 1: 'Online', - 2: 'Busy', - 3: 'Away', - 4: 'Snooze', - 5: 'Trade', - 6: 'Play', - }.get(self._profile.status, 'Offline') + try: + self._profile = self._steamod.user.profile(self._account) + if self._profile.current_game[2] is None: + self._game = 'None' + else: + self._game = self._profile.current_game[2] + self._state = { + 1: 'Online', + 2: 'Busy', + 3: 'Away', + 4: 'Snooze', + 5: 'Trade', + 6: 'Play', + }.get(self._profile.status, 'Offline') + self._name = self._profile.persona + self._avatar = self._profile.avatar_medium + except self._steamod.api.HTTPTimeoutError as error: + _LOGGER.warning(error) + self._game = 'Unknown' + self._state = 'Unknown' + self._name = 'Unknown' + self._avatar = None @property def device_state_attributes(self): @@ -83,7 +96,7 @@ class SteamSensor(Entity): @property def entity_picture(self): """Avatar of the account.""" - return self._profile.avatar_medium + return self._avatar @property def icon(self): From 3fa8aff78e313e6c3ae5d7dac8fd1c6b45532139 Mon Sep 17 00:00:00 2001 From: Micha LaQua Date: Thu, 2 Mar 2017 16:12:44 +0100 Subject: [PATCH 101/198] snmp: upgrade pysnmp to 4.3.4 (#6359) * snmp: upgrade pysnmp to 4.3.4 fixes https://github.com/home-assistant/home-assistant/issues/6238 * snmp: v4.3.4: add missing definition changes --- homeassistant/components/device_tracker/snmp.py | 2 +- homeassistant/components/sensor/snmp.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index 4cbaa557517..6e8b07e6bab 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.3'] +REQUIREMENTS = ['pysnmp==4.3.4'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 6a991f28898..b72398c3736 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pysnmp==4.3.3'] +REQUIREMENTS = ['pysnmp==4.3.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4bf689d904c..23b29610865 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,7 +558,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp -pysnmp==4.3.3 +pysnmp==4.3.4 # homeassistant.components.media_player.clementine python-clementine-remote==1.0.1 From a5b2fc97590e3e86b11b5fcc0008f5db353ca1bf Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Mar 2017 17:27:45 +0100 Subject: [PATCH 102/198] Bugfix new async_add_devices function (#6362) --- homeassistant/components/media_player/apple_tv.py | 5 ++--- homeassistant/components/media_player/kodi.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index ad0adfb008a..436730b7041 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -44,8 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup the Apple TV platform.""" import pyatv @@ -79,7 +78,7 @@ def async_setup_platform(hass, config, async_add_entities, hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) - yield from async_add_entities([entity]) + async_add_devices([entity]) class AppleTvDevice(MediaPlayerDevice): diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 5631f7e5da7..071b5068884 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -60,8 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Setup the Kodi platform.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) @@ -84,7 +83,7 @@ def async_setup_platform(hass, config, async_add_entities, password=config.get(CONF_PASSWORD), turn_off_action=config.get(CONF_TURN_OFF_ACTION), websocket=websocket) - yield from async_add_entities([entity], update_before_add=True) + async_add_devices([entity], update_before_add=True) class KodiDevice(MediaPlayerDevice): From 08f9793175df0d90429810d427dc61dae5b64163 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 2 Mar 2017 17:36:26 +0100 Subject: [PATCH 103/198] Restore for input_slider (#6360) --- homeassistant/components/input_slider.py | 13 ++++++++ tests/components/test_input_slider.py | 41 ++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/input_slider.py b/homeassistant/components/input_slider.py index d2453a97d14..9e4faaf3d78 100644 --- a/homeassistant/components/input_slider.py +++ b/homeassistant/components/input_slider.py @@ -14,6 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state DOMAIN = 'input_slider' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -165,6 +166,18 @@ class InputSlider(Entity): ATTR_STEP: self._step } + @asyncio.coroutine + def async_added_to_hass(self): + """Called when entity about to be added to hass.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if not state: + return + + num_value = float(state.state) + if num_value < self._minimum or num_value > self._maximum: + return + self._current_value = num_value + @asyncio.coroutine def async_select_value(self, value): """Select new value.""" diff --git a/tests/components/test_input_slider.py b/tests/components/test_input_slider.py index b927ec48a25..bc8921d000a 100644 --- a/tests/components/test_input_slider.py +++ b/tests/components/test_input_slider.py @@ -1,11 +1,14 @@ """The tests for the Input slider component.""" # pylint: disable=protected-access +import asyncio import unittest -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_component -from homeassistant.bootstrap import setup_component +from homeassistant.core import CoreState, State +from homeassistant.bootstrap import setup_component, async_setup_component from homeassistant.components.input_slider import (DOMAIN, select_value) +from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE class TestInputSlider(unittest.TestCase): @@ -67,3 +70,37 @@ class TestInputSlider(unittest.TestCase): state = self.hass.states.get(entity_id) self.assertEqual(70, float(state.state)) + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + hass.data[DATA_RESTORE_CACHE] = { + 'input_slider.b1': State('input_slider.b1', '70'), + 'input_slider.b2': State('input_slider.b2', '200'), + } + + hass.state = CoreState.starting + mock_component(hass, 'recorder') + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'initial': 50, + 'min': 0, + 'max': 100, + }, + 'b2': { + 'initial': 60, + 'min': 0, + 'max': 100, + }, + }}) + + state = hass.states.get('input_slider.b1') + assert state + assert float(state.state) == 70 + + state = hass.states.get('input_slider.b2') + assert state + assert float(state.state) == 60 From 8a67fcfee3d94bed52a8b1bbaf744758c041849e Mon Sep 17 00:00:00 2001 From: Open Home Automation Date: Fri, 3 Mar 2017 07:16:50 +0100 Subject: [PATCH 104/198] Added IPv4 data collector (#6304) * Added IPv4 data collector * Formatting * Bugfix: data is in kBit/s not kByte/s --- homeassistant/components/sensor/netdata.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index 3a87eeb5ceb..5a3077350bc 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -41,6 +41,8 @@ SENSOR_TYPES = { 'system_load': ['System Load', '15 min', 'system.processes', 'running', 2], 'system_io_in': ['System IO In', 'Count', 'system.io', 'in', 0], 'system_io_out': ['System IO Out', 'Count', 'system.io', 'out', 0], + 'ipv4_in': ['IPv4 In', 'kb/s', 'system.ipv4', 'received', 0], + 'ipv4_out': ['IPv4 Out', 'kb/s', 'system.ipv4', 'sent', 0], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ From 4da2156ebf55b02992fa2b162821b1cda31f5d73 Mon Sep 17 00:00:00 2001 From: Jose Juan Montes Date: Fri, 3 Mar 2017 07:18:01 +0100 Subject: [PATCH 105/198] Return None instead of raising ValueException from as_timestamp template function. (#6155) --- homeassistant/helpers/template.py | 10 +++++++++- tests/helpers/test_template.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8d55615f661..4eabf1d071b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -387,6 +387,14 @@ def timestamp_utc(value): return value +def forgiving_as_timestamp(value): + """Try to convert value to timestamp.""" + try: + return dt_util.as_timestamp(value) + except (ValueError, TypeError): + return None + + def strptime(string, fmt): """Parse a time string to datetime.""" try: @@ -430,6 +438,6 @@ ENV.filters['min'] = min ENV.globals['float'] = forgiving_float ENV.globals['now'] = dt_util.now ENV.globals['utcnow'] = dt_util.utcnow -ENV.globals['as_timestamp'] = dt_util.as_timestamp +ENV.globals['as_timestamp'] = forgiving_as_timestamp ENV.globals['relative_time'] = dt_util.get_age ENV.globals['strptime'] = strptime diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b2134879640..18656acac51 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -215,6 +215,21 @@ class TestHelpersTemplate(unittest.TestCase): template.Template('{{ %s | timestamp_utc }}' % inp, self.hass).render()) + def test_as_timestamp(self): + """Test the as_timestamp function.""" + self.assertEqual("None", + template.Template('{{ as_timestamp("invalid") }}', + self.hass).render()) + self.hass.mock = None + self.assertEqual("None", + template.Template('{{ as_timestamp(states.mock) }}', + self.hass).render()) + + tpl = '{{ as_timestamp(strptime("2024-02-03T09:10:24+0000", ' \ + '"%Y-%m-%dT%H:%M:%S%z")) }}' + self.assertEqual("1706951424.0", + template.Template(tpl, self.hass).render()) + def test_passing_vars_as_keywords(self): """Test passing variables as keywords.""" self.assertEqual( From fbd0bf77c777bc4326a3debd3c776c893fe2eaae Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 3 Mar 2017 08:44:52 +0200 Subject: [PATCH 106/198] [recorder] Catch more startup errors #6179 (#6192) * [recorder] Catch more startup errors #6179 * Rebase on new recorder --- homeassistant/components/recorder/__init__.py | 9 +++++++-- tests/components/recorder/test_init.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c60b95d1cae..907ae8ba51b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -153,8 +153,8 @@ class Recorder(threading.Thread): def run(self): """Start processing events to save.""" - from sqlalchemy.exc import SQLAlchemyError from .models import States, Events + from homeassistant.components import persistent_notification while True: try: @@ -163,10 +163,15 @@ class Recorder(threading.Thread): self._setup_run() self.hass.loop.call_soon_threadsafe(self.async_db_ready.set) break - except SQLAlchemyError as err: + except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error during connection setup: %s (retrying " "in %s seconds)", err, CONNECT_RETRY_WAIT) time.sleep(CONNECT_RETRY_WAIT) + retry = locals().setdefault('retry', 10) - 1 + if retry == 0: + msg = "The recorder could not start, please check the log" + persistent_notification.create(self.hass, msg, 'Recorder') + return purge_task = object() shutdown_task = object() diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 0724313dcea..c43caefb67c 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1,14 +1,17 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access import unittest +from unittest.mock import patch import pytest from homeassistant.core import callback from homeassistant.const import MATCH_ALL +from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.models import States, Events + from tests.common import get_test_home_assistant, init_recorder_component @@ -163,3 +166,18 @@ def test_saving_state_include_domain_exclude_entity(hass_recorder): assert len(states) == 1 assert hass.states.get('test.ok') == states[0] assert hass.states.get('test.ok').state == 'state2' + + +def test_recorder_setup_failure(): + """Test some exceptions.""" + hass = get_test_home_assistant() + + with patch.object(Recorder, '_setup_connection') as setup, \ + patch('homeassistant.components.recorder.time.sleep'): + setup.side_effect = ImportError("driver not found") + rec = Recorder( + hass, purge_days=0, uri='sqlite://', include={}, exclude={}) + rec.start() + rec.join() + + hass.stop() From b53bc24a634600bfc8c4545d47c31adcca0fbbe0 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Fri, 3 Mar 2017 02:14:51 -0500 Subject: [PATCH 107/198] twilio component (#6348) * twilio component * add http dependency to twilio * fire->async_fire --- .coveragerc | 6 ++- .../components/notify/twilio_call.py | 15 ++---- homeassistant/components/notify/twilio_sms.py | 15 ++---- homeassistant/components/twilio.py | 54 +++++++++++++++++++ requirements_all.txt | 3 +- 5 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/twilio.py diff --git a/.coveragerc b/.coveragerc index cbe868954b4..8ce3817d932 100644 --- a/.coveragerc +++ b/.coveragerc @@ -85,6 +85,10 @@ omit = homeassistant/components/*/thinkingcleaner.py + homeassistant/components/twilio.py + homeassistant/components/notify/twilio_sms.py + homeassistant/components/notify/twilio_call.py + homeassistant/components/vera.py homeassistant/components/*/vera.py @@ -291,8 +295,6 @@ omit = homeassistant/components/notify/syslog.py homeassistant/components/notify/telegram.py homeassistant/components/notify/telstra.py - homeassistant/components/notify/twilio_sms.py - homeassistant/components/notify/twilio_call.py homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py homeassistant/components/nuimo_controller.py diff --git a/homeassistant/components/notify/twilio_call.py b/homeassistant/components/notify/twilio_call.py index 374e77b9507..f917d5cdab3 100644 --- a/homeassistant/components/notify/twilio_call.py +++ b/homeassistant/components/notify/twilio_call.py @@ -9,21 +9,18 @@ import urllib import voluptuous as vol +from homeassistant.components.twilio import DATA_TWILIO import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["twilio==5.7.0"] +DEPENDENCIES = ["twilio"] -CONF_ACCOUNT_SID = "account_sid" -CONF_AUTH_TOKEN = "auth_token" CONF_FROM_NUMBER = "from_number" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ACCOUNT_SID): cv.string, - vol.Required(CONF_AUTH_TOKEN): cv.string, vol.Required(CONF_FROM_NUMBER): vol.All(cv.string, vol.Match(r"^\+?[1-9]\d{1,14}$")), }) @@ -31,13 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the Twilio Call notification service.""" - # pylint: disable=import-error - from twilio.rest import TwilioRestClient - - twilio_client = TwilioRestClient(config[CONF_ACCOUNT_SID], - config[CONF_AUTH_TOKEN]) - - return TwilioCallNotificationService(twilio_client, + return TwilioCallNotificationService(hass.data[DATA_TWILIO], config[CONF_FROM_NUMBER]) diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py index ab3ac89e6b2..1bdfcb64407 100644 --- a/homeassistant/components/notify/twilio_sms.py +++ b/homeassistant/components/notify/twilio_sms.py @@ -8,21 +8,18 @@ import logging import voluptuous as vol +from homeassistant.components.twilio import DATA_TWILIO import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService) _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ["twilio==5.7.0"] +DEPENDENCIES = ["twilio"] -CONF_ACCOUNT_SID = "account_sid" -CONF_AUTH_TOKEN = "auth_token" CONF_FROM_NUMBER = "from_number" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ACCOUNT_SID): cv.string, - vol.Required(CONF_AUTH_TOKEN): cv.string, vol.Required(CONF_FROM_NUMBER): vol.All(cv.string, vol.Match(r"^\+?[1-9]\d{1,14}$")), }) @@ -30,13 +27,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the Twilio SMS notification service.""" - # pylint: disable=import-error - from twilio.rest import TwilioRestClient - - twilio_client = TwilioRestClient(config[CONF_ACCOUNT_SID], - config[CONF_AUTH_TOKEN]) - - return TwilioSMSNotificationService(twilio_client, + return TwilioSMSNotificationService(hass.data[DATA_TWILIO], config[CONF_FROM_NUMBER]) diff --git a/homeassistant/components/twilio.py b/homeassistant/components/twilio.py new file mode 100644 index 00000000000..e4b36d41e14 --- /dev/null +++ b/homeassistant/components/twilio.py @@ -0,0 +1,54 @@ +""" +Support for Twilio. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/twilio/ +""" +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView + +DEPENDENCIES = ['http'] +REQUIREMENTS = ['twilio==5.7.0'] + +DOMAIN = 'twilio' +DATA_TWILIO = DOMAIN +API_PATH = '/api/{}'.format(DOMAIN) +RECEIVED_DATA = '{}_data_received'.format(DOMAIN) + +CONF_ACCOUNT_SID = 'account_sid' +CONF_AUTH_TOKEN = 'auth_token' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCOUNT_SID): cv.string, + vol.Required(CONF_AUTH_TOKEN): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Twilio component.""" + from twilio.rest import TwilioRestClient + conf = config[DOMAIN] + hass.data[DATA_TWILIO] = TwilioRestClient(conf.get(CONF_ACCOUNT_SID), + conf.get(CONF_AUTH_TOKEN)) + hass.http.register_view(TwilioReceiveDataView()) + return True + + +class TwilioReceiveDataView(HomeAssistantView): + """Handle data from Twilio inbound messages and calls.""" + + url = API_PATH + name = 'api:{}'.format(DOMAIN) + + @callback + def post(self, request): # pylint: disable=no-self-use + """Handle Twilio data post.""" + from twilio.twiml import Response + hass = request.app['hass'] + data = yield from request.post() + hass.bus.async_fire(RECEIVED_DATA, dict(data)) + return Response().toxml() diff --git a/requirements_all.txt b/requirements_all.txt index 23b29610865..e06957b6ece 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -717,8 +717,7 @@ tikteck==0.4 # homeassistant.components.switch.transmission transmissionrpc==0.11 -# homeassistant.components.notify.twilio_call -# homeassistant.components.notify.twilio_sms +# homeassistant.components.twilio twilio==5.7.0 # homeassistant.components.sensor.uber From aa17481c94154470e5c6510dac46907b669fc0d2 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 3 Mar 2017 09:19:06 +0200 Subject: [PATCH 108/198] Add Z-Wave battery level as a sensor. (#6341) --- homeassistant/components/sensor/zwave.py | 2 ++ homeassistant/components/zwave/__init__.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index 0d10a470b07..0c4d61d86d2 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__) def get_device(node, value, **kwargs): """Create zwave entity device.""" # Generic Device mappings + if value.command_class == zwave.const.COMMAND_CLASS_BATTERY: + return ZWaveSensor(value) if node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_MULTILEVEL): return ZWaveMultilevelSensor(value) if node.has_command_class(zwave.const.COMMAND_CLASS_METER) and \ diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index bd6394867c2..ba7a0f0f033 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -70,7 +70,8 @@ DISCOVERY_COMPONENTS = [ [const.COMMAND_CLASS_SENSOR_MULTILEVEL, const.COMMAND_CLASS_METER, const.COMMAND_CLASS_ALARM, - const.COMMAND_CLASS_SENSOR_ALARM], + const.COMMAND_CLASS_SENSOR_ALARM, + const.COMMAND_CLASS_BATTERY], const.TYPE_WHATEVER, const.GENRE_USER), ('light', From 3e70154695b3141560814d6fddf695c3ac0e55e8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Mar 2017 09:23:58 +0100 Subject: [PATCH 109/198] OwnTrack Async (#6363) * Migrate owntrack to async * fix tests --- .../components/device_tracker/owntracks.py | 202 ++++++++++-------- .../device_tracker/test_owntracks.py | 6 +- 2 files changed, 117 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index c03041b6317..f4737fd26da 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -4,14 +4,15 @@ Support the OwnTracks platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ """ +import asyncio import json import logging -import threading import base64 from collections import defaultdict import voluptuous as vol +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME @@ -19,6 +20,7 @@ from homeassistant.util import convert, slugify from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA +DEPENDENCIES = ['mqtt'] REQUIREMENTS = ['libnacl==1.5.0'] _LOGGER = logging.getLogger(__name__) @@ -30,16 +32,9 @@ CONF_SECRET = 'secret' CONF_WAYPOINT_IMPORT = 'waypoints' CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -DEPENDENCIES = ['mqtt'] - EVENT_TOPIC = 'owntracks/+/+/event' LOCATION_TOPIC = 'owntracks/+/+' -LOCK = threading.Lock() - -MOBILE_BEACONS_ACTIVE = defaultdict(list) - -REGIONS_ENTERED = defaultdict(list) VALIDATE_LOCATION = 'location' VALIDATE_TRANSITION = 'transition' @@ -60,8 +55,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) +@callback def get_cipher(): - """Return decryption function and length of key.""" + """Return decryption function and length of key. + + Async friendly. + """ from libnacl import crypto_secretbox_KEYBYTES as KEYLEN from libnacl.secret import SecretBox @@ -71,13 +70,18 @@ def get_cipher(): return (KEYLEN, decrypt) -def setup_scanner(hass, config, see, discovery_info=None): +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up an OwnTracks tracker.""" max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) waypoint_import = config.get(CONF_WAYPOINT_IMPORT) waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) secret = config.get(CONF_SECRET) + mobile_beacons_active = defaultdict(list) + regions_entered = defaultdict(list) + + @callback def decrypt_payload(topic, ciphertext): """Decrypt encrypted payload.""" try: @@ -115,6 +119,7 @@ def setup_scanner(hass, config, see, discovery_info=None): return None # pylint: disable=too-many-return-statements + @callback def validate_payload(topic, payload, data_type): """Validate the OwnTracks payload.""" try: @@ -154,7 +159,8 @@ def setup_scanner(hass, config, see, discovery_info=None): return data - def owntracks_location_update(topic, payload, qos): + @callback + def async_owntracks_location_update(topic, payload, qos): """MQTT message received.""" # Docs on available data: # http://owntracks.org/booklet/tech/json/#_typelocation @@ -164,18 +170,17 @@ def setup_scanner(hass, config, see, discovery_info=None): dev_id, kwargs = _parse_see_args(topic, data) - # Block updates if we're in a region - with LOCK: - if REGIONS_ENTERED[dev_id]: - _LOGGER.debug( - "location update ignored - inside region %s", - REGIONS_ENTERED[-1]) - return + if regions_entered[dev_id]: + _LOGGER.debug( + "location update ignored - inside region %s", + regions_entered[-1]) + return - see(**kwargs) - see_beacons(dev_id, kwargs) + hass.async_add_job(async_see(**kwargs)) + async_see_beacons(dev_id, kwargs) - def owntracks_event_update(topic, payload, qos): + @callback + def async_owntracks_event_update(topic, payload, qos): """MQTT event (geofences) received.""" # Docs on available data: # http://owntracks.org/booklet/tech/json/#_typetransition @@ -196,70 +201,70 @@ def setup_scanner(hass, config, see, discovery_info=None): dev_id, kwargs = _parse_see_args(topic, data) + @callback def enter_event(): """Execute enter event.""" zone = hass.states.get("zone.{}".format(slugify(location))) - with LOCK: - if zone is None and data.get('t') == 'b': - # Not a HA zone, and a beacon so assume mobile - beacons = MOBILE_BEACONS_ACTIVE[dev_id] - if location not in beacons: - beacons.append(location) - _LOGGER.info("Added beacon %s", location) - else: - # Normal region - regions = REGIONS_ENTERED[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) + if zone is None and data.get('t') == 'b': + # Not a HA zone, and a beacon so assume mobile + beacons = mobile_beacons_active[dev_id] + if location not in beacons: + beacons.append(location) + _LOGGER.info("Added beacon %s", location) + else: + # Normal region + regions = regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) - see(**kwargs) - see_beacons(dev_id, kwargs) + hass.async_add_job(async_see(**kwargs)) + async_see_beacons(dev_id, kwargs) + @callback def leave_event(): """Execute leave event.""" - with LOCK: - regions = REGIONS_ENTERED[dev_id] - if location in regions: - regions.remove(location) - new_region = regions[-1] if regions else None + regions = regions_entered[dev_id] + if location in regions: + regions.remove(location) + new_region = regions[-1] if regions else None - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - see(**kwargs) - see_beacons(dev_id, kwargs) + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + hass.async_add_job(async_see(**kwargs)) + async_see_beacons(dev_id, kwargs) - else: - _LOGGER.info("Exit to GPS") - # Check for GPS accuracy - valid_gps = True - if 'acc' in data: - if data['acc'] == 0.0: - valid_gps = False - _LOGGER.warning( - 'Ignoring GPS in region exit because accuracy' - 'is zero: %s', - payload) - if (max_gps_accuracy is not None and - data['acc'] > max_gps_accuracy): - valid_gps = False - _LOGGER.info( - 'Ignoring GPS in region exit because expected ' - 'GPS accuracy %s is not met: %s', - max_gps_accuracy, payload) - if valid_gps: - see(**kwargs) - see_beacons(dev_id, kwargs) + else: + _LOGGER.info("Exit to GPS") + # Check for GPS accuracy + valid_gps = True + if 'acc' in data: + if data['acc'] == 0.0: + valid_gps = False + _LOGGER.warning( + 'Ignoring GPS in region exit because accuracy' + 'is zero: %s', + payload) + if (max_gps_accuracy is not None and + data['acc'] > max_gps_accuracy): + valid_gps = False + _LOGGER.info( + 'Ignoring GPS in region exit because expected ' + 'GPS accuracy %s is not met: %s', + max_gps_accuracy, payload) + if valid_gps: + hass.async_add_job(async_see(**kwargs)) + async_see_beacons(dev_id, kwargs) - beacons = MOBILE_BEACONS_ACTIVE[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) + beacons = mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) if data['event'] == 'enter': enter_event() @@ -271,7 +276,8 @@ def setup_scanner(hass, config, see, discovery_info=None): data['event']) return - def owntracks_waypoint_update(topic, payload, qos): + @callback + def async_owntracks_waypoint_update(topic, payload, qos): """List of waypoints published by a user.""" # Docs on available data: # http://owntracks.org/booklet/tech/json/#_typewaypoints @@ -298,36 +304,44 @@ def setup_scanner(hass, config, see, discovery_info=None): zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, zone_comp.ICON_IMPORT, False) zone.entity_id = entity_id - zone.update_ha_state() + hass.async_add_job(zone.async_update_ha_state()) - def see_beacons(dev_id, kwargs_param): + @callback + def async_see_beacons(dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() # the battery state applies to the tracking device, not the beacon kwargs.pop('battery', None) - for beacon in MOBILE_BEACONS_ACTIVE[dev_id]: + for beacon in mobile_beacons_active[dev_id]: kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) kwargs['host_name'] = beacon - see(**kwargs) + hass.async_add_job(async_see(**kwargs)) - mqtt.subscribe(hass, LOCATION_TOPIC, owntracks_location_update, 1) - mqtt.subscribe(hass, EVENT_TOPIC, owntracks_event_update, 1) + yield from mqtt.async_subscribe( + hass, LOCATION_TOPIC, async_owntracks_location_update, 1) + yield from mqtt.async_subscribe( + hass, EVENT_TOPIC, async_owntracks_event_update, 1) if waypoint_import: if waypoint_whitelist is None: - mqtt.subscribe(hass, WAYPOINT_TOPIC.format('+', '+'), - owntracks_waypoint_update, 1) + yield from mqtt.async_subscribe( + hass, WAYPOINT_TOPIC.format('+', '+'), + async_owntracks_waypoint_update, 1) else: for whitelist_user in waypoint_whitelist: - mqtt.subscribe(hass, WAYPOINT_TOPIC.format(whitelist_user, - '+'), - owntracks_waypoint_update, 1) + yield from mqtt.async_subscribe( + hass, WAYPOINT_TOPIC.format(whitelist_user, '+'), + async_owntracks_waypoint_update, 1) return True +@callback def parse_topic(topic, pretty=False): - """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple.""" + """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. + + Async friendly. + """ parts = topic.split('/') dev_id_format = '' if pretty: @@ -339,8 +353,12 @@ def parse_topic(topic, pretty=False): return (host_name, dev_id) +@callback def _parse_see_args(topic, data): - """Parse the OwnTracks location parameters, into the format see expects.""" + """Parse the OwnTracks location parameters, into the format see expects. + + Async friendly. + """ (host_name, dev_id) = parse_topic(topic, False) kwargs = { 'dev_id': dev_id, @@ -354,8 +372,12 @@ def _parse_see_args(topic, data): return dev_id, kwargs +@callback def _set_gps_from_zone(kwargs, location, zone): - """Set the see parameters from the zone parameters.""" + """Set the see parameters from the zone parameters. + + Async friendly. + """ if zone is not None: kwargs['gps'] = ( zone.attributes['latitude'], diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 4bea0d3d0b3..31f9a6b96a0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,4 +1,5 @@ """The tests for the Owntracks device tracker.""" +import asyncio import json import os import unittest @@ -12,6 +13,7 @@ import homeassistant.components.device_tracker.owntracks as owntracks from homeassistant.bootstrap import setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME +from homeassistant.util.async import run_coroutine_threadsafe USER = 'greg' DEVICE = 'phone' @@ -640,6 +642,7 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): def test_waypoint_import_no_whitelist(self): """Test import of list of waypoints with no whitelist set.""" + @asyncio.coroutine def mock_see(**kwargs): """Fake see method for owntracks.""" return @@ -649,7 +652,8 @@ class TestDeviceTrackerOwnTracks(BaseMQTT): CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True } - owntracks.setup_scanner(self.hass, test_config, mock_see) + run_coroutine_threadsafe(owntracks.async_setup_scanner( + self.hass, test_config, mock_see), self.hass.loop).result() waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() self.send_message(WAYPOINT_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states From 55f8ec88664ffa64a61d04eaf95fe6c00294355a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Mar 2017 10:05:52 +0100 Subject: [PATCH 110/198] Fix possibility that have multible topic subscribe mqtt (#6372) --- homeassistant/components/mqtt/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e8616e22761..6bfdde813c1 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -482,17 +482,17 @@ class MQTT(object): if not isinstance(topic, str): raise HomeAssistantError("topic need to be a string!") - if topic in self.topics: - return - with (yield from self._paho_lock): + if topic in self.topics: + return + result, mid = yield from self.hass.loop.run_in_executor( None, self._mqttc.subscribe, topic, qos) yield from asyncio.sleep(0, loop=self.hass.loop) - _raise_on_error(result) - self.progress[mid] = topic - self.topics[topic] = None + _raise_on_error(result) + self.progress[mid] = topic + self.topics[topic] = None @asyncio.coroutine def async_unsubscribe(self, topic): From ed9e93c29fcac60fe9f10715a438c69b043e99ab Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 3 Mar 2017 12:09:10 +0100 Subject: [PATCH 111/198] Migrate mqtt tracker and arwn sensor to async / cleanup owntrack (#6373) * Migrate mqtt tracker and arwn sensor to async / cleanup owntrack * Fix tests / lint --- .../components/device_tracker/mqtt.py | 14 +++++--- .../components/device_tracker/owntracks.py | 8 ----- homeassistant/components/sensor/arwn.py | 34 ++++++++++++------- tests/components/device_tracker/test_mqtt.py | 4 ++- 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index a93263fada9..1f7fa9c1b84 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -4,11 +4,13 @@ Support for tracking MQTT enabled devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.mqtt/ """ +import asyncio import logging import voluptuous as vol import homeassistant.components.mqtt as mqtt +from homeassistant.core import callback from homeassistant.const import CONF_DEVICES from homeassistant.components.mqtt import CONF_QOS from homeassistant.components.device_tracker import PLATFORM_SCHEMA @@ -23,19 +25,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({ }) -def setup_scanner(hass, config, see, discovery_info=None): +@asyncio.coroutine +def async_setup_scanner(hass, config, async_see, discovery_info=None): """Setup the MQTT tracker.""" devices = config[CONF_DEVICES] qos = config[CONF_QOS] dev_id_lookup = {} - def device_tracker_message_received(topic, payload, qos): + @callback + def async_tracker_message_received(topic, payload, qos): """MQTT message received.""" - see(dev_id=dev_id_lookup[topic], location_name=payload) + hass.async_add_job( + async_see(dev_id=dev_id_lookup[topic], location_name=payload)) for dev_id, topic in devices.items(): dev_id_lookup[topic] = dev_id - mqtt.subscribe(hass, topic, device_tracker_message_received, qos) + yield from mqtt.async_subscribe( + hass, topic, async_tracker_message_received, qos) return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index f4737fd26da..156e9d6a08a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -55,7 +55,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@callback def get_cipher(): """Return decryption function and length of key. @@ -81,7 +80,6 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): mobile_beacons_active = defaultdict(list) regions_entered = defaultdict(list) - @callback def decrypt_payload(topic, ciphertext): """Decrypt encrypted payload.""" try: @@ -119,7 +117,6 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): return None # pylint: disable=too-many-return-statements - @callback def validate_payload(topic, payload, data_type): """Validate the OwnTracks payload.""" try: @@ -201,7 +198,6 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): dev_id, kwargs = _parse_see_args(topic, data) - @callback def enter_event(): """Execute enter event.""" zone = hass.states.get("zone.{}".format(slugify(location))) @@ -222,7 +218,6 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): hass.async_add_job(async_see(**kwargs)) async_see_beacons(dev_id, kwargs) - @callback def leave_event(): """Execute leave event.""" regions = regions_entered[dev_id] @@ -336,7 +331,6 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): return True -@callback def parse_topic(topic, pretty=False): """Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple. @@ -353,7 +347,6 @@ def parse_topic(topic, pretty=False): return (host_name, dev_id) -@callback def _parse_see_args(topic, data): """Parse the OwnTracks location parameters, into the format see expects. @@ -372,7 +365,6 @@ def _parse_see_args(topic, data): return dev_id, kwargs -@callback def _set_gps_from_zone(kwargs, location, zone): """Set the see parameters from the zone parameters. diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 834efa1b415..0bf68e68b0d 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -4,11 +4,13 @@ Support for collecting data from the ARWN project. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.arwn/ """ +import asyncio import json import logging import homeassistant.components.mqtt as mqtt -from homeassistant.const import (TEMP_FAHRENHEIT, TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS from homeassistant.helpers.entity import Entity from homeassistant.util import slugify @@ -17,13 +19,15 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['mqtt'] DOMAIN = 'arwn' -SENSORS = {} - +DATA_ARWN = 'arwn' TOPIC = 'arwn/#' def discover_sensors(topic, payload): - """Given a topic, dynamically create the right sensor type.""" + """Given a topic, dynamically create the right sensor type. + + Async friendly. + """ parts = topic.split('/') unit = payload.get('units', '') domain = parts[1] @@ -47,9 +51,11 @@ def _slug(name): return 'sensor.arwn_{}'.format(slugify(name)) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the ARWN platform.""" - def sensor_event_received(topic, payload, qos): + @callback + def async_sensor_event_received(topic, payload, qos): """Process events as sensors. When a new event on our topic (arwn/#) is received we map it @@ -67,6 +73,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if not sensors: return + store = hass.data.get(DATA_ARWN) + if store is None: + store = hass.data[DATA_ARWN] = {} + if isinstance(sensors, ArwnSensor): sensors = (sensors, ) @@ -74,18 +84,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): del event['timestamp'] for sensor in sensors: - if sensor.name not in SENSORS: + if sensor.name not in store: sensor.hass = hass sensor.set_event(event) - SENSORS[sensor.name] = sensor + store[sensor.name] = sensor _LOGGER.debug("Registering new sensor %(name)s => %(event)s", dict(name=sensor.name, event=event)) - add_devices((sensor,)) + async_add_devices((sensor,), True) else: - SENSORS[sensor.name].set_event(event) - SENSORS[sensor.name].update_ha_state() + store[sensor.name].set_event(event) - mqtt.subscribe(hass, TOPIC, sensor_event_received, 0) + yield from mqtt.async_subscribe( + hass, TOPIC, async_sensor_event_received, 0) return True diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 583b9b86383..08aab93a5a5 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -1,4 +1,5 @@ """The tests for the MQTT device tracker platform.""" +import asyncio import unittest from unittest.mock import patch import logging @@ -33,12 +34,13 @@ class TestComponentsDeviceTrackerMQTT(unittest.TestCase): def test_ensure_device_tracker_platform_validation(self): \ # pylint: disable=invalid-name """Test if platform validation was done.""" + @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): """Check that Qos was added by validation.""" self.assertTrue('qos' in config) with patch('homeassistant.components.device_tracker.mqtt.' - 'setup_scanner', autospec=True, + 'async_setup_scanner', autospec=True, side_effect=mock_setup_scanner) as mock_sp: dev_id = 'paulus' From edf130b34156b73a06bdd6714631fb22f02b77b9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Mar 2017 04:47:59 -0800 Subject: [PATCH 112/198] Z-Wave prevent I/O event loop (#6369) * Prevent Z-Wave I/O in event loop * Move value_handler to util class. * Add docstring --- homeassistant/components/zwave/__init__.py | 72 +++------------------- homeassistant/components/zwave/util.py | 54 ++++++++++++++++ 2 files changed, 63 insertions(+), 63 deletions(-) create mode 100644 homeassistant/components/zwave/util.py diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index ba7a0f0f033..30cf16fc9e5 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -26,6 +26,7 @@ import homeassistant.helpers.config_validation as cv from . import const from . import workaround +from .util import value_handler REQUIREMENTS = ['pydispatcher==2.0.5'] @@ -729,8 +730,11 @@ class ZWaveDeviceEntity(Entity): from pydispatch import dispatcher self._value = value self._value.set_change_verified(False) - self.entity_id = "{}.{}".format(domain, self._object_id()) + self.entity_id = "{}.{}".format(domain, object_id(value)) + self._name = _value_name(self._value) + self._unique_id = "ZWAVE-{}-{}".format(self._value.node.node_id, + self._value.object_id) self._wakeup_value_id = None self._battery_value_id = None self._power_value_id = None @@ -816,62 +820,13 @@ class ZWaveDeviceEntity(Entity): self.power_consumption = round( power_value.data, power_value.precision) if power_value else None - def _value_handler(self, method=None, class_id=None, index=None, - label=None, data=None, member=None, instance=None, - **kwargs): - """Get the values for a given command_class with arguments. - - May only be used inside callback. - - """ - values = [] - if class_id is None: - values.extend(self._value.node.get_values(**kwargs).values()) - else: - if not isinstance(class_id, list): - class_id = [class_id] - for cid in class_id: - values.extend(self._value.node.get_values( - class_id=cid, **kwargs).values()) - _LOGGER.debug('method=%s, class_id=%s, index=%s, label=%s, data=%s,' - ' member=%s, instance=%d, kwargs=%s', - method, class_id, index, label, data, member, instance, - kwargs) - _LOGGER.debug('values=%s', values) - results = None - for value in values: - if index is not None and value.index != index: - continue - if label is not None: - label_found = False - for entry in label: - if value.label == entry: - label_found = True - break - if not label_found: - continue - if method == 'set': - value.data = data - return - if data is not None and value.data != data: - continue - if instance is not None and value.instance != instance: - continue - if member is not None: - results = getattr(value, member) - else: - results = value - break - _LOGGER.debug('final result=%s', results) - return results - def get_value(self, **kwargs): """Simplifyer to get values. May only be used inside callback.""" - return self._value_handler(method='get', **kwargs) + return value_handler(self._value, method='get', **kwargs) def set_value(self, **kwargs): """Simplifyer to set a value.""" - return self._value_handler(method='set', **kwargs) + return value_handler(self._value, method='set', **kwargs) def update_properties(self): """Callback on data changes for node values.""" @@ -885,21 +840,12 @@ class ZWaveDeviceEntity(Entity): @property def unique_id(self): """Return an unique ID.""" - return "ZWAVE-{}-{}".format(self._value.node.node_id, - self._value.object_id) + return self._unique_id @property def name(self): """Return the name of the device.""" - return _value_name(self._value) - - def _object_id(self): - """Return the object_id of the device value. - - The object_id contains node_id and value instance id to not collide - with other entity_ids. - """ - return object_id(self._value) + return self._name @property def device_state_attributes(self): diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py new file mode 100644 index 00000000000..3ce99dbf1ab --- /dev/null +++ b/homeassistant/components/zwave/util.py @@ -0,0 +1,54 @@ +"""Zwave util methods.""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +def value_handler(value, method=None, class_id=None, index=None, + label=None, data=None, member=None, instance=None, + **kwargs): + """Get the values for a given command_class with arguments. + + May only be used inside callback. + + """ + values = [] + if class_id is None: + values.extend(value.node.get_values(**kwargs).values()) + else: + if not isinstance(class_id, list): + class_id = [class_id] + for cid in class_id: + values.extend(value.node.get_values( + class_id=cid, **kwargs).values()) + _LOGGER.debug('method=%s, class_id=%s, index=%s, label=%s, data=%s,' + ' member=%s, instance=%d, kwargs=%s', + method, class_id, index, label, data, member, instance, + kwargs) + _LOGGER.debug('values=%s', values) + results = None + for value in values: + if index is not None and value.index != index: + continue + if label is not None: + label_found = False + for entry in label: + if value.label == entry: + label_found = True + break + if not label_found: + continue + if method == 'set': + value.data = data + return + if data is not None and value.data != data: + continue + if instance is not None and value.instance != instance: + continue + if member is not None: + results = getattr(value, member) + else: + results = value + break + _LOGGER.debug('final result=%s', results) + return results From 568c5493535779f36ba8c259ce31a35a52089744 Mon Sep 17 00:00:00 2001 From: Valentin Alexeev Date: Fri, 3 Mar 2017 15:50:54 +0200 Subject: [PATCH 113/198] Update pwaqi to 3.0 to use public API (#6376) The underlying PWAQI library version 3.0 is now using public API to access AQICN data. --- homeassistant/components/sensor/waqi.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/waqi.py b/homeassistant/components/sensor/waqi.py index 453d32bd673..11e1f17c2ba 100644 --- a/homeassistant/components/sensor/waqi.py +++ b/homeassistant/components/sensor/waqi.py @@ -16,7 +16,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pwaqi==2.0'] +REQUIREMENTS = ['pwaqi==3.0'] _LOGGER = logging.getLogger(__name__) @@ -162,7 +162,7 @@ class WaqiData(object): """Get the data from World Air Quality Index and updates the states.""" import pwaqi try: - self.data = pwaqi.getStationObservation( + self.data = pwaqi.get_station_observation( self._station_id, self._token) except AttributeError: _LOGGER.exception("Unable to fetch data from WAQI") diff --git a/requirements_all.txt b/requirements_all.txt index e06957b6ece..1148aac03d8 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -433,7 +433,7 @@ pushbullet.py==0.10.0 pushetta==1.0.15 # homeassistant.components.sensor.waqi -pwaqi==2.0 +pwaqi==3.0 # homeassistant.components.sensor.cpuspeed py-cpuinfo==0.2.6 From 35fcc299c0ea6c600d00a5bfb996ede63b09bafe Mon Sep 17 00:00:00 2001 From: John Mihalic Date: Fri, 3 Mar 2017 09:11:30 -0500 Subject: [PATCH 114/198] Update Hikvision Binary Sensors to latest library, remove pyDispatcher (#6231) * Update pyHik version, remove pyDispatcher in favor of callbacks * Fix naming * Fix lint blank line * Move stream thread start to HOMEASSISTANT_START event * Bump library version to cleanup shutdown * Fix requirements --- .../components/binary_sensor/hikvision.py | 43 ++++++++----------- requirements_all.txt | 3 +- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index e14d4149ffe..135d9a1e028 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -15,9 +15,10 @@ from homeassistant.components.binary_sensor import ( import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - CONF_SSL, EVENT_HOMEASSISTANT_STOP, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) + CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, + ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.0.7', 'pydispatcher==2.0.5'] +REQUIREMENTS = ['pyhik==0.1.0'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -119,30 +120,32 @@ class HikvisionData(object): self._password = password # Establish camera - self._cam = HikCamera(self._url, self._port, - self._username, self._password) + self.camdata = HikCamera(self._url, self._port, + self._username, self._password) if self._name is None: - self._name = self._cam.get_name - - # Start event stream - self._cam.start_stream() + self._name = self.camdata.get_name hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik) def stop_hik(self, event): """Shutdown Hikvision subscriptions and subscription thread on exit.""" - self._cam.disconnect() + self.camdata.disconnect() + + def start_hik(self, event): + """Start Hikvision event stream thread.""" + self.camdata.start_stream() @property def sensors(self): """Return list of available sensors and their states.""" - return self._cam.current_event_states + return self.camdata.current_event_states @property def cam_id(self): """Return camera id.""" - return self._cam.get_id + return self.camdata.get_id @property def name(self): @@ -155,8 +158,6 @@ class HikvisionBinarySensor(BinarySensorDevice): def __init__(self, hass, sensor, cam, delay): """Initialize the binary_sensor.""" - from pydispatch import dispatcher - self._hass = hass self._cam = cam self._name = self._cam.name + ' ' + sensor @@ -170,12 +171,8 @@ class HikvisionBinarySensor(BinarySensorDevice): self._timer = None - # Form signal for dispatcher - signal = 'ValueChanged.{}'.format(self._cam.cam_id) - - dispatcher.connect(self._update_callback, - signal=signal, - sender=self._sensor) + # Register callback function with pyHik + self._cam.camdata.add_update_callback(self._update_callback, self._id) def _sensor_state(self): """Extract sensor state.""" @@ -225,13 +222,9 @@ class HikvisionBinarySensor(BinarySensorDevice): return attr - def _update_callback(self, signal, sender): + def _update_callback(self, msg): """Update the sensor's state, if needed.""" - _LOGGER.debug('Dispatcher callback, signal: %s, sender: %s', - signal, sender) - - if sender is not self._sensor: - return + _LOGGER.debug('Callback signal from: %s', msg) if self._delay > 0 and not self.is_on: # Set timer to wait until updating the state diff --git a/requirements_all.txt b/requirements_all.txt index 1148aac03d8..825a03373cf 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -473,7 +473,6 @@ pycmus==0.1.0 # pycups==1.9.73 # homeassistant.components.zwave -# homeassistant.components.binary_sensor.hikvision pydispatcher==2.0.5 # homeassistant.components.sensor.ebox @@ -498,7 +497,7 @@ pygatt==3.0.0 pyharmony==1.0.12 # homeassistant.components.binary_sensor.hikvision -pyhik==0.0.7 +pyhik==0.1.0 # homeassistant.components.homematic pyhomematic==0.1.22 From 0489ae53c44e06f9eaf2a1d376dcc6cd64a1c1a7 Mon Sep 17 00:00:00 2001 From: Colin O'Dell Date: Fri, 3 Mar 2017 16:11:40 -0500 Subject: [PATCH 115/198] Don't initialize components which have already been discovered (#6381) * Don't initialize components which have already been discovered (fixes #5588) * Don't log that we've found a service unless we know it's not a duplicate * Encode discovery data hash with JSON This also solves the issue of trying to hash non-hashable objects like dicts * Add test for duplicate device discovery --- homeassistant/components/discovery.py | 12 ++++++++++-- tests/components/test_discovery.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index ac68cfaf367..421ba321c8d 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -7,6 +7,7 @@ Knows which components handle certain types, will make sure they are loaded before the EVENT_PLATFORM_DISCOVERED is fired. """ import asyncio +import json from datetime import timedelta import logging @@ -66,6 +67,7 @@ def async_setup(hass, config): logger = logging.getLogger(__name__) netdisco = NetworkDiscovery() + already_discovered = set() # Disable zeroconf logging, it spams logging.getLogger('zeroconf').setLevel(logging.CRITICAL) @@ -80,14 +82,20 @@ def async_setup(hass, config): logger.info("Ignoring service: %s %s", service, info) return - logger.info("Found new service: %s %s", service, info) - comp_plat = SERVICE_HANDLERS.get(service) # We do not know how to handle this service. if not comp_plat: return + discovery_hash = json.dumps([service, info], sort_keys=True) + if discovery_hash in already_discovered: + return + + already_discovered.add(discovery_hash) + + logger.info("Found new service: %s %s", service, info) + component, platform = comp_plat if platform is None: diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index bc2be3ed463..abffc3b17cd 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -126,3 +126,30 @@ def test_ignore_service(hass): assert not mock_discover.called assert not mock_platform.called + + +@asyncio.coroutine +def test_discover_duplicates(hass): + """Test load a component.""" + result = yield from async_setup_component(hass, 'discovery', BASE_CONFIG) + assert result + + def discover(netdisco): + """Fake discovery.""" + return [(SERVICE_NO_PLATFORM, SERVICE_INFO), + (SERVICE_NO_PLATFORM, SERVICE_INFO)] + + with patch.object(discovery, '_discover', discover), \ + patch('homeassistant.components.discovery.async_discover', + return_value=mock_coro()) as mock_discover, \ + patch('homeassistant.components.discovery.async_load_platform', + return_value=mock_coro()) as mock_platform: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + yield from hass.async_block_till_done() + + assert mock_discover.called + assert mock_discover.call_count == 1 + assert not mock_platform.called + mock_discover.assert_called_with( + hass, SERVICE_NO_PLATFORM, SERVICE_INFO, + SERVICE_NO_PLATFORM_COMPONENT, BASE_CONFIG) From 483556ac5b49c7c56507658fbcaea89b3b21a47f Mon Sep 17 00:00:00 2001 From: joe248 Date: Fri, 3 Mar 2017 16:14:22 -0600 Subject: [PATCH 116/198] Comed Hourly Pricing sensor (#6378) * Add ComEd RRTP price sensor * Update wording to reflect ComEd's naming change from 'RRTP' to 'Hourly Pricing' * Changed name of sensor source file * Cleanup based on requested changes * More cleanup * small cleanups --- .coveragerc | 1 + .../components/sensor/comed_hourly_pricing.py | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 homeassistant/components/sensor/comed_hourly_pricing.py diff --git a/.coveragerc b/.coveragerc index 8ce3817d932..6787c56d639 100644 --- a/.coveragerc +++ b/.coveragerc @@ -310,6 +310,7 @@ omit = homeassistant/components/sensor/broadlink.py homeassistant/components/sensor/dublin_bus_transport.py homeassistant/components/sensor/coinmarketcap.py + homeassistant/components/sensor/comed_hourly_pricing.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/cups.py homeassistant/components/sensor/currencylayer.py diff --git a/homeassistant/components/sensor/comed_hourly_pricing.py b/homeassistant/components/sensor/comed_hourly_pricing.py new file mode 100644 index 00000000000..30948fada8f --- /dev/null +++ b/homeassistant/components/sensor/comed_hourly_pricing.py @@ -0,0 +1,110 @@ +""" +Support for ComEd Hourly Pricing data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.comed_hourly_pricing/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +from requests import RequestException, get + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://hourlypricing.comed.com/api' + +SCAN_INTERVAL = timedelta(minutes=5) + +CONF_MONITORED_FEEDS = 'monitored_feeds' +CONF_SENSOR_TYPE = 'type' +CONF_OFFSET = 'offset' +CONF_NAME = 'name' + +CONF_FIVE_MINUTE = 'five_minute' +CONF_CURRENT_HOUR_AVERAGE = 'current_hour_average' + +SENSOR_TYPES = { + CONF_FIVE_MINUTE: ['ComEd 5 Minute Price', 'c'], + CONF_CURRENT_HOUR_AVERAGE: ['ComEd Current Hour Average Price', 'c'], +} + +TYPES_SCHEMA = vol.In(SENSOR_TYPES) + +SENSORS_SCHEMA = vol.Schema({ + vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Optional(CONF_OFFSET, default=0.0): vol.Coerce(float), + vol.Optional(CONF_NAME): cv.string +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA] +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ComEd Hourly Pricing sensor.""" + dev = [] + for variable in config[CONF_MONITORED_FEEDS]: + dev.append(ComedHourlyPricingSensor( + variable[CONF_SENSOR_TYPE], variable[CONF_OFFSET], + variable.get(CONF_NAME))) + + add_devices(dev) + + +class ComedHourlyPricingSensor(Entity): + """Implementation of a ComEd Hourly Pricing sensor.""" + + def __init__(self, sensor_type, offset, name): + """Initialize the sensor.""" + if name: + self._name = name + else: + self._name = SENSOR_TYPES[sensor_type][0] + self.type = sensor_type + self.offset = offset + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_ATTRIBUTION: 'Data provided by ComEd Hourly ' + 'Pricing service'} + return attrs + + def update(self): + """Get the ComEd Hourly Pricing data from the web service.""" + try: + if self.type == CONF_FIVE_MINUTE: + url_string = _RESOURCE + '?type=5minutefeed' + response = get(url_string, timeout=10) + self._state = float(response.json()[0]['price']) + self.offset + elif self.type == CONF_CURRENT_HOUR_AVERAGE: + url_string = _RESOURCE + '?type=currenthouraverage' + response = get(url_string, timeout=10) + self._state = float(response.json()[0]['price']) + self.offset + else: + self._state = STATE_UNKNOWN + except (RequestException, ValueError, KeyError): + _LOGGER.warning('Could not update status for %s', self.name) From b038a1650e96773ecea7e9378b8e445f3cec676a Mon Sep 17 00:00:00 2001 From: Christiaan Blom Date: Fri, 3 Mar 2017 23:15:03 +0100 Subject: [PATCH 117/198] Resolved issue #5688 --- homeassistant/components/notify/discord.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index e6c4b3bad96..fa8aa72cef3 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -35,18 +35,21 @@ class DiscordNotificationService(BaseNotificationService): """Initialize the service.""" self.token = token self.hass = hass + self.loggedin = 0 @asyncio.coroutine def async_send_message(self, message, **kwargs): """Login to Discord, send message to channel(s) and log out.""" import discord discord_bot = discord.Client(loop=self.hass.loop) + + @discord_bot.event + @asyncio.coroutine + def on_ready(): + for channelid in kwargs[ATTR_TARGET]: + channel = discord.Object(id=channelid) + yield from discord_bot.send_message(channel, message) + yield from discord_bot.logout() - yield from discord_bot.login(self.token) + yield from discord_bot.start(self.token) - for channelid in kwargs[ATTR_TARGET]: - channel = discord.Object(id=channelid) - yield from discord_bot.send_message(channel, message) - - yield from discord_bot.logout() - yield from discord_bot.close() From a444df3fdea41cb8be7e80a71c12832a830b501f Mon Sep 17 00:00:00 2001 From: Christiaan Blom Date: Sat, 4 Mar 2017 01:03:10 +0100 Subject: [PATCH 118/198] tweaks --- homeassistant/components/notify/discord.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index fa8aa72cef3..07b595b3945 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -27,7 +27,6 @@ def get_service(hass, config, discovery_info=None): token = config.get(CONF_TOKEN) return DiscordNotificationService(hass, token) - class DiscordNotificationService(BaseNotificationService): """Implement the notification service for Discord.""" @@ -35,14 +34,13 @@ class DiscordNotificationService(BaseNotificationService): """Initialize the service.""" self.token = token self.hass = hass - self.loggedin = 0 @asyncio.coroutine def async_send_message(self, message, **kwargs): """Login to Discord, send message to channel(s) and log out.""" import discord discord_bot = discord.Client(loop=self.hass.loop) - + @discord_bot.event @asyncio.coroutine def on_ready(): From 887b53b794c409d1b6cf287f038e90b6502066c7 Mon Sep 17 00:00:00 2001 From: Christiaan Blom Date: Sat, 4 Mar 2017 01:31:19 +0100 Subject: [PATCH 119/198] Changes for Travis bot. Unused variable 'on_ready' will likely remain reported --- homeassistant/components/notify/discord.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 07b595b3945..8647ea8792e 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -27,6 +27,7 @@ def get_service(hass, config, discovery_info=None): token = config.get(CONF_TOKEN) return DiscordNotificationService(hass, token) + class DiscordNotificationService(BaseNotificationService): """Implement the notification service for Discord.""" @@ -44,10 +45,10 @@ class DiscordNotificationService(BaseNotificationService): @discord_bot.event @asyncio.coroutine def on_ready(): + """sends the messages when the bot is ready""" for channelid in kwargs[ATTR_TARGET]: channel = discord.Object(id=channelid) yield from discord_bot.send_message(channel, message) yield from discord_bot.logout() yield from discord_bot.start(self.token) - From c1f3ce78e13c4dea98fb762ea7b86e7e4059a94e Mon Sep 17 00:00:00 2001 From: Christiaan Blom Date: Sat, 4 Mar 2017 01:43:59 +0100 Subject: [PATCH 120/198] Edit docstring --- homeassistant/components/notify/discord.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 8647ea8792e..7e1a83cdcb4 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -45,7 +45,7 @@ class DiscordNotificationService(BaseNotificationService): @discord_bot.event @asyncio.coroutine def on_ready(): - """sends the messages when the bot is ready""" + """Send the messages when the bot is ready.""" for channelid in kwargs[ATTR_TARGET]: channel = discord.Object(id=channelid) yield from discord_bot.send_message(channel, message) From aaa094459582d13368611bb5d39e73804cb45b61 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Sat, 4 Mar 2017 03:11:58 -0500 Subject: [PATCH 121/198] Add multi contracts support for Hydroquebec (#6392) --- .../components/sensor/hydroquebec.py | 36 +++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index 8e19a60bba8..7ec2b17af2d 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,13 +21,14 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyhydroquebec==0.1.1'] +REQUIREMENTS = ['pyhydroquebec==1.0.0'] _LOGGER = logging.getLogger(__name__) KILOWATT_HOUR = "kWh" # type: str PRICE = "CAD" # type: str DAYS = "days" # type: str +CONF_CONTRACT = "contract" # type: str DEFAULT_NAME = "HydroQuebec" @@ -64,6 +65,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CONTRACT): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -91,10 +93,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) + contract = config.get(CONF_CONTRACT) try: - hydroquebec_data = HydroquebecData(username, password) - hydroquebec_data.update() + hydroquebec_data = HydroquebecData(username, password, contract) + _LOGGER.info("Contract list: %s", + ", ".join(hydroquebec_data.get_contract_list())) except requests.exceptions.HTTPError as error: _LOGGER.error("Failt login: %s", error) return False @@ -105,7 +109,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_VARIABLES]: sensors.append(HydroQuebecSensor(hydroquebec_data, variable, name)) - add_devices(sensors) + add_devices(sensors, True) class HydroQuebecSensor(Entity): @@ -122,8 +126,6 @@ class HydroQuebecSensor(Entity): self.hydroquebec_data = hydroquebec_data self._state = None - self.update() - @property def name(self): """Return the name of the sensor.""" @@ -153,22 +155,34 @@ class HydroQuebecSensor(Entity): class HydroquebecData(object): """Get data from HydroQuebec.""" - def __init__(self, username, password): + def __init__(self, username, password, contract=None): """Initialize the data object.""" from pyhydroquebec import HydroQuebecClient self.client = HydroQuebecClient(username, password, REQUESTS_TIMEOUT) + self._contract = contract self.data = {} - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from HydroQuebec.""" + def get_contract_list(self): + """Return the contract list.""" + # Fetch data + self._fetch_data() + return self.client.get_contracts() + + def _fetch_data(self): + """Fetch latest data from HydroQuebec.""" from pyhydroquebec.client import PyHydroQuebecError try: self.client.fetch_data() except PyHydroQuebecError as exp: _LOGGER.error("Error on receive last Hydroquebec data: %s", exp) return + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Return the latest collected data from HydroQuebec.""" + # Fetch data + self._fetch_data() # Update data - self.data = self.client.get_data() + self.data = self.client.get_data(self._contract)[self._contract] diff --git a/requirements_all.txt b/requirements_all.txt index 825a03373cf..50184c397db 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -503,7 +503,7 @@ pyhik==0.1.0 pyhomematic==0.1.22 # homeassistant.components.sensor.hydroquebec -pyhydroquebec==0.1.1 +pyhydroquebec==1.0.0 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 From aac9f972cfe1bff6b8e067f3b34d0abb143a2381 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 4 Mar 2017 19:13:24 +0200 Subject: [PATCH 122/198] Add Zwave refresh services (#6377) * Add Zwave refresh services * services file * Use dispatcher * Add zwave prefix to signal --- homeassistant/components/zwave/__init__.py | 49 ++++++++++++++++++++ homeassistant/components/zwave/const.py | 2 + homeassistant/components/zwave/services.yaml | 14 ++++++ 3 files changed, 65 insertions(+) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 30cf16fc9e5..5e651f69213 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -23,6 +23,8 @@ from homeassistant.helpers.event import track_time_change from homeassistant.util import convert, slugify import homeassistant.config as conf_util import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) from . import const from . import workaround @@ -162,6 +164,10 @@ NODE_SERVICE_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), }) +REFRESH_ENTITY_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_id, +}) + CHANGE_ASSOCIATION_SCHEMA = vol.Schema({ vol.Required(const.ATTR_ASSOCIATION): cv.string, vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), @@ -185,6 +191,8 @@ DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ cv.positive_int }) +SIGNAL_REFRESH_ENTITY_FORMAT = 'zwave_refresh_entity_{}' + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_AUTOHEAL, default=DEFAULT_CONF_AUTOHEAL): cv.boolean, @@ -615,6 +623,19 @@ def setup(hass, config): "target node:%s, instance=%s", node_id, group, target_node_id, instance) + @asyncio.coroutine + def async_refresh_entity(service): + """Refresh values that specific entity depends on.""" + entity_id = service.data.get(ATTR_ENTITY_ID) + async_dispatcher_send( + hass, SIGNAL_REFRESH_ENTITY_FORMAT.format(entity_id)) + + def refresh_node(service): + """Refresh all node info.""" + node_id = service.data.get(const.ATTR_NODE_ID) + node = NETWORK.nodes[node_id] + node.refresh_info() + def start_zwave(_service_or_event): """Startup Z-Wave network.""" _LOGGER.info("Starting ZWave network.") @@ -709,6 +730,16 @@ def setup(hass, config): descriptions[ const.SERVICE_PRINT_NODE], schema=NODE_SERVICE_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_REFRESH_ENTITY, + async_refresh_entity, + descriptions[ + const.SERVICE_REFRESH_ENTITY], + schema=REFRESH_ENTITY_SCHEMA) + hass.services.register(DOMAIN, const.SERVICE_REFRESH_NODE, + refresh_node, + descriptions[ + const.SERVICE_REFRESH_NODE], + schema=NODE_SERVICE_SCHEMA) # Setup autoheal if autoheal: @@ -788,6 +819,14 @@ class ZWaveDeviceEntity(Entity): """ return [] + @asyncio.coroutine + def async_added_to_hass(self): + """Add device to dict.""" + async_dispatcher_connect( + self.hass, + SIGNAL_REFRESH_ENTITY_FORMAT.format(self.entity_id), + self.refresh_from_network) + def _get_dependent_value_ids(self): """Return a list of value_ids this device depend on. @@ -867,3 +906,13 @@ class ZWaveDeviceEntity(Entity): attrs[ATTR_POWER] = self.power_consumption return attrs + + def refresh_from_network(self): + """Refresh all dependent values from zwave network.""" + dependent_ids = self._get_dependent_value_ids() + if dependent_ids is None: + # Entity depends on the whole node + self._value.node.refresh_info() + return + for value_id in dependent_ids + [self._value.value_id]: + self._value.node.refresh_value(value_id) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index 52ccdc0a752..ab4c7604dc4 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -34,6 +34,8 @@ SERVICE_SET_WAKEUP = "set_wakeup" SERVICE_STOP_NETWORK = "stop_network" SERVICE_START_NETWORK = "start_network" SERVICE_RENAME_NODE = "rename_node" +SERVICE_REFRESH_ENTITY = "refresh_entity" +SERVICE_REFRESH_NODE = "refresh_node" EVENT_SCENE_ACTIVATED = "zwave.scene_activated" EVENT_NODE_EVENT = "zwave.node_event" diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 08cd8069d83..00881602dc0 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -65,6 +65,20 @@ print_node: node_id: description: Node id of the device to print. +refresh_entity: + description: Refresh zwave entity. + fields: + entity_id: + description: Name of the entity to refresh. + example: 'light.leviton_vrmx11lz_multilevel_scene_switch_level_40' + +refresh_node: + description: Refresh zwave node. + fields: + entity_id: + description: ID of the node to refresh. + example: 10 + set_wakeup: description: Sets wake-up interval of a node. fields: From f396a4593e6ef0d348b3728737696092f46b80da Mon Sep 17 00:00:00 2001 From: Lev Aronsky Date: Sat, 4 Mar 2017 19:42:43 +0200 Subject: [PATCH 123/198] Add keep-alive feature to the generic thermostat (#6040) * Add keep-alive feature to the generic thermostat * Comply with maximum line lengths * Added tests for the keep-alive functionality --- .../components/climate/generic_thermostat.py | 24 ++- .../climate/test_generic_thermostat.py | 183 ++++++++++++++++++ 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index d4b8ef16985..4fc667a5326 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -16,7 +16,8 @@ from homeassistant.components.climate import ( from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF, ATTR_TEMPERATURE) from homeassistant.helpers import condition -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_time_interval) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,7 @@ CONF_TARGET_TEMP = 'target_temp' CONF_AC_MODE = 'ac_mode' CONF_MIN_DUR = 'min_cycle_duration' CONF_TOLERANCE = 'tolerance' +CONF_KEEP_ALIVE = 'keep_alive' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_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), }) @@ -62,10 +66,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ac_mode = config.get(CONF_AC_MODE) min_cycle_duration = config.get(CONF_MIN_DUR) tolerance = config.get(CONF_TOLERANCE) + keep_alive = config.get(CONF_KEEP_ALIVE) async_add_devices([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, - target_temp, ac_mode, min_cycle_duration, tolerance)]) + target_temp, ac_mode, min_cycle_duration, tolerance, keep_alive)]) class GenericThermostat(ClimateDevice): @@ -73,7 +78,7 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, - tolerance): + tolerance, keep_alive): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -81,6 +86,7 @@ class GenericThermostat(ClimateDevice): self.ac_mode = ac_mode self.min_cycle_duration = min_cycle_duration self._tolerance = tolerance + self._keep_alive = keep_alive self._active = False self._cur_temp = None @@ -94,6 +100,10 @@ class GenericThermostat(ClimateDevice): async_track_state_change( hass, heater_entity_id, self._async_switch_changed) + if self._keep_alive: + async_track_time_interval( + hass, self._async_keep_alive, self._keep_alive) + sensor_state = hass.states.get(sensor_entity_id) if sensor_state: self._async_update_temp(sensor_state) @@ -180,6 +190,14 @@ class GenericThermostat(ClimateDevice): return self.hass.async_add_job(self.async_update_ha_state()) + @callback + def _async_keep_alive(self, time): + """Called at constant intervals for keep-alive purposes.""" + if self.current_operation in [STATE_COOL, STATE_HEAT]: + switch.async_turn_on(self.hass, self.heater_entity_id) + else: + switch.async_turn_off(self.hass, self.heater_entity_id) + @callback def _async_update_temp(self, state): """Update thermostat with latest state from sensor.""" diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 846ecdc320f..d4a5b3d21bb 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -1,9 +1,11 @@ """The tests for the generic_thermostat.""" import asyncio import datetime +import pytz import unittest from unittest import mock +import homeassistant.core as ha from homeassistant.core import callback from homeassistant.bootstrap import setup_component, async_setup_component from homeassistant.const import ( @@ -524,6 +526,187 @@ class TestClimateGenericThermostatMinCycle(unittest.TestCase): self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) +class TestClimateGenericThermostatACKeepAlive(unittest.TestCase): + """Test the Generic Thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + assert setup_component(self.hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'tolerance': 0.3, + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True, + 'keep_alive': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_ac_trigger_on_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(True) + self.hass.block_till_done() + self._setup_sensor(30) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_trigger_off_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(False) + self.hass.block_till_done() + self._setup_sensor(20) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _send_time_changed(self, now): + """Send a time changed event.""" + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + @callback + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatKeepAlive(unittest.TestCase): + """Test the Generic Thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + assert setup_component(self.hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'tolerance': 0.3, + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'keep_alive': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_heater_trigger_on_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(True) + self.hass.block_till_done() + self._setup_sensor(20) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_trigger_off_long_enough(self): + """Test if turn on signal is sent at keep-alive intervals.""" + self._setup_switch(False) + self.hass.block_till_done() + self._setup_sensor(30) + self.hass.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.block_till_done() + test_time = datetime.datetime.now(pytz.UTC) + self._send_time_changed(test_time) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=5)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + self._send_time_changed(test_time + datetime.timedelta(minutes=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _send_time_changed(self, now): + """Send a time changed event.""" + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + @callback + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + @asyncio.coroutine def test_custom_setup_params(hass): """Test the setup with custom parameters.""" From a5081ac307e037caee6bbd1add49d4c0d9424353 Mon Sep 17 00:00:00 2001 From: siebert Date: Sat, 4 Mar 2017 18:58:01 +0100 Subject: [PATCH 124/198] Fix wake_on_lan for german version of Windows 10 (#6397) (#6398) --- homeassistant/components/switch/wake_on_lan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index e6efc1869af..57ad4d34f1a 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -91,5 +91,5 @@ class WOLSwitch(SwitchDevice): ping_cmd = 'ping -c 1 -W {} {}'.format( DEFAULT_PING_TIMEOUT, self._host) - status = sp.getstatusoutput(ping_cmd)[0] + status = sp.call(ping_cmd, stdout=sp.DEVNULL) self._state = not bool(status) From 3044aecbe96e061d7d7ffd917ec9d61e8caaa244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 4 Mar 2017 21:33:24 +0100 Subject: [PATCH 125/198] flux led lib (#6404) --- homeassistant/components/light/flux_led.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index f64719a6529..f0f719fd15f 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['flux_led==0.13'] +REQUIREMENTS = ['flux_led==0.15'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 50184c397db..a18aa2485d5 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -155,7 +155,7 @@ fitbit==0.2.3 fixerio==0.1.1 # homeassistant.components.light.flux_led -flux_led==0.13 +flux_led==0.15 # homeassistant.components.notify.free_mobile freesms==0.1.1 From 78f5a8a6f8c938860df27352777ee9712abad322 Mon Sep 17 00:00:00 2001 From: Jeremy Volkman Date: Sat, 4 Mar 2017 12:39:25 -0800 Subject: [PATCH 126/198] Small typo fix in setup_docker_prereqs --- virtualization/Docker/setup_docker_prereqs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index b66966da5e7..f2238e43876 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -1,5 +1,5 @@ #!/bin/bash -# Install requirements and build dependencies for Home Assinstant in Docker. +# Install requirements and build dependencies for Home Assistant in Docker. # Stop on errors set -e From 8232f1ef650bdc95656209def3343bbfa83bfeb0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 5 Mar 2017 00:10:36 +0100 Subject: [PATCH 127/198] Cleanup async handling (#6388) * Cleanups unneeded blocks * Cleanup bootstrap * dedicated update_ha_state * Fix imap_email_content * fx tests * Fix lint & spell --- homeassistant/bootstrap.py | 7 +++ .../components/binary_sensor/enocean.py | 2 +- homeassistant/components/climate/zwave.py | 2 +- homeassistant/components/isy994.py | 2 +- homeassistant/components/light/enocean.py | 2 +- homeassistant/components/light/lifx.py | 6 +-- homeassistant/components/light/scsgate.py | 2 +- homeassistant/components/proximity.py | 12 ++--- homeassistant/components/qwikswitch.py | 2 +- homeassistant/components/remote/harmony.py | 2 +- homeassistant/components/rfxtrx.py | 4 +- homeassistant/components/sensor/enocean.py | 2 +- .../components/sensor/fritzbox_callmonitor.py | 2 +- .../components/sensor/haveibeenpwned.py | 2 +- .../components/sensor/imap_email_content.py | 34 ++++++------ homeassistant/components/sensor/loopenergy.py | 2 +- homeassistant/components/sensor/pilight.py | 2 +- homeassistant/components/sun.py | 4 +- homeassistant/components/switch/broadlink.py | 4 +- homeassistant/components/switch/rpi_rf.py | 4 +- homeassistant/components/switch/scsgate.py | 2 +- homeassistant/components/weblink.py | 2 +- homeassistant/components/wink.py | 8 +-- homeassistant/helpers/entity.py | 16 ++---- homeassistant/helpers/entity_component.py | 7 +-- .../components/media_player/test_universal.py | 53 ++++++++++++------- .../sensor/test_imap_email_content.py | 28 +++++----- tests/components/test_init.py | 6 ++- tests/helpers/test_entity.py | 9 ++-- tests/test_config.py | 2 +- 30 files changed, 124 insertions(+), 108 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c0ed6db11f7..3b53010e3e3 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -55,6 +55,9 @@ def async_setup_component(hass: core.HomeAssistant, domain: str, This method is a coroutine. """ + if domain in hass.config.components: + return True + setup_tasks = hass.data.get(DATA_SETUP) if setup_tasks is not None and domain in setup_tasks: @@ -211,6 +214,10 @@ def _async_setup_component(hass: core.HomeAssistant, hass.config.components.add(component.DOMAIN) + # cleanup + if domain in hass.data[DATA_SETUP]: + hass.data[DATA_SETUP].pop(domain) + hass.bus.async_fire( EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} ) diff --git a/homeassistant/components/binary_sensor/enocean.py b/homeassistant/components/binary_sensor/enocean.py index c89148ebc15..be01f63e657 100644 --- a/homeassistant/components/binary_sensor/enocean.py +++ b/homeassistant/components/binary_sensor/enocean.py @@ -67,7 +67,7 @@ class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): This method is called when there is an incoming packet associated with this platform. """ - self.update_ha_state() + self.schedule_update_ha_state() if value2 == 0x70: self.which = 0 self.onoff = 0 diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index e4c586965a6..660eb76098d 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -216,7 +216,7 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self.set_value( class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_SETPOINT, index=self._index, data=temperature) - self.update_ha_state() + self.schedule_update_ha_state() def set_fan_mode(self, fan): """Set new target fan mode.""" diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index cbe7c7166e7..171c78a2fc8 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -231,7 +231,7 @@ class ISYDevice(Entity): # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" - self.update_ha_state() + self.schedule_update_ha_state() @property def domain(self) -> str: diff --git a/homeassistant/components/light/enocean.py b/homeassistant/components/light/enocean.py index e24aca4902d..844cba1e631 100644 --- a/homeassistant/components/light/enocean.py +++ b/homeassistant/components/light/enocean.py @@ -106,4 +106,4 @@ class EnOceanLight(enocean.EnOceanDevice, Light): """Update the internal state of this device.""" self._brightness = math.floor(val / 100.0 * 256.0) self._on_state = bool(val != 0) - self.update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 69c948bb1e9..039e22e73df 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -98,7 +98,7 @@ class LIFX(object): ipaddr, name, power, hue, sat, bri, kel) bulb.set_power(power) bulb.set_color(hue, sat, bri, kel) - bulb.update_ha_state() + bulb.schedule_update_ha_state() def on_color(self, ipaddr, hue, sat, bri, kel): """Initialize the light.""" @@ -106,7 +106,7 @@ class LIFX(object): if bulb is not None: bulb.set_color(hue, sat, bri, kel) - bulb.update_ha_state() + bulb.schedule_update_ha_state() def on_power(self, ipaddr, power): """Initialize the light.""" @@ -114,7 +114,7 @@ class LIFX(object): if bulb is not None: bulb.set_power(power) - bulb.update_ha_state() + bulb.schedule_update_ha_state() # pylint: disable=unused-argument def poll(self, now): diff --git a/homeassistant/components/light/scsgate.py b/homeassistant/components/light/scsgate.py index 7445977c4f3..532dc67562f 100644 --- a/homeassistant/components/light/scsgate.py +++ b/homeassistant/components/light/scsgate.py @@ -106,7 +106,7 @@ class SCSGateLight(Light): return self._toggled = message.toggled - self.update_ha_state() + self.schedule_update_ha_state() command = "off" if self._toggled: diff --git a/homeassistant/components/proximity.py b/homeassistant/components/proximity.py index 18548dc203b..084d6ac7407 100644 --- a/homeassistant/components/proximity.py +++ b/homeassistant/components/proximity.py @@ -71,7 +71,7 @@ def setup_proximity_component(hass, name, config): zone_id, unit_of_measurement) proximity.entity_id = '{}.{}'.format(DOMAIN, proximity_zone) - proximity.update_ha_state() + proximity.schedule_update_ha_state() track_state_change( hass, proximity_devices, proximity.check_proximity_state_change) @@ -161,7 +161,7 @@ class Proximity(Entity): self.dist_to = 'not set' self.dir_of_travel = 'not set' self.nearest = 'not set' - self.update_ha_state() + self.schedule_update_ha_state() return # At least one device is in the monitored zone so update the entity. @@ -169,7 +169,7 @@ class Proximity(Entity): self.dist_to = 0 self.dir_of_travel = 'arrived' self.nearest = devices_in_zone - self.update_ha_state() + self.schedule_update_ha_state() return # We can't check proximity because latitude and longitude don't exist. @@ -214,7 +214,7 @@ class Proximity(Entity): self.dir_of_travel = 'unknown' device_state = self.hass.states.get(closest_device) self.nearest = device_state.name - self.update_ha_state() + self.schedule_update_ha_state() return # Stop if we cannot calculate the direction of travel (i.e. we don't @@ -223,7 +223,7 @@ class Proximity(Entity): self.dist_to = round(distances_to_zone[entity]) self.dir_of_travel = 'unknown' self.nearest = entity_name - self.update_ha_state() + self.schedule_update_ha_state() return # Reset the variables @@ -250,7 +250,7 @@ class Proximity(Entity): self.dist_to = round(dist_to_zone) self.dir_of_travel = direction_of_travel self.nearest = entity_name - self.update_ha_state() + self.schedule_update_ha_state() _LOGGER.debug('proximity.%s update entity: distance=%s: direction=%s: ' 'device=%s', self.friendly_name, round(dist_to_zone), direction_of_travel, entity_name) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 3c0e66679bc..2d497d38273 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -89,7 +89,7 @@ class QSToggleEntity(object): if value != self._value: self._value = value # pylint: disable=no-member - super().update_ha_state() # Part of Entity/ToggleEntity + super().schedule_update_ha_state() # Part of Entity/ToggleEntity return self._value def turn_on(self, **kwargs): diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 60d0b29c51d..351b85cf902 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -92,7 +92,7 @@ def _apply_service(service, service_func, *service_func_args): for device in _devices: service_func(device, *service_func_args) - device.update_ha_state(True) + device.schedule_update_ha_state(True) def _sync_service(service): diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index c035836594c..6eaf9ad1cf9 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -334,7 +334,7 @@ class RfxtrxDevice(Entity): """Update det state of the device.""" self._state = state self._brightness = brightness - self.update_ha_state() + self.schedule_update_ha_state() def _send_command(self, command, brightness=0): if not self._event: @@ -369,4 +369,4 @@ class RfxtrxDevice(Entity): for _ in range(self.signal_repetitions): self._event.device.send_stop(RFXOBJECT.transport) - self.update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/enocean.py b/homeassistant/components/sensor/enocean.py index 009718dd720..5f2f8edf872 100644 --- a/homeassistant/components/sensor/enocean.py +++ b/homeassistant/components/sensor/enocean.py @@ -54,7 +54,7 @@ class EnOceanSensor(enocean.EnOceanDevice, Entity): def value_changed(self, value): """Update the internal state of the device.""" self.power = value - self.update_ha_state() + self.schedule_update_ha_state() @property def state(self): diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index a8b125ae54b..9927b321024 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -169,4 +169,4 @@ class FritzBoxCallMonitor(object): self._sensor.set_state(VALUE_DISCONNECT) att = {"duration": line[3], "closed": isotime} self._sensor.set_attributes(att) - self._sensor.update_ha_state() + self._sensor.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/haveibeenpwned.py b/homeassistant/components/sensor/haveibeenpwned.py index 36330f9bba9..f5b6d8cfba0 100644 --- a/homeassistant/components/sensor/haveibeenpwned.py +++ b/homeassistant/components/sensor/haveibeenpwned.py @@ -110,7 +110,7 @@ class HaveIBeenPwnedSensor(Entity): if self._email in self._data.data: self._state = len(self._data.data[self._email]) - self.update_ha_state() + self.schedule_update_ha_state() def update(self): """Update data and see if it contains data for our email.""" diff --git a/homeassistant/components/sensor/imap_email_content.py b/homeassistant/components/sensor/imap_email_content.py index 5f9a7e7f8e7..b5ff92860a0 100644 --- a/homeassistant/components/sensor/imap_email_content.py +++ b/homeassistant/components/sensor/imap_email_content.py @@ -225,25 +225,23 @@ class EmailContentSensor(Entity): def update(self): """Read emails and publish state change.""" - while True: - email_message = self._email_reader.read_next() + email_message = self._email_reader.read_next() - if email_message is None: - break + if email_message is None: + return - if self.sender_allowed(email_message): - message_body = EmailContentSensor.get_msg_text(email_message) + if self.sender_allowed(email_message): + message_body = EmailContentSensor.get_msg_text(email_message) - if self._value_template is not None: - message_body = self.render_template(email_message) + if self._value_template is not None: + message_body = self.render_template(email_message) - self._message = message_body - self._state_attributes = { - ATTR_FROM: - EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: - EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: - email_message['Date'] - } - self.update_ha_state() + self._message = message_body + self._state_attributes = { + ATTR_FROM: + EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: + EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: + email_message['Date'] + } diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 06d1fd954f2..ebd044343b0 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -121,7 +121,7 @@ class LoopEnergyDevice(Entity): return self._unit_of_measurement def _callback(self): - self.update_ha_state(True) + self.schedule_update_ha_state(True) class LoopEnergyElec(LoopEnergyDevice): diff --git a/homeassistant/components/sensor/pilight.py b/homeassistant/components/sensor/pilight.py index 8a403b4adbf..86ca496eb62 100644 --- a/homeassistant/components/sensor/pilight.py +++ b/homeassistant/components/sensor/pilight.py @@ -89,7 +89,7 @@ class PilightSensor(Entity): try: value = call.data[self._variable] self._state = value - self.update_ha_state() + self.schedule_update_ha_state() except KeyError: _LOGGER.error( 'No variable %s in received code data %s', diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py index 00a9370a446..2d1ad579342 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -231,7 +231,7 @@ class Sun(Entity): def point_in_time_listener(self, now): """Called when the state of the sun has changed.""" self.update_as_of(now) - self.update_ha_state() + self.schedule_update_ha_state() # Schedule next update at next_change+1 second so sun state has changed track_point_in_utc_time( @@ -241,4 +241,4 @@ class Sun(Entity): def timer_update(self, time): """Needed to update solar elevation and azimuth.""" self.update_sun_position(time) - self.update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 3cc30a38908..e97745cc0c6 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -188,13 +188,13 @@ class BroadlinkRMSwitch(SwitchDevice): """Turn the device on.""" if self._sendpacket(self._command_on): self._state = True - self.update_ha_state() + self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" if self._sendpacket(self._command_off): self._state = False - self.update_ha_state() + self.schedule_update_ha_state() def _sendpacket(self, packet, retry=2): """Send packet to device.""" diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 0a6d487c331..e646afef172 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -114,10 +114,10 @@ class RPiRFSwitch(SwitchDevice): """Turn the switch on.""" if self._send_code(self._code_on, self._protocol, self._pulselength): self._state = True - self.update_ha_state() + self.schedule_update_ha_state() def turn_off(self): """Turn the switch off.""" if self._send_code(self._code_off, self._protocol, self._pulselength): self._state = False - self.update_ha_state() + self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/scsgate.py b/homeassistant/components/switch/scsgate.py index d7670dff067..965011d12ea 100644 --- a/homeassistant/components/switch/scsgate.py +++ b/homeassistant/components/switch/scsgate.py @@ -141,7 +141,7 @@ class SCSGateSwitch(SwitchDevice): return self._toggled = message.toggled - self.update_ha_state() + self.schedule_update_ha_state() command = "off" if self._toggled: diff --git a/homeassistant/components/weblink.py b/homeassistant/components/weblink.py index bec89787048..7fe121d64c9 100644 --- a/homeassistant/components/weblink.py +++ b/homeassistant/components/weblink.py @@ -54,7 +54,7 @@ class Link(Entity): self._url = url self._icon = icon self.entity_id = DOMAIN + '.%s' % slugify(name) - self.update_ha_state() + self.schedule_update_ha_state() @property def icon(self): diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 6bde1600a82..39256657c45 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -115,7 +115,7 @@ def setup(hass, config): """Force all devices to poll the Wink API.""" _LOGGER.info("Refreshing Wink states from API") for entity in hass.data[DOMAIN]['entities']: - entity.update_ha_state(True) + entity.schedule_update_ha_state(True) hass.services.register(DOMAIN, 'Refresh state from Wink', force_update) def pull_new_devices(call): @@ -150,14 +150,14 @@ class WinkDevice(Entity): if message is None: _LOGGER.error("Error on pubnub update for %s " "polling API for current state", self.name) - self.update_ha_state(True) + self.schedule_update_ha_state(True) else: self.wink.pubnub_update(message) - self.update_ha_state() + self.schedule_update_ha_state() except (ValueError, KeyError, AttributeError): _LOGGER.error("Error in pubnub JSON for %s " "polling API for current state", self.name) - self.update_ha_state(True) + self.schedule_update_ha_state(True) @property def name(self): diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a3bf1a03386..895df3d6721 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -180,14 +180,9 @@ class Entity(object): If force_refresh == True will update entity before setting state. """ - # We're already in a thread, do the force refresh here. - if force_refresh and not hasattr(self, 'async_update'): - self.update() - force_refresh = False - - run_coroutine_threadsafe( - self.async_update_ha_state(force_refresh), self.hass.loop - ).result() + _LOGGER.warning("'update_ha_state' is deprecated. " + "Use 'schedule_update_ha_state' instead.") + self.schedule_update_ha_state(force_refresh) @asyncio.coroutine def async_update_ha_state(self, force_refresh=False): @@ -280,11 +275,6 @@ class Entity(object): That is only needed on executor to not block. """ - # We're already in a thread, do the force refresh here. - if force_refresh and not hasattr(self, 'async_update'): - self.update() - force_refresh = False - self.hass.add_job(self.async_update_ha_state(force_refresh)) def remove(self) -> None: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1b20695b349..1bf09e16247 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -309,13 +309,10 @@ class EntityPlatform(object): def schedule_add_entities(self, new_entities, update_before_add=False): """Add entities for a single platform.""" - if update_before_add: - for entity in new_entities: - entity.update() - run_callback_threadsafe( self.component.hass.loop, - self.async_schedule_add_entities, list(new_entities), False + self.async_schedule_add_entities, list(new_entities), + update_before_add ).result() @callback diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index 3ccfcd7eb64..62be4aca267 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -140,10 +140,12 @@ class TestMediaPlayer(unittest.TestCase): self.hass = get_test_home_assistant() self.mock_mp_1 = MockMediaPlayer(self.hass, 'mock1') - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() self.mock_mp_2 = MockMediaPlayer(self.hass, 'mock2') - self.mock_mp_2.update_ha_state() + self.mock_mp_2.schedule_update_ha_state() + + self.hass.block_till_done() self.mock_mute_switch_id = switch.ENTITY_ID_FORMAT.format('mute') self.hass.states.set(self.mock_mute_switch_id, STATE_OFF) @@ -315,19 +317,22 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual(None, ump._child_state) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(self.mock_mp_1.entity_id, ump._child_state.entity_id) self.mock_mp_2._state = STATE_PLAYING - self.mock_mp_2.update_ha_state() + self.mock_mp_2.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(self.mock_mp_1.entity_id, ump._child_state.entity_id) self.mock_mp_1._state = STATE_OFF - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(self.mock_mp_2.entity_id, ump._child_state.entity_id) @@ -362,7 +367,8 @@ class TestMediaPlayer(unittest.TestCase): self.assertTrue(ump.state, STATE_OFF) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(STATE_PLAYING, ump.state) @@ -382,7 +388,8 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual(STATE_ON, ump.state) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(STATE_PLAYING, ump.state) @@ -402,12 +409,14 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual(None, ump.volume_level) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(0, ump.volume_level) self.mock_mp_1._volume_level = 1 - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(1, ump.volume_level) @@ -425,7 +434,8 @@ class TestMediaPlayer(unittest.TestCase): self.mock_mp_1._state = STATE_PLAYING self.mock_mp_1._media_image_url = TEST_URL - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() # mock_mp_1 will convert the url to the api proxy url. This test # ensures ump passes through the same url without an additional proxy. @@ -443,12 +453,14 @@ class TestMediaPlayer(unittest.TestCase): self.assertFalse(ump.is_volume_muted) self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertFalse(ump.is_volume_muted) self.mock_mp_1._is_volume_muted = True - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertTrue(ump.is_volume_muted) @@ -513,7 +525,8 @@ class TestMediaPlayer(unittest.TestCase): self.mock_mp_1._supported_features = 512 self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.assertEqual(512, ump.supported_features) @@ -534,7 +547,8 @@ class TestMediaPlayer(unittest.TestCase): run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() check_flags = universal.SUPPORT_TURN_ON | universal.SUPPORT_TURN_OFF \ @@ -553,9 +567,10 @@ class TestMediaPlayer(unittest.TestCase): run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.mock_mp_1._state = STATE_OFF - self.mock_mp_1.update_ha_state() + self.mock_mp_1.schedule_update_ha_state() self.mock_mp_2._state = STATE_OFF - self.mock_mp_2.update_ha_state() + self.mock_mp_2.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() run_coroutine_threadsafe( @@ -574,7 +589,8 @@ class TestMediaPlayer(unittest.TestCase): run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.mock_mp_2._state = STATE_PLAYING - self.mock_mp_2.update_ha_state() + self.mock_mp_2.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() run_coroutine_threadsafe( @@ -672,7 +688,8 @@ class TestMediaPlayer(unittest.TestCase): run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() self.mock_mp_2._state = STATE_PLAYING - self.mock_mp_2.update_ha_state() + self.mock_mp_2.schedule_update_ha_state() + self.hass.block_till_done() run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() run_coroutine_threadsafe(ump.async_turn_off(), self.hass.loop).result() diff --git a/tests/components/sensor/test_imap_email_content.py b/tests/components/sensor/test_imap_email_content.py index f8e5caf0dd2..0bba3647c6c 100644 --- a/tests/components/sensor/test_imap_email_content.py +++ b/tests/components/sensor/test_imap_email_content.py @@ -4,7 +4,6 @@ import email from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import datetime -from threading import Event import unittest from homeassistant.helpers.template import Template @@ -59,7 +58,8 @@ class EmailContentSensor(unittest.TestCase): None) sensor.entity_id = 'sensor.emailtest' - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() self.assertEqual("Test Message", sensor.state) self.assertEqual('sender@test.com', sensor.device_state_attributes['from']) @@ -87,7 +87,8 @@ class EmailContentSensor(unittest.TestCase): ['sender@test.com'], None) sensor.entity_id = "sensor.emailtest" - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() self.assertEqual("Test Message", sensor.state) def test_multi_part_only_html(self): @@ -110,7 +111,8 @@ class EmailContentSensor(unittest.TestCase): None) sensor.entity_id = 'sensor.emailtest' - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() self.assertEqual( "Test Message", sensor.state) @@ -132,7 +134,8 @@ class EmailContentSensor(unittest.TestCase): ['sender@test.com'], None) sensor.entity_id = 'sensor.emailtest' - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() self.assertEqual("Test Message", sensor.state) def test_multiple_emails(self): @@ -151,12 +154,8 @@ class EmailContentSensor(unittest.TestCase): test_message2['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57) test_message2.set_payload("Test Message 2") - states_received = Event() - def state_changed_listener(entity_id, from_s, to_s): states.append(to_s) - if len(states) == 2: - states_received.set() track_state_change( self.hass, ['sensor.emailtest'], state_changed_listener) @@ -167,10 +166,11 @@ class EmailContentSensor(unittest.TestCase): 'test_emails_sensor', ['sender@test.com'], None) sensor.entity_id = 'sensor.emailtest' - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() + sensor.schedule_update_ha_state(True) self.hass.block_till_done() - states_received.wait(5) self.assertEqual("Test Message", states[0].state) self.assertEqual("Test Message 2", states[1].state) @@ -190,7 +190,8 @@ class EmailContentSensor(unittest.TestCase): 'test_emails_sensor', ['other@test.com'], None) sensor.entity_id = 'sensor.emailtest' - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() self.assertEqual(None, sensor.state) def test_template(self): @@ -208,7 +209,8 @@ class EmailContentSensor(unittest.TestCase): self.hass)) sensor.entity_id = 'sensor.emailtest' - sensor.update() + sensor.schedule_update_ha_state(True) + self.hass.block_till_done() self.assertEqual( "Test from sender@test.com with message Test Message", sensor.state) diff --git a/tests/components/test_init.py b/tests/components/test_init.py index 0971f0a16bd..6a0db023671 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -103,7 +103,8 @@ class TestComponentsCore(unittest.TestCase): ent = entity.Entity() ent.entity_id = 'test.entity' ent.hass = self.hass - ent.update_ha_state() + ent.schedule_update_ha_state() + self.hass.block_till_done() state = self.hass.states.get('test.entity') assert state is not None @@ -130,7 +131,8 @@ class TestComponentsCore(unittest.TestCase): assert 10 == self.hass.config.latitude assert 20 == self.hass.config.longitude - ent.update_ha_state() + ent.schedule_update_ha_state() + self.hass.block_till_done() state = self.hass.states.get('test.entity') assert state is not None diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 965afde8309..d0909fa33b7 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -77,7 +77,8 @@ class TestHelpersEntity(object): self.entity = entity.Entity() self.entity.entity_id = 'test.overwrite_hidden_true' self.hass = self.entity.hass = get_test_home_assistant() - self.entity.update_ha_state() + self.entity.schedule_update_ha_state() + self.hass.block_till_done() def teardown_method(self, method): """Stop everything that was started.""" @@ -92,7 +93,8 @@ class TestHelpersEntity(object): """Test we can overwrite hidden property to True.""" self.hass.data[DATA_CUSTOMIZE] = EntityValues({ self.entity.entity_id: {ATTR_HIDDEN: True}}) - self.entity.update_ha_state() + self.entity.schedule_update_ha_state() + self.hass.block_till_done() state = self.hass.states.get(self.entity.entity_id) assert state.attributes.get(ATTR_HIDDEN) @@ -126,6 +128,7 @@ class TestHelpersEntity(object): assert state.attributes.get(ATTR_DEVICE_CLASS) is None with patch('homeassistant.helpers.entity.Entity.device_class', new='test_class'): - self.entity.update_ha_state() + self.entity.schedule_update_ha_state() + self.hass.block_till_done() state = self.hass.states.get(self.entity.entity_id) assert state.attributes.get(ATTR_DEVICE_CLASS) == 'test_class' diff --git a/tests/test_config.py b/tests/test_config.py index 18b69f81a9d..990bd557e70 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -213,7 +213,7 @@ class TestConfig(unittest.TestCase): entity = Entity() entity.entity_id = 'test.test' entity.hass = self.hass - entity.update_ha_state() + entity.schedule_update_ha_state() self.hass.block_till_done() From 1522e673516b8b807540d1cd87c564ef985c3639 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 5 Mar 2017 01:19:01 +0200 Subject: [PATCH 128/198] Restore for automation entities (#6254) * Restore for automation entities * coroutine * no clue what i'm doing now * Still passes nicely in py 3.4 --- .../components/automation/__init__.py | 13 +++- tests/common.py | 17 ++--- tests/components/automation/test_init.py | 76 +++++++++++++++---- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 0e734d7214d..a5fc52c448e 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.loader import get_platform from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv @@ -265,9 +266,15 @@ class AutomationEntity(ToggleEntity): @asyncio.coroutine def async_added_to_hass(self) -> None: - """Startup if initial_state.""" - if self._initial_state: - yield from self.async_enable() + """Startup with initial state or previous state.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state is None: + if self._initial_state: + yield from self.async_enable() + else: + self._last_triggered = state.attributes.get('last_triggered') + if state.state == STATE_ON: + yield from self.async_enable() @asyncio.coroutine def async_turn_on(self, **kwargs) -> None: diff --git a/tests/common.py b/tests/common.py index 34cd9765695..840dfd50caa 100644 --- a/tests/common.py +++ b/tests/common.py @@ -131,6 +131,7 @@ def async_test_home_assistant(loop): @ha.callback def clear_instance(event): + """Clear global instance.""" global INST_COUNT INST_COUNT -= 1 @@ -140,20 +141,18 @@ def async_test_home_assistant(loop): def mock_service(hass, domain, service): - """Setup a fake service. - - Return a list that logs all calls to fake service. - """ + """Setup a fake service & return a list that logs calls to this service.""" calls = [] - # pylint: disable=redefined-outer-name - @ha.callback - def mock_service(call): + @asyncio.coroutine + def mock_service_log(call): # pylint: disable=unnecessary-lambda """"Mocked service call.""" calls.append(call) - # pylint: disable=unnecessary-lambda - hass.services.register(domain, service, mock_service) + if hass.loop.__dict__.get("_thread_ident", 0) == threading.get_ident(): + hass.services.async_register(domain, service, mock_service_log) + else: + hass.services.register(domain, service, mock_service_log) return calls diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index fa7658f3407..9dc08089011 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,17 +1,19 @@ """The tests for the automation component.""" -import unittest +import asyncio from datetime import timedelta +import unittest from unittest.mock import patch -from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.core import State +from homeassistant.bootstrap import setup_component, async_setup_component import homeassistant.components.automation as automation -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_OFF from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant, assert_setup_component, \ - fire_time_changed, mock_component +from tests.common import ( + assert_setup_component, get_test_home_assistant, fire_time_changed, + mock_component, mock_service, mock_restore_cache) # pylint: disable=invalid-name @@ -22,14 +24,7 @@ class TestAutomation(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() mock_component(self.hass, 'group') - self.calls = [] - - @callback - def record_call(service): - """Helper to record calls.""" - self.calls.append(service) - - self.hass.services.register('test', 'automation', record_call) + self.calls = mock_service(self.hass, 'test', 'automation') def tearDown(self): """Stop everything that was started.""" @@ -572,3 +567,56 @@ class TestAutomation(unittest.TestCase): self.hass.bus.fire('test_event') self.hass.block_till_done() assert len(self.calls) == 2 + + +@asyncio.coroutine +def test_automation_restore_state(hass): + """Ensure states are restored on startup.""" + time = dt_util.utcnow() + + mock_restore_cache(hass, ( + State('automation.hello', STATE_ON), + State('automation.bye', STATE_OFF, {'last_triggered': time}), + )) + + config = {automation.DOMAIN: [{ + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event_hello', + }, + 'action': {'service': 'test.automation'} + }, { + 'alias': 'bye', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event_bye', + }, + 'action': {'service': 'test.automation'} + }]} + + assert (yield from async_setup_component(hass, automation.DOMAIN, config)) + + state = hass.states.get('automation.hello') + assert state + assert state.state == STATE_ON + + state = hass.states.get('automation.bye') + assert state + assert state.state == STATE_OFF + assert state.attributes.get('last_triggered') == time + + calls = mock_service(hass, 'test', 'automation') + + assert automation.is_on(hass, 'automation.bye') is False + + hass.bus.async_fire('test_event_bye') + yield from hass.async_block_till_done() + assert len(calls) == 0 + + assert automation.is_on(hass, 'automation.hello') + + hass.bus.async_fire('test_event_hello') + yield from hass.async_block_till_done() + + assert len(calls) == 1 From b939626497d2d3a0ef66b777b21a2ed950ada842 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Mar 2017 17:15:20 -0800 Subject: [PATCH 129/198] Fix tests no internet (#6411) * Fix honeywell tests without internet * Fix device tracker without internet * Fix MFI using internet during tests * Remove I/O from apns tests --- homeassistant/components/climate/honeywell.py | 1 + homeassistant/components/notify/apns.py | 46 ++-- tests/components/climate/test_honeywell.py | 3 +- tests/components/device_tracker/test_init.py | 6 +- tests/components/notify/test_apns.py | 236 ++++++++++++------ tests/components/sensor/test_mfi.py | 19 +- 6 files changed, 201 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 7b65ed4f077..5152519459b 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -401,3 +401,4 @@ class HoneywellUSThermostat(ClimateDevice): return False self._device = devices[0] + return True diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 09716065751..54635669766 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -126,6 +126,27 @@ class ApnsDevice(object): return not self.__eq__(other) +def _write_device(out, device): + """Write a single device to file.""" + attributes = [] + if device.name is not None: + attributes.append( + 'name: {}'.format(device.name)) + if device.tracking_device_id is not None: + attributes.append( + 'tracking_device_id: {}'.format(device.tracking_device_id)) + if device.disabled: + attributes.append('disabled: True') + + out.write(device.push_id) + out.write(": {") + if len(attributes) > 0: + separator = ", " + out.write(separator.join(attributes)) + + out.write("}\n") + + class ApnsNotificationService(BaseNotificationService): """Implement the notification service for the APNS service.""" @@ -171,32 +192,11 @@ class ApnsNotificationService(BaseNotificationService): self.device_states[entity_id] = str(to_s.state) return - @staticmethod - def write_device(out, device): - """Write a single device to file.""" - attributes = [] - if device.name is not None: - attributes.append( - 'name: {}'.format(device.name)) - if device.tracking_device_id is not None: - attributes.append( - 'tracking_device_id: {}'.format(device.tracking_device_id)) - if device.disabled: - attributes.append('disabled: True') - - out.write(device.push_id) - out.write(": {") - if len(attributes) > 0: - separator = ", " - out.write(separator.join(attributes)) - - out.write("}\n") - def write_devices(self): """Write all known devices to file.""" with open(self.yaml_path, 'w+') as out: for _, device in self.devices.items(): - ApnsNotificationService.write_device(out, device) + _write_device(out, device) def register(self, call): """Register a device to receive push messages.""" @@ -215,7 +215,7 @@ class ApnsNotificationService(BaseNotificationService): if current_device is None: self.devices[push_id] = device with open(self.yaml_path, 'a') as out: - self.write_device(out, device) + _write_device(out, device) return True if device != current_device: diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py index a4cdda2adc4..3fcb6a1575c 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -436,7 +436,8 @@ class TestHoneywellUS(unittest.TestCase): self.assertFalse(self.honeywell.is_away_mode_on) self.assertEqual(self.device.hold_heat, False) - def test_retry(self): + @mock.patch('somecomfort.SomeComfort') + def test_retry(self, test_somecomfort): """Test retry connection.""" old_device = self.honeywell._device self.honeywell._retry() diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index c12d984d275..3d0a99ec939 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -24,7 +24,7 @@ from homeassistant.remote import JSONEncoder from tests.common import ( get_test_home_assistant, fire_time_changed, fire_service_discovered, - patch_yaml_files, assert_setup_component, mock_restore_cache) + patch_yaml_files, assert_setup_component, mock_restore_cache, mock_coro) from ...test_util.aiohttp import mock_aiohttp_client @@ -521,7 +521,9 @@ class TestComponentsDeviceTracker(unittest.TestCase): timedelta(seconds=0)) assert len(config) == 0 - def test_see_state(self): + @patch('homeassistant.components.device_tracker.Device' + '.set_vendor_for_mac', return_value=mock_coro()) + def test_see_state(self, mock_set_vendor): """Test device tracker see records state correctly.""" self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, TEST_PLATFORM)) diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 7246aea3302..628c38ae180 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -1,16 +1,16 @@ """The tests for the APNS component.""" -import os +import io import unittest -from unittest.mock import patch -from unittest.mock import Mock +from unittest.mock import Mock, patch from apns2.errors import Unregistered +import yaml import homeassistant.components.notify as notify from homeassistant.bootstrap import setup_component -from homeassistant.components.notify.apns import ApnsNotificationService -from homeassistant.config import load_yaml_config_file +from homeassistant.components.notify import apns from homeassistant.core import State + from tests.common import assert_setup_component, get_test_home_assistant CONFIG = { @@ -37,6 +37,9 @@ class TestApns(unittest.TestCase): @patch('os.path.isfile', Mock(return_value=True)) @patch('os.access', Mock(return_value=True)) def _setup_notify(self): + assert isinstance(apns.load_yaml_config_file, Mock), \ + 'Found unmocked load_yaml' + with assert_setup_component(1) as handle_config: assert setup_component(self.hass, notify.DOMAIN, CONFIG) assert handle_config[notify.DOMAIN] @@ -98,69 +101,103 @@ class TestApns(unittest.TestCase): assert setup_component(self.hass, notify.DOMAIN, config) assert not handle_config[notify.DOMAIN] - def test_register_new_device(self): + @patch('homeassistant.components.notify.apns._write_device') + def test_register_new_device(self, mock_write): """Test registering a new device with a name.""" - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('5678: {name: test device 2}\n') + yaml_file = {5678: {'name': 'test device 2'}} + + written_devices = [] + + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) + + mock_write.side_effect = fake_write + + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() - self._setup_notify() self.assertTrue(self.hass.services.call(notify.DOMAIN, 'apns_test_app', {'push_id': '1234', 'name': 'test device'}, blocking=True)) - devices = {str(key): value for (key, value) in - load_yaml_config_file(devices_path).items()} + assert len(written_devices) == 1 + assert written_devices[0].name == 'test device' - test_device_1 = devices.get('1234') - test_device_2 = devices.get('5678') - - self.assertIsNotNone(test_device_1) - self.assertIsNotNone(test_device_2) - - self.assertEqual('test device', test_device_1.get('name')) - - os.remove(devices_path) - - def test_register_device_without_name(self): + @patch('homeassistant.components.notify.apns._write_device') + def test_register_device_without_name(self, mock_write): """Test registering a without a name.""" - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('5678: {name: test device 2}\n') + yaml_file = { + 1234: { + 'name': 'test device 1', + 'tracking_device_id': 'tracking123', + }, + 5678: { + 'name': 'test device 2', + 'tracking_device_id': 'tracking456', + }, + } + + written_devices = [] + + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) + + mock_write.side_effect = fake_write + + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() - self._setup_notify() self.assertTrue(self.hass.services.call(notify.DOMAIN, 'apns_test_app', {'push_id': '1234'}, blocking=True)) - devices = {str(key): value for (key, value) in - load_yaml_config_file(devices_path).items()} + devices = {dev.push_id: dev for dev in written_devices} test_device = devices.get('1234') self.assertIsNotNone(test_device) - self.assertIsNone(test_device.get('name')) + self.assertIsNone(test_device.name) - os.remove(devices_path) - - def test_update_existing_device(self): + @patch('homeassistant.components.notify.apns._write_device') + def test_update_existing_device(self, mock_write): """Test updating an existing device.""" - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('1234: {name: test device 1}\n') - out.write('5678: {name: test device 2}\n') + yaml_file = { + 1234: { + 'name': 'test device 1', + }, + 5678: { + 'name': 'test device 2', + }, + } + + written_devices = [] + + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) + + mock_write.side_effect = fake_write + + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() - self._setup_notify() self.assertTrue(self.hass.services.call(notify.DOMAIN, 'apns_test_app', {'push_id': '1234', 'name': 'updated device 1'}, blocking=True)) - devices = {str(key): value for (key, value) in - load_yaml_config_file(devices_path).items()} + devices = {dev.push_id: dev for dev in written_devices} test_device_1 = devices.get('1234') test_device_2 = devices.get('5678') @@ -168,28 +205,42 @@ class TestApns(unittest.TestCase): self.assertIsNotNone(test_device_1) self.assertIsNotNone(test_device_2) - self.assertEqual('updated device 1', test_device_1.get('name')) + self.assertEqual('updated device 1', test_device_1.name) - os.remove(devices_path) - - def test_update_existing_device_with_tracking_id(self): + @patch('homeassistant.components.notify.apns._write_device') + def test_update_existing_device_with_tracking_id(self, mock_write): """Test updating an existing device that has a tracking id.""" - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('1234: {name: test device 1, ' - 'tracking_device_id: tracking123}\n') - out.write('5678: {name: test device 2, ' - 'tracking_device_id: tracking456}\n') + yaml_file = { + 1234: { + 'name': 'test device 1', + 'tracking_device_id': 'tracking123', + }, + 5678: { + 'name': 'test device 2', + 'tracking_device_id': 'tracking456', + }, + } + + written_devices = [] + + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) + + mock_write.side_effect = fake_write + + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() - self._setup_notify() self.assertTrue(self.hass.services.call(notify.DOMAIN, 'apns_test_app', {'push_id': '1234', 'name': 'updated device 1'}, blocking=True)) - devices = {str(key): value for (key, value) in - load_yaml_config_file(devices_path).items()} + devices = {dev.push_id: dev for dev in written_devices} test_device_1 = devices.get('1234') test_device_2 = devices.get('5678') @@ -198,22 +249,21 @@ class TestApns(unittest.TestCase): self.assertIsNotNone(test_device_2) self.assertEqual('tracking123', - test_device_1.get('tracking_device_id')) + test_device_1.tracking_device_id) self.assertEqual('tracking456', - test_device_2.get('tracking_device_id')) - - os.remove(devices_path) + test_device_2.tracking_device_id) @patch('apns2.client.APNsClient') def test_send(self, mock_client): """Test updating an existing device.""" send = mock_client.return_value.send_notification - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('1234: {name: test device 1}\n') + yaml_file = {1234: {'name': 'test device 1'}} - self._setup_notify() + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() self.assertTrue(self.hass.services.call( 'notify', 'test_app', @@ -240,11 +290,15 @@ class TestApns(unittest.TestCase): """Test updating an existing device.""" send = mock_client.return_value.send_notification - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('1234: {name: test device 1, disabled: True}\n') + yaml_file = {1234: { + 'name': 'test device 1', + 'disabled': True, + }} - self._setup_notify() + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() self.assertTrue(self.hass.services.call( 'notify', 'test_app', @@ -268,7 +322,7 @@ class TestApns(unittest.TestCase): out.write('5678: {name: test device 2, ' 'tracking_device_id: tracking456}\n') - notify_service = ApnsNotificationService( + notify_service = apns.ApnsNotificationService( self.hass, 'test_app', 'testapp.appname', @@ -295,26 +349,58 @@ class TestApns(unittest.TestCase): self.assertEqual('Hello', payload.alert) @patch('apns2.client.APNsClient') - def test_disable_when_unregistered(self, mock_client): + @patch('homeassistant.components.notify.apns._write_device') + def test_disable_when_unregistered(self, mock_write, mock_client): """Test disabling a device when it is unregistered.""" send = mock_client.return_value.send_notification send.side_effect = Unregistered() - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('1234: {name: test device 1}\n') + yaml_file = { + 1234: { + 'name': 'test device 1', + 'tracking_device_id': 'tracking123', + }, + 5678: { + 'name': 'test device 2', + 'tracking_device_id': 'tracking456', + }, + } - self._setup_notify() + written_devices = [] + + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) + + mock_write.side_effect = fake_write + + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)): + self._setup_notify() self.assertTrue(self.hass.services.call('notify', 'test_app', {'message': 'Hello'}, blocking=True)) - devices = {str(key): value for (key, value) in - load_yaml_config_file(devices_path).items()} + devices = {dev.push_id: dev for dev in written_devices} test_device_1 = devices.get('1234') self.assertIsNotNone(test_device_1) - self.assertEqual(True, test_device_1.get('disabled')) + self.assertEqual(True, test_device_1.disabled) - os.remove(devices_path) + +def test_write_device(): + """Test writing device.""" + out = io.StringIO() + device = apns.ApnsDevice('123', 'name', 'track_id', True) + + apns._write_device(out, device) + data = yaml.load(out.getvalue()) + assert data == { + 123: { + 'name': 'name', + 'tracking_device_id': 'track_id', + 'disabled': True + }, + } diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index 82577a5b2a0..a55250c8872 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -38,29 +38,31 @@ class TestMfiSensorSetup(unittest.TestCase): """Stop everything that was started.""" self.hass.stop() - def test_setup_missing_config(self): + @mock.patch('mficlient.client.MFiClient') + def test_setup_missing_config(self, mock_client): """Test setup with missing configuration.""" config = { 'sensor': { 'platform': 'mfi', } } - self.assertFalse(self.PLATFORM.setup_platform(self.hass, config, None)) + assert setup_component(self.hass, 'sensor', config) + assert not mock_client.called - @mock.patch('mficlient.client') + @mock.patch('mficlient.client.MFiClient') def test_setup_failed_login(self, mock_client): """Test setup with login failure.""" - mock_client.FailedToLogin = Exception() - mock_client.MFiClient.side_effect = mock_client.FailedToLogin + from mficlient.client import FailedToLogin + + mock_client.side_effect = FailedToLogin self.assertFalse( self.PLATFORM.setup_platform( self.hass, dict(self.GOOD_CONFIG), None)) - @mock.patch('mficlient.client') + @mock.patch('mficlient.client.MFiClient') def test_setup_failed_connect(self, mock_client): """Test setup with conection failure.""" - mock_client.FailedToLogin = Exception() - mock_client.MFiClient.side_effect = requests.exceptions.ConnectionError + mock_client.side_effect = requests.exceptions.ConnectionError self.assertFalse( self.PLATFORM.setup_platform( self.hass, dict(self.GOOD_CONFIG), None)) @@ -116,7 +118,6 @@ class TestMfiSensorSetup(unittest.TestCase): ports = {i: mock.MagicMock(model=model) for i, model in enumerate(mfi.SENSOR_MODELS)} ports['bad'] = mock.MagicMock(model='notasensor') - print(ports['bad'].model) mock_client.return_value.get_devices.return_value = \ [mock.MagicMock(ports=ports)] assert setup_component(self.hass, sensor.DOMAIN, self.GOOD_CONFIG) From 307514e3a7115c1ce1eb3b2a343607fa5688b8bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Mar 2017 19:57:04 -0800 Subject: [PATCH 130/198] Prevent more I/O in apns (#6413) --- homeassistant/components/notify/apns.py | 1 - tests/components/notify/test_apns.py | 39 +++++++++++++++---------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 54635669766..50842c69a61 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -190,7 +190,6 @@ class ApnsNotificationService(BaseNotificationService): has a tracking id specified. """ self.device_states[entity_id] = str(to_s.state) - return def write_devices(self): """Write all known devices to file.""" diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 628c38ae180..9bacd1391f1 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -1,7 +1,7 @@ """The tests for the APNS component.""" import io import unittest -from unittest.mock import Mock, patch +from unittest.mock import Mock, patch, mock_open from apns2.errors import Unregistered import yaml @@ -23,6 +23,7 @@ CONFIG = { } +@patch('homeassistant.components.notify.apns.open', mock_open(), create=True) class TestApns(unittest.TestCase): """Test the APNS component.""" @@ -315,28 +316,34 @@ class TestApns(unittest.TestCase): """Test updating an existing device.""" send = mock_client.return_value.send_notification - devices_path = self.hass.config.path('test_app_apns.yaml') - with open(devices_path, 'w+') as out: - out.write('1234: {name: test device 1, ' - 'tracking_device_id: tracking123}\n') - out.write('5678: {name: test device 2, ' - 'tracking_device_id: tracking456}\n') + yaml_file = { + 1234: { + 'name': 'test device 1', + 'tracking_device_id': 'tracking123', + }, + 5678: { + 'name': 'test device 2', + 'tracking_device_id': 'tracking456', + }, + } - notify_service = apns.ApnsNotificationService( - self.hass, - 'test_app', - 'testapp.appname', - False, - 'test_app.pem' - ) + with patch( + 'homeassistant.components.notify.apns.load_yaml_config_file', + Mock(return_value=yaml_file)), \ + patch('os.path.isfile', Mock(return_value=True)): + notify_service = apns.ApnsNotificationService( + self.hass, + 'test_app', + 'testapp.appname', + False, + 'test_app.pem' + ) notify_service.device_state_changed_listener( 'device_tracker.tracking456', State('device_tracker.tracking456', None), State('device_tracker.tracking456', 'home')) - self.hass.block_till_done() - notify_service.send_message(message='Hello', target='home') self.assertTrue(send.called) From c0bf3d7f32d64007f99d55f2463ed547ea6703dd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 5 Mar 2017 08:06:53 +0100 Subject: [PATCH 131/198] Restore flow on device_tracker platform (#6374) * Restore flow on device_tracker platform * fix flow * fix lint --- .../components/device_tracker/__init__.py | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index c11e25ae130..66977222c5d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -132,18 +132,6 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): devices = yield from async_load_config(yaml_path, hass, consider_home) tracker = DeviceTracker(hass, consider_home, track_new, devices) - # added_to_hass - add_tasks = [device.async_added_to_hass() for device in devices - if device.track] - if add_tasks: - yield from asyncio.wait(add_tasks, loop=hass.loop) - - # update tracked devices - update_tasks = [device.async_update_ha_state() for device in devices - if device.track] - if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) - @asyncio.coroutine def async_setup_platform(p_type, p_config, disc_info=None): """Setup a device tracker platform.""" @@ -226,6 +214,8 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): hass.services.async_register( DOMAIN, SERVICE_SEE, async_see_service, descriptions.get(SERVICE_SEE)) + # restore + yield from tracker.async_setup_tracked_device() return True @@ -356,6 +346,27 @@ class DeviceTracker(object): device.stale(now): self.hass.async_add_job(device.async_update_ha_state(True)) + @asyncio.coroutine + def async_setup_tracked_device(self): + """Setup all not exists tracked devices. + + This method is a coroutine. + """ + @asyncio.coroutine + def async_init_single_device(dev): + """Init a single device_tracker entity.""" + yield from dev.async_added_to_hass() + yield from dev.async_update_ha_state() + + tasks = [] + for device in self.devices.values(): + if device.track and not device.last_seen: + tasks.append(self.hass.async_add_job( + async_init_single_device(device))) + + if tasks: + yield from asyncio.wait(tasks, loop=self.hass.loop) + class Device(Entity): """Represent a tracked device.""" From 96aae1292b76d9d06febf9f71e1284049831c665 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 5 Mar 2017 08:44:34 +0100 Subject: [PATCH 132/198] switch.tplink: catch exceptions coming from pyHS100 to avoid flooding the logs when the plug is not available (#6400) --- homeassistant/components/switch/tplink.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 961ee72496e..f740a5f1614 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -83,6 +83,7 @@ class SmartPlugSwitch(SwitchDevice): def update(self): """Update the TP-Link switch's state.""" + from pyHS100 import SmartPlugException try: self._state = self.smartplug.state == \ self.smartplug.SWITCH_STATE_ON @@ -107,5 +108,5 @@ class SmartPlugSwitch(SwitchDevice): # device returned no daily history pass - except OSError: - _LOGGER.warning('Could not update status for %s', self.name) + except (SmartPlugException, OSError) as ex: + _LOGGER.warning('Could not read state for %s: %s', self.name, ex) From 928e025910d6dd387925e482b55f339c5d4657db Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Sun, 5 Mar 2017 03:03:00 -0500 Subject: [PATCH 133/198] Added sensors to support Ring.com devices (#6419) --- .coveragerc | 1 + homeassistant/components/sensor/ring.py | 160 ++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 164 insertions(+) create mode 100644 homeassistant/components/sensor/ring.py diff --git a/.coveragerc b/.coveragerc index 6787c56d639..baacdc5ccb0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -362,6 +362,7 @@ omit = homeassistant/components/sensor/pocketcasts.py homeassistant/components/sensor/pvoutput.py homeassistant/components/sensor/qnap.py + homeassistant/components/sensor/ring.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py diff --git a/homeassistant/components/sensor/ring.py b/homeassistant/components/sensor/ring.py new file mode 100644 index 00000000000..ab64557dd2f --- /dev/null +++ b/homeassistant/components/sensor/ring.py @@ -0,0 +1,160 @@ +""" +This component provides HA sensor support for Ring Door Bell/Chimes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ring/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, + CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, + ATTR_ATTRIBUTION) +from homeassistant.helpers.entity import Entity +import homeassistant.loader as loader + +from requests.exceptions import HTTPError, ConnectTimeout + +REQUIREMENTS = ['ring_doorbell==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'ring_notification' +NOTIFICATION_TITLE = 'Ring Sensor Setup' + +DEFAULT_ENTITY_NAMESPACE = 'ring' +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +CONF_ATTRIBUTION = "Data provided by Ring.com" + +# Sensor types: Name, category, units, icon +SENSOR_TYPES = { + 'battery': ['Battery', ['doorbell'], '%', 'battery-50'], + 'last_activity': ['Last Activity', ['doorbell'], None, 'history'], + 'volume': ['Volume', ['chime', 'doorbell'], None, 'bell-ring'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE): + cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a Ring device.""" + from ring_doorbell import Ring + + ring = Ring(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + + persistent_notification = loader.get_component('persistent_notification') + try: + ring.is_connected + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error("Unable to connect to Ring service: %s", str(ex)) + persistent_notification.create( + hass, 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for device in ring.chimes: + if 'chime' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingSensor(hass, + device, + sensor_type)) + + for device in ring.doorbells: + if 'doorbell' in SENSOR_TYPES[sensor_type][1]: + sensors.append(RingSensor(hass, + device, + sensor_type)) + + add_devices(sensors, True) + return True + + +class RingSensor(Entity): + """A sensor implementation for Ring device.""" + + def __init__(self, hass, data, sensor_type): + """Initialize a sensor for Ring device.""" + super(RingSensor, self).__init__() + self._sensor_type = sensor_type + self._data = data + self._extra = None + self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[3]) + self._name = "{0} {1}".format(self._data.name, + SENSOR_TYPES.get(self._sensor_type)[0]) + self._state = STATE_UNKNOWN + self._tz = str(hass.config.time_zone) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attrs['device_id'] = self._data.id + attrs['firmware'] = self._data.firmware + attrs['kind'] = self._data.kind + attrs['timezone'] = self._data.timezone + attrs['type'] = self._data.family + + if self._extra and self._sensor_type == 'last_activity': + attrs['created_at'] = self._extra['created_at'] + attrs['answered'] = self._extra['answered'] + attrs['recording_status'] = self._extra['recording']['status'] + attrs['category'] = self._extra['kind'] + + return attrs + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES.get(self._sensor_type)[2] + + def update(self): + """Get the latest data and updates the state.""" + self._data.update() + + if self._sensor_type == 'volume': + self._state = self._data.volume + + if self._sensor_type == 'battery': + self._state = self._data.battery_life + + if self._sensor_type == 'last_activity': + self._extra = self._data.history(limit=1, timezone=self._tz)[0] + created_at = self._extra['created_at'] + self._state = '{0:0>2}:{1:0>2}'.format(created_at.hour, + created_at.minute) diff --git a/requirements_all.txt b/requirements_all.txt index a18aa2485d5..e73dcba0a71 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -641,6 +641,9 @@ radiotherm==1.2 # homeassistant.components.rflink rflink==0.0.28 +# homeassistant.components.sensor.ring +ring_doorbell==0.1.0 + # homeassistant.components.switch.rpi_rf # rpi-rf==0.9.6 From bdf948d86619185996d0e2ee2e873dffb9dadcf5 Mon Sep 17 00:00:00 2001 From: Thibault Cohen Date: Sun, 5 Mar 2017 03:08:58 -0500 Subject: [PATCH 134/198] Add Mint finance sensor (#6132) * Add Mint finance sensor * Add retry * Fix PR comments * Upgrade mintapi version * Update mint_finance.py * Doc tweak * Update mint_finance.py --- .coveragerc | 1 + .../components/sensor/mint_finance.py | 199 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 203 insertions(+) create mode 100644 homeassistant/components/sensor/mint_finance.py diff --git a/.coveragerc b/.coveragerc index baacdc5ccb0..77398e84b11 100644 --- a/.coveragerc +++ b/.coveragerc @@ -346,6 +346,7 @@ omit = homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/miflora.py + homeassistant/components/sensor/mint_finance.py homeassistant/components/sensor/modem_callerid.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/netdata.py diff --git a/homeassistant/components/sensor/mint_finance.py b/homeassistant/components/sensor/mint_finance.py new file mode 100644 index 00000000000..7a25b3de85f --- /dev/null +++ b/homeassistant/components/sensor/mint_finance.py @@ -0,0 +1,199 @@ +""" +Mint accounts sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mint/ +""" +import logging +import time +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_ID, CONF_NAME, CONF_USERNAME, CONF_PASSWORD) + +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['mintapi==1.23'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ATTRIBUTION = "Intuit Mint" +CONF_ACCOUNTS = 'accounts' +CONF_THX_GUID = 'thx_guid' +CONF_SESSION = 'ius_session' +CONF_CURRENCY = "currency" + +DEFAULT_NAME = 'Mint' + +ICON = 'mdi:square-inc-cash' +INIT_RETRIES = 5 + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_THX_GUID): cv.string, + vol.Required(CONF_SESSION): cv.string, + vol.Required(CONF_ACCOUNTS): + vol.All(cv.ensure_list, [{CONF_ID: int, + CONF_NAME: cv.string, + CONF_CURRENCY: cv.string}]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Mint sensor.""" + from mintapi import Mint + from mintapi.api import MintException + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + ius_session = config.get(CONF_SESSION) + thx_guid = config.get(CONF_THX_GUID) + accounts = config.get(CONF_ACCOUNTS) + + # Make some retries to ensure the startup + retries = 1 + while retries <= INIT_RETRIES: + try: + # Init Mint client + mint_client = Mint(username, password, ius_session, thx_guid) + # Save new update time + mint_client.updated = time.time() + # Update accounts + mint_client.initiate_account_refresh() + break + except MintException as exp: + if retries > INIT_RETRIES: + _LOGGER.error(exp) + return + # retrying + retries += 1 + _LOGGER.info("Mint init failed. " + "Retrying (Try %d/%d)", retries, INIT_RETRIES) + + # List accounts + account_ids = [str(acc['accountId']) for acc in mint_client.get_accounts() + if acc['accountId'] is not None] + _LOGGER.info("Mint account ids: %s", ", ".join(account_ids)) + + # Prepare sensors + dev = [] + for account in accounts: + data = MintData(mint_client, account['id']) + dev.append(MintSensor(data, account['name'], account['currency'])) + + add_devices(dev, True) + + +class MintSensor(Entity): + """Representation of a Mint sensor.""" + + def __init__(self, data, account_name, currency): + """Initialize the sensor.""" + self.data = data + self._name = str(account_name) + self._state = None + self._currency = currency + + @property + def name(self): + """Return the name of the sensor.""" + return "mint_{}".format(self._name) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._currency + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._state is not None: + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + + @property + def type(self): + """Return the account type.""" + return self.data.type_ + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data and updates the states.""" + self.data.update() + self._state = self.data.balance + + +class MintData(object): + """Get data from Intuit Mint.""" + + def __init__(self, mint_client, account_id): + """Initialize the data object.""" + self._client = mint_client + self._account_id = account_id + self.balance = None + self.name = None + self.type_ = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data and updates the states.""" + from mintapi.api import MintException + retries = 1 + while retries <= INIT_RETRIES: + try: + # With store the last update time to share with + # all sensors to avoir multiple update requests + next_update = self._client.updated + 15 * 60 + if time.time() > next_update: + # Save new update time + self._client.updated = time.time() + # Update accounts + self._client.initiate_account_refresh() + # Get accounts + raw_accounts = self._client.get_accounts() + break + except MintException as exp: + _LOGGER.info("Mint get account failed. Retrying " + "(Try %s/%s)", retries, INIT_RETRIES) + if retries >= INIT_RETRIES: + _LOGGER.error(exp) + return + # retrying + retries += 1 + + # Search for account + accounts = dict([(a['accountId'], a) for a in raw_accounts]) + if self._account_id not in accounts: + # Account not found + account_list_msg = ", ".join([str(a) for a in accounts.keys()]) + _LOGGER.exception("Account '%s' not found. Account list: %s", + self._account_id, account_list_msg) + return + # Prepare account name + acc_suff = accounts[self._account_id]['yodleeAccountNumberLast4'][-4:] + self.name = "{}{}".format(self._account_id, acc_suff) + # Get type + self.type_ = accounts[self._account_id]['accountType'] + # Set Balance + self.balance = accounts[self._account_id]['currentBalance'] + # Set negative balance for credit/loan accounts + if self.type_ in ['credit', 'loan']: + self.balance = - self.balance diff --git a/requirements_all.txt b/requirements_all.txt index e73dcba0a71..978950046ed 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -358,6 +358,9 @@ mficlient==0.3.0 # homeassistant.components.sensor.miflora miflora==0.1.16 +# homeassistant.components.sensor.mint_finance +mintapi==1.23 + # homeassistant.components.tts mutagen==1.36.2 From 2650c73a899bf867a23972031de7c4ddea6fb8c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Mar 2017 01:41:54 -0800 Subject: [PATCH 135/198] Split bootstrap into bs + setup (#6416) * Split bootstrap into bs + setup * Lint --- homeassistant/bootstrap.py | 243 +-------- .../components/automation/__init__.py | 2 +- homeassistant/components/config/__init__.py | 2 +- .../components/device_tracker/__init__.py | 2 +- homeassistant/components/google.py | 4 +- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mysensors.py | 2 +- homeassistant/components/notify/__init__.py | 2 +- homeassistant/components/tts/__init__.py | 2 +- homeassistant/helpers/discovery.py | 6 +- homeassistant/helpers/entity_component.py | 2 +- homeassistant/remote.py | 5 +- homeassistant/scripts/check_config.py | 8 +- homeassistant/setup.py | 253 ++++++++++ tests/common.py | 4 +- .../alarm_control_panel/test_manual.py | 2 +- .../alarm_control_panel/test_mqtt.py | 2 +- tests/components/automation/test_event.py | 2 +- tests/components/automation/test_init.py | 2 +- tests/components/automation/test_litejet.py | 6 +- tests/components/automation/test_mqtt.py | 2 +- .../automation/test_numeric_state.py | 2 +- tests/components/automation/test_state.py | 2 +- tests/components/automation/test_sun.py | 2 +- tests/components/automation/test_template.py | 2 +- tests/components/automation/test_time.py | 2 +- tests/components/automation/test_zone.py | 2 +- .../binary_sensor/test_command_line.py | 4 +- tests/components/binary_sensor/test_ffmpeg.py | 2 +- tests/components/binary_sensor/test_mqtt.py | 2 +- tests/components/binary_sensor/test_nx584.py | 2 +- .../components/binary_sensor/test_sleepiq.py | 2 +- tests/components/binary_sensor/test_tcp.py | 2 +- .../components/binary_sensor/test_template.py | 16 +- .../binary_sensor/test_threshold.py | 2 +- tests/components/binary_sensor/test_trend.py | 24 +- tests/components/camera/test_generic.py | 2 +- tests/components/camera/test_init.py | 2 +- tests/components/camera/test_local_file.py | 2 +- tests/components/camera/test_uvc.py | 2 +- tests/components/climate/test_demo.py | 2 +- .../climate/test_generic_thermostat.py | 2 +- tests/components/config/test_init.py | 2 +- tests/components/cover/test_command_line.py | 2 +- tests/components/cover/test_demo.py | 2 +- tests/components/cover/test_mqtt.py | 2 +- tests/components/cover/test_rfxtrx.py | 2 +- .../components/device_tracker/test_asuswrt.py | 2 +- tests/components/device_tracker/test_ddwrt.py | 2 +- tests/components/device_tracker/test_init.py | 2 +- .../device_tracker/test_locative.py | 6 +- tests/components/device_tracker/test_mqtt.py | 2 +- .../device_tracker/test_owntracks.py | 2 +- .../device_tracker/test_upc_connect.py | 2 +- tests/components/emulated_hue/test_hue_api.py | 14 +- tests/components/emulated_hue/test_upnp.py | 10 +- tests/components/fan/test_demo.py | 2 +- tests/components/http/test_auth.py | 6 +- tests/components/http/test_ban.py | 6 +- tests/components/http/test_init.py | 18 +- .../components/image_processing/test_init.py | 2 +- .../test_microsoft_face_detect.py | 2 +- .../test_microsoft_face_identify.py | 2 +- .../image_processing/test_openalpr_cloud.py | 2 +- .../image_processing/test_openalpr_local.py | 2 +- tests/components/light/test_demo.py | 2 +- tests/components/light/test_init.py | 2 +- tests/components/light/test_litejet.py | 4 +- tests/components/light/test_mqtt.py | 2 +- tests/components/light/test_mqtt_json.py | 2 +- tests/components/light/test_mqtt_template.py | 2 +- tests/components/light/test_rfxtrx.py | 2 +- tests/components/lock/test_demo.py | 2 +- tests/components/lock/test_mqtt.py | 2 +- tests/components/media_player/test_demo.py | 2 +- tests/components/media_player/test_sonos.py | 2 +- tests/components/mqtt/test_init.py | 2 +- tests/components/mqtt/test_server.py | 2 +- tests/components/notify/test_apns.py | 2 +- tests/components/notify/test_command_line.py | 2 +- tests/components/notify/test_demo.py | 2 +- tests/components/notify/test_file.py | 2 +- tests/components/notify/test_group.py | 2 +- tests/components/remote/test_demo.py | 2 +- tests/components/remote/test_init.py | 2 +- tests/components/scene/test_init.py | 2 +- tests/components/scene/test_litejet.py | 4 +- tests/components/sensor/test_command_line.py | 4 +- tests/components/sensor/test_darksky.py | 2 +- tests/components/sensor/test_history_stats.py | 2 +- tests/components/sensor/test_mfi.py | 2 +- tests/components/sensor/test_mhz19.py | 2 +- tests/components/sensor/test_min_max.py | 2 +- tests/components/sensor/test_moldindicator.py | 2 +- tests/components/sensor/test_moon.py | 2 +- tests/components/sensor/test_mqtt.py | 2 +- tests/components/sensor/test_mqtt_room.py | 2 +- tests/components/sensor/test_pilight.py | 2 +- tests/components/sensor/test_random.py | 2 +- tests/components/sensor/test_rest.py | 2 +- tests/components/sensor/test_rfxtrx.py | 2 +- tests/components/sensor/test_sleepiq.py | 2 +- tests/components/sensor/test_statistics.py | 2 +- tests/components/sensor/test_tcp.py | 2 +- tests/components/sensor/test_template.py | 2 +- tests/components/sensor/test_worldclock.py | 2 +- tests/components/sensor/test_wsdot.py | 2 +- tests/components/sensor/test_yahoo_finance.py | 2 +- tests/components/switch/test_command_line.py | 2 +- tests/components/switch/test_flux.py | 2 +- tests/components/switch/test_init.py | 2 +- tests/components/switch/test_litejet.py | 4 +- tests/components/switch/test_mfi.py | 2 +- tests/components/switch/test_mochad.py | 2 +- tests/components/switch/test_mqtt.py | 2 +- tests/components/switch/test_rest.py | 2 +- tests/components/switch/test_rfxtrx.py | 2 +- tests/components/switch/test_template.py | 28 +- tests/components/test_alert.py | 2 +- tests/components/test_alexa.py | 6 +- tests/components/test_api.py | 6 +- tests/components/test_apiai.py | 6 +- tests/components/test_conversation.py | 2 +- tests/components/test_demo.py | 2 +- .../test_device_sun_light_trigger.py | 2 +- tests/components/test_ffmpeg.py | 2 +- tests/components/test_frontend.py | 6 +- tests/components/test_google.py | 2 +- tests/components/test_graphite.py | 2 +- tests/components/test_group.py | 2 +- tests/components/test_history.py | 2 +- tests/components/test_influxdb.py | 2 +- tests/components/test_input_boolean.py | 2 +- tests/components/test_input_select.py | 2 +- tests/components/test_input_slider.py | 2 +- tests/components/test_introduction.py | 2 +- tests/components/test_logbook.py | 2 +- tests/components/test_logentries.py | 2 +- tests/components/test_logger.py | 2 +- tests/components/test_microsoft_face.py | 2 +- tests/components/test_mqtt_eventstream.py | 2 +- tests/components/test_panel_custom.py | 14 +- tests/components/test_panel_iframe.py | 6 +- .../test_persistent_notification.py | 2 +- tests/components/test_pilight.py | 2 +- tests/components/test_proximity.py | 2 +- tests/components/test_rest_command.py | 2 +- tests/components/test_rfxtrx.py | 2 +- tests/components/test_script.py | 2 +- tests/components/test_shell_command.py | 2 +- tests/components/test_sleepiq.py | 6 +- tests/components/test_splunk.py | 2 +- tests/components/test_statsd.py | 2 +- tests/components/test_sun.py | 2 +- tests/components/test_updater.py | 2 +- tests/components/test_weblink.py | 5 +- tests/components/test_zone.py | 18 +- tests/components/tts/test_google.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/tts/test_voicerss.py | 2 +- tests/components/tts/test_yandextts.py | 2 +- tests/components/weather/test_weather.py | 2 +- tests/conftest.py | 4 +- tests/helpers/test_aiohttp_client.py | 2 +- tests/helpers/test_discovery.py | 16 +- tests/helpers/test_entity_component.py | 2 +- tests/helpers/test_event.py | 2 +- tests/helpers/test_restore_state.py | 2 +- tests/test_bootstrap.py | 472 ++---------------- tests/test_remote.py | 10 +- tests/test_setup.py | 409 +++++++++++++++ 171 files changed, 972 insertions(+), 959 deletions(-) create mode 100644 homeassistant/setup.py create mode 100644 tests/test_setup.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3b53010e3e3..2cca8e1495b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -7,7 +7,6 @@ import sys from time import time from collections import OrderedDict -from types import ModuleType from typing import Any, Optional, Dict import voluptuous as vol @@ -15,263 +14,23 @@ import voluptuous as vol import homeassistant.components as core_components from homeassistant.components import persistent_notification import homeassistant.config as conf_util -from homeassistant.config import async_notify_setup_error import homeassistant.core as core from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE +from homeassistant.setup import async_setup_component import homeassistant.loader as loader -import homeassistant.util.package as pkg_util -from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.util.logging import AsyncHandler from homeassistant.util.yaml import clear_secret_cache -from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import event_decorators, service from homeassistant.helpers.signal import async_register_signal_handling _LOGGER = logging.getLogger(__name__) -ATTR_COMPONENT = 'component' - -DATA_SETUP = 'setup_tasks' -DATA_PIP_LOCK = 'pip_lock' - ERROR_LOG_FILENAME = 'home-assistant.log' - FIRST_INIT_COMPONENT = set(( 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction')) -def setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict]=None) -> bool: - """Setup a component and all its dependencies.""" - return run_coroutine_threadsafe( - async_setup_component(hass, domain, config), loop=hass.loop).result() - - -@asyncio.coroutine -def async_setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict]=None) -> bool: - """Setup a component and all its dependencies. - - This method is a coroutine. - """ - if domain in hass.config.components: - return True - - setup_tasks = hass.data.get(DATA_SETUP) - - if setup_tasks is not None and domain in setup_tasks: - return (yield from setup_tasks[domain]) - - if config is None: - config = {} - - if setup_tasks is None: - setup_tasks = hass.data[DATA_SETUP] = {} - - task = setup_tasks[domain] = hass.async_add_job( - _async_setup_component(hass, domain, config)) - - return (yield from task) - - -@asyncio.coroutine -def _async_process_requirements(hass: core.HomeAssistant, name: str, - requirements) -> bool: - """Install the requirements for a component. - - This method is a coroutine. - """ - if hass.config.skip_pip: - return True - - pip_lock = hass.data.get(DATA_PIP_LOCK) - if pip_lock is None: - pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock(loop=hass.loop) - - def pip_install(mod): - """Install packages.""" - return pkg_util.install_package(mod, target=hass.config.path('deps')) - - with (yield from pip_lock): - for req in requirements: - ret = yield from hass.loop.run_in_executor(None, pip_install, req) - if not ret: - _LOGGER.error('Not initializing %s because could not install ' - 'dependency %s', name, req) - async_notify_setup_error(hass, name) - return False - - return True - - -@asyncio.coroutine -def _async_process_dependencies(hass, config, name, dependencies): - """Ensure all dependencies are set up.""" - blacklisted = [dep for dep in dependencies - if dep in loader.DEPENDENCY_BLACKLIST] - - if blacklisted: - _LOGGER.error('Unable to setup dependencies of %s: ' - 'found blacklisted dependencies: %s', - name, ', '.join(blacklisted)) - return False - - tasks = [async_setup_component(hass, dep, config) for dep - in dependencies] - - if not tasks: - return True - - results = yield from asyncio.gather(*tasks, loop=hass.loop) - - failed = [dependencies[idx] for idx, res - in enumerate(results) if not res] - - if failed: - _LOGGER.error('Unable to setup dependencies of %s. ' - 'Setup failed for dependencies: %s', - name, ', '.join(failed)) - - return False - return True - - -@asyncio.coroutine -def _async_setup_component(hass: core.HomeAssistant, - domain: str, config) -> bool: - """Setup a component for Home Assistant. - - This method is a coroutine. - - hass: Home Assistant instance. - domain: Domain of component to setup. - config: The Home Assistant configuration. - """ - def log_error(msg, link=True): - """Log helper.""" - _LOGGER.error('Setup failed for %s: %s', domain, msg) - async_notify_setup_error(hass, domain, link) - - component = loader.get_component(domain) - - if not component: - log_error('Component not found.', False) - return False - - # Validate no circular dependencies - components = loader.load_order_component(domain) - - # OrderedSet is empty if component or dependencies could not be resolved - if not components: - log_error('Unable to resolve component or dependencies.') - return False - - processed_config = \ - conf_util.async_process_component_config(hass, config, domain) - - if processed_config is None: - log_error('Invalid config.') - return False - - if not hass.config.skip_pip and hasattr(component, 'REQUIREMENTS'): - req_success = yield from _async_process_requirements( - hass, domain, component.REQUIREMENTS) - if not req_success: - log_error('Could not install all requirements.') - return False - - if hasattr(component, 'DEPENDENCIES'): - dep_success = yield from _async_process_dependencies( - hass, config, domain, component.DEPENDENCIES) - - if not dep_success: - log_error('Could not setup all dependencies.') - return False - - async_comp = hasattr(component, 'async_setup') - - try: - _LOGGER.info("Setting up %s", domain) - if async_comp: - result = yield from component.async_setup(hass, processed_config) - else: - result = yield from hass.loop.run_in_executor( - None, component.setup, hass, processed_config) - except Exception: # pylint: disable=broad-except - _LOGGER.exception('Error during setup of component %s', domain) - async_notify_setup_error(hass, domain, True) - return False - - if result is False: - log_error('Component failed to initialize.') - return False - elif result is not True: - log_error('Component did not return boolean if setup was successful. ' - 'Disabling component.') - loader.set_component(domain, None) - return False - - hass.config.components.add(component.DOMAIN) - - # cleanup - if domain in hass.data[DATA_SETUP]: - hass.data[DATA_SETUP].pop(domain) - - hass.bus.async_fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} - ) - - return True - - -@asyncio.coroutine -def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, - platform_name: str) \ - -> Optional[ModuleType]: - """Load a platform and makes sure dependencies are setup. - - This method is a coroutine. - """ - platform_path = PLATFORM_FORMAT.format(domain, platform_name) - - def log_error(msg): - """Log helper.""" - _LOGGER.error('Unable to prepare setup for platform %s: %s', - platform_path, msg) - async_notify_setup_error(hass, platform_path) - - platform = loader.get_platform(domain, platform_name) - - # Not found - if platform is None: - log_error('Platform not found.') - return None - - # Already loaded - elif platform_path in hass.config.components: - return platform - - # Load dependencies - if hasattr(platform, 'DEPENDENCIES'): - dep_success = yield from _async_process_dependencies( - hass, config, platform_path, platform.DEPENDENCIES) - - if not dep_success: - log_error('Could not setup all dependencies.') - return False - - if not hass.config.skip_pip and hasattr(platform, 'REQUIREMENTS'): - req_success = yield from _async_process_requirements( - hass, platform_path, platform.REQUIREMENTS) - - if not req_success: - log_error('Could not install all requirements.') - return None - - return platform - - def from_config_dict(config: Dict[str, Any], hass: Optional[core.HomeAssistant]=None, config_dir: Optional[str]=None, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index a5fc52c448e..7233ffc5c66 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -11,7 +11,7 @@ import os import voluptuous as vol -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.setup import async_prepare_setup_platform from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 631650077ce..ab175d1d56f 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.bootstrap import ( +from homeassistant.setup import ( async_prepare_setup_platform, ATTR_COMPONENT) from homeassistant.components.frontend import register_built_in_panel from homeassistant.components.http import HomeAssistantView diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 66977222c5d..3e04f46cb50 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -14,7 +14,7 @@ import aiohttp import async_timeout import voluptuous as vol -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import callback from homeassistant.components import group, zone from homeassistant.components.discovery import SERVICE_NETGEAR diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index e72eca9e7fa..0e1caf3e137 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -18,7 +18,7 @@ from voluptuous.error import Error as VoluptuousError import homeassistant.helpers.config_validation as cv import homeassistant.loader as loader -from homeassistant import bootstrap +from homeassistant.setup import setup_component from homeassistant.helpers import discovery from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import track_time_change @@ -223,7 +223,7 @@ def do_setup(hass, config): setup_services(hass, track_new_found_calendars, calendar_service) # Ensure component is loaded - bootstrap.setup_component(hass, 'calendar', config) + setup_component(hass, 'calendar', config) for calendar in hass.data[DATA_INDEX].values(): discovery.load_platform(hass, 'calendar', DOMAIN, calendar) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6bfdde813c1..331d32e83be 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -13,7 +13,7 @@ import time import voluptuous as vol from homeassistant.core import callback -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.setup import async_prepare_setup_platform from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import template, config_validation as cv diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index f9438962274..14ef4f10864 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -12,7 +12,7 @@ import sys import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.mqtt import (valid_publish_topic, valid_subscribe_topic) from homeassistant.const import (ATTR_BATTERY_LEVEL, CONF_NAME, diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index d1d35e07054..35a01e25475 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -11,7 +11,7 @@ from functools import partial import voluptuous as vol -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 5673e61e16b..c175290f451 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -18,7 +18,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import callback from homeassistant.config import load_yaml_config_file from homeassistant.components.http import HomeAssistantView diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 5615f3a3199..67fa71ece29 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -7,7 +7,7 @@ There are two different types of discoveries that can be fired/listened for. """ import asyncio -from homeassistant import bootstrap, core +from homeassistant import setup, core from homeassistant.const import ( ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED) from homeassistant.exceptions import HomeAssistantError @@ -63,7 +63,7 @@ def async_discover(hass, service, discovered=None, component=None, 'Cannot discover the {} component.'.format(component)) if component is not None and component not in hass.config.components: - yield from bootstrap.async_setup_component( + yield from setup.async_setup_component( hass, component, hass_config) data = { @@ -151,7 +151,7 @@ def async_load_platform(hass, component, platform, discovered=None, setup_success = True if component not in hass.config.components: - setup_success = yield from bootstrap.async_setup_component( + setup_success = yield from setup.async_setup_component( hass, component, hass_config) # No need to fire event if we could not setup component diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 1bf09e16247..26c633820cf 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta from homeassistant import config as conf_util -from homeassistant.bootstrap import async_prepare_setup_platform +from homeassistant.setup import async_prepare_setup_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE, DEVICE_DEFAULT_NAME) diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 65ff61888ea..316853bbcb9 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -21,8 +21,7 @@ from typing import Optional import requests -import homeassistant.bootstrap as bootstrap -import homeassistant.core as ha +from homeassistant import setup, core as ha from homeassistant.const import ( HTTP_HEADER_HA_AUTH, SERVER_PORT, URL_API, URL_API_EVENT_FORWARD, URL_API_EVENTS, URL_API_EVENTS_EVENT, URL_API_SERVICES, URL_API_CONFIG, @@ -151,7 +150,7 @@ class HomeAssistant(ha.HomeAssistant): """Start the instance.""" # Ensure a local API exists to connect with remote if 'api' not in self.config.components: - if not bootstrap.setup_component(self, 'api'): + if not setup.setup_component(self, 'api'): raise HomeAssistantError( 'Unable to setup local API to receive events') diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index eac0df8bc90..f8f4a3e9a6d 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -9,9 +9,7 @@ from unittest.mock import patch from typing import Dict, List, Sequence -import homeassistant.bootstrap as bootstrap -import homeassistant.config as config_util -import homeassistant.loader as loader +from homeassistant import bootstrap, loader, setup, config as config_util import homeassistant.util.yaml as yaml from homeassistant.exceptions import HomeAssistantError @@ -30,8 +28,8 @@ MOCKS = { config_util.async_log_exception), 'package_error': ("homeassistant.config._log_pkg_error", config_util._log_pkg_error), - 'logger_exception': ("homeassistant.bootstrap._LOGGER.error", - bootstrap._LOGGER.error), + 'logger_exception': ("homeassistant.setup._LOGGER.error", + setup._LOGGER.error), } SILENCE = ( 'homeassistant.bootstrap.clear_secret_cache', diff --git a/homeassistant/setup.py b/homeassistant/setup.py new file mode 100644 index 00000000000..b9652787eff --- /dev/null +++ b/homeassistant/setup.py @@ -0,0 +1,253 @@ +"""Provides methods to bootstrap a home assistant instance.""" +import asyncio +import logging +import logging.handlers + +from types import ModuleType +from typing import Optional, Dict + +import homeassistant.config as conf_util +from homeassistant.config import async_notify_setup_error +import homeassistant.core as core +import homeassistant.loader as loader +import homeassistant.util.package as pkg_util +from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT + +_LOGGER = logging.getLogger(__name__) + +ATTR_COMPONENT = 'component' + +DATA_SETUP = 'setup_tasks' +DATA_PIP_LOCK = 'pip_lock' + + +def setup_component(hass: core.HomeAssistant, domain: str, + config: Optional[Dict]=None) -> bool: + """Setup a component and all its dependencies.""" + return run_coroutine_threadsafe( + async_setup_component(hass, domain, config), loop=hass.loop).result() + + +@asyncio.coroutine +def async_setup_component(hass: core.HomeAssistant, domain: str, + config: Optional[Dict]=None) -> bool: + """Setup a component and all its dependencies. + + This method is a coroutine. + """ + if domain in hass.config.components: + return True + + setup_tasks = hass.data.get(DATA_SETUP) + + if setup_tasks is not None and domain in setup_tasks: + return (yield from setup_tasks[domain]) + + if config is None: + config = {} + + if setup_tasks is None: + setup_tasks = hass.data[DATA_SETUP] = {} + + task = setup_tasks[domain] = hass.async_add_job( + _async_setup_component(hass, domain, config)) + + return (yield from task) + + +@asyncio.coroutine +def _async_process_requirements(hass: core.HomeAssistant, name: str, + requirements) -> bool: + """Install the requirements for a component. + + This method is a coroutine. + """ + if hass.config.skip_pip: + return True + + pip_lock = hass.data.get(DATA_PIP_LOCK) + if pip_lock is None: + pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock(loop=hass.loop) + + def pip_install(mod): + """Install packages.""" + return pkg_util.install_package(mod, target=hass.config.path('deps')) + + with (yield from pip_lock): + for req in requirements: + ret = yield from hass.loop.run_in_executor(None, pip_install, req) + if not ret: + _LOGGER.error('Not initializing %s because could not install ' + 'dependency %s', name, req) + async_notify_setup_error(hass, name) + return False + + return True + + +@asyncio.coroutine +def _async_process_dependencies(hass, config, name, dependencies): + """Ensure all dependencies are set up.""" + blacklisted = [dep for dep in dependencies + if dep in loader.DEPENDENCY_BLACKLIST] + + if blacklisted: + _LOGGER.error('Unable to setup dependencies of %s: ' + 'found blacklisted dependencies: %s', + name, ', '.join(blacklisted)) + return False + + tasks = [async_setup_component(hass, dep, config) for dep + in dependencies] + + if not tasks: + return True + + results = yield from asyncio.gather(*tasks, loop=hass.loop) + + failed = [dependencies[idx] for idx, res + in enumerate(results) if not res] + + if failed: + _LOGGER.error('Unable to setup dependencies of %s. ' + 'Setup failed for dependencies: %s', + name, ', '.join(failed)) + + return False + return True + + +@asyncio.coroutine +def _async_setup_component(hass: core.HomeAssistant, + domain: str, config) -> bool: + """Setup a component for Home Assistant. + + This method is a coroutine. + + hass: Home Assistant instance. + domain: Domain of component to setup. + config: The Home Assistant configuration. + """ + def log_error(msg, link=True): + """Log helper.""" + _LOGGER.error('Setup failed for %s: %s', domain, msg) + async_notify_setup_error(hass, domain, link) + + component = loader.get_component(domain) + + if not component: + log_error('Component not found.', False) + return False + + # Validate no circular dependencies + components = loader.load_order_component(domain) + + # OrderedSet is empty if component or dependencies could not be resolved + if not components: + log_error('Unable to resolve component or dependencies.') + return False + + processed_config = \ + conf_util.async_process_component_config(hass, config, domain) + + if processed_config is None: + log_error('Invalid config.') + return False + + if not hass.config.skip_pip and hasattr(component, 'REQUIREMENTS'): + req_success = yield from _async_process_requirements( + hass, domain, component.REQUIREMENTS) + if not req_success: + log_error('Could not install all requirements.') + return False + + if hasattr(component, 'DEPENDENCIES'): + dep_success = yield from _async_process_dependencies( + hass, config, domain, component.DEPENDENCIES) + + if not dep_success: + log_error('Could not setup all dependencies.') + return False + + async_comp = hasattr(component, 'async_setup') + + try: + _LOGGER.info("Setting up %s", domain) + if async_comp: + result = yield from component.async_setup(hass, processed_config) + else: + result = yield from hass.loop.run_in_executor( + None, component.setup, hass, processed_config) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error during setup of component %s', domain) + async_notify_setup_error(hass, domain, True) + return False + + if result is False: + log_error('Component failed to initialize.') + return False + elif result is not True: + log_error('Component did not return boolean if setup was successful. ' + 'Disabling component.') + loader.set_component(domain, None) + return False + + hass.config.components.add(component.DOMAIN) + + # cleanup + if domain in hass.data[DATA_SETUP]: + hass.data[DATA_SETUP].pop(domain) + + hass.bus.async_fire( + EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} + ) + + return True + + +@asyncio.coroutine +def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, + platform_name: str) \ + -> Optional[ModuleType]: + """Load a platform and makes sure dependencies are setup. + + This method is a coroutine. + """ + platform_path = PLATFORM_FORMAT.format(domain, platform_name) + + def log_error(msg): + """Log helper.""" + _LOGGER.error('Unable to prepare setup for platform %s: %s', + platform_path, msg) + async_notify_setup_error(hass, platform_path) + + platform = loader.get_platform(domain, platform_name) + + # Not found + if platform is None: + log_error('Platform not found.') + return None + + # Already loaded + elif platform_path in hass.config.components: + return platform + + # Load dependencies + if hasattr(platform, 'DEPENDENCIES'): + dep_success = yield from _async_process_dependencies( + hass, config, platform_path, platform.DEPENDENCIES) + + if not dep_success: + log_error('Could not setup all dependencies.') + return False + + if not hass.config.skip_pip and hasattr(platform, 'REQUIREMENTS'): + req_success = yield from _async_process_requirements( + hass, platform_path, platform.REQUIREMENTS) + + if not req_success: + log_error('Could not install all requirements.') + return None + + return platform diff --git a/tests/common.py b/tests/common.py index 840dfd50caa..509e72fe3a7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -13,7 +13,7 @@ from aiohttp import web from aiohttp.test_utils import unused_port as get_test_instance_port # noqa from homeassistant import core as ha, loader -from homeassistant.bootstrap import setup_component, DATA_SETUP +from homeassistant.setup import setup_component, DATA_SETUP from homeassistant.config import async_process_component_config from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import ToggleEntity @@ -435,7 +435,7 @@ def assert_setup_component(count, domain=None): - domain: The domain to count is optional. It can be automatically determined most of the time - Use as a context manager aroung bootstrap.setup_component + Use as a context manager aroung setup.setup_component with assert_setup_component(0) as result_config: setup_component(hass, domain, start_config) # using result_config is optional diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index f033006c28c..7bd89d12a0a 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -3,7 +3,7 @@ from datetime import timedelta import unittest from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index 2fe9e05d9d5..368a43e6113 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -1,7 +1,7 @@ """The tests the MQTT alarm control panel component.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN) diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index c032c72446a..b4686650057 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -2,7 +2,7 @@ import unittest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.automation as automation from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 9dc08089011..271aa58f7fb 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch from homeassistant.core import State -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component import homeassistant.components.automation as automation from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_OFF from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/automation/test_litejet.py b/tests/components/automation/test_litejet.py index d76a0ee19ac..e6445415490 100644 --- a/tests/components/automation/test_litejet.py +++ b/tests/components/automation/test_litejet.py @@ -4,7 +4,7 @@ import unittest from unittest import mock from datetime import timedelta -from homeassistant import bootstrap +from homeassistant import setup import homeassistant.util.dt as dt_util from homeassistant.components import litejet from tests.common import (fire_time_changed, get_test_home_assistant) @@ -57,7 +57,7 @@ class TestLiteJetTrigger(unittest.TestCase): 'port': '/tmp/this_will_be_mocked' } } - assert bootstrap.setup_component(self.hass, litejet.DOMAIN, config) + assert setup.setup_component(self.hass, litejet.DOMAIN, config) self.hass.services.register('test', 'automation', record_call) @@ -106,7 +106,7 @@ class TestLiteJetTrigger(unittest.TestCase): def setup_automation(self, trigger): """Test setting up the automation.""" - assert bootstrap.setup_component(self.hass, automation.DOMAIN, { + assert setup.setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: [ { 'alias': 'My Test', diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py index df8baced090..d1eb0d63ee8 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -2,7 +2,7 @@ import unittest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.automation as automation from tests.common import ( mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 8862303da5f..0fca1d96a69 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -2,7 +2,7 @@ import unittest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.automation as automation from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index f375aec4666..afddaa85b04 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py index 47bbf6b680c..2341d22d633 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import sun import homeassistant.components.automation as automation import homeassistant.util.dt as dt_util diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index 8bdf9f8f439..cf8b7a59c87 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -2,7 +2,7 @@ import unittest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.automation as automation from tests.common import ( diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 6a76bb887b8..738c2251264 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util import homeassistant.components.automation as automation diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index ea216b12a26..3dc4b75b8ae 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -2,7 +2,7 @@ import unittest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import automation, zone from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py index 80b309c22c3..98d7456ef98 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -3,7 +3,7 @@ import unittest from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.components.binary_sensor import command_line -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.helpers import template from tests.common import get_test_home_assistant @@ -47,7 +47,7 @@ class TestCommandSensorBinarySensor(unittest.TestCase): 'platform': 'not_command_line', } - self.assertFalse(bootstrap.setup_component(self.hass, 'test', { + self.assertFalse(setup.setup_component(self.hass, 'test', { 'command_line': config, })) diff --git a/tests/components/binary_sensor/test_ffmpeg.py b/tests/components/binary_sensor/test_ffmpeg.py index ffeba1870a6..64c540f4398 100644 --- a/tests/components/binary_sensor/test_ffmpeg.py +++ b/tests/components/binary_sensor/test_ffmpeg.py @@ -1,7 +1,7 @@ """The tests for Home Assistant ffmpeg binary sensor.""" from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_coro) diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 1b756f72f61..85e56fb44ea 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT binary sensor platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.binary_sensor as binary_sensor from homeassistant.const import (STATE_OFF, STATE_ON) diff --git a/tests/components/binary_sensor/test_nx584.py b/tests/components/binary_sensor/test_nx584.py index ef8861e12ce..d94d887c641 100644 --- a/tests/components/binary_sensor/test_nx584.py +++ b/tests/components/binary_sensor/test_nx584.py @@ -6,7 +6,7 @@ from unittest import mock from nx584 import client as nx584_client from homeassistant.components.binary_sensor import nx584 -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant diff --git a/tests/components/binary_sensor/test_sleepiq.py b/tests/components/binary_sensor/test_sleepiq.py index fb86e2b3ee5..40e0aa35e03 100644 --- a/tests/components/binary_sensor/test_sleepiq.py +++ b/tests/components/binary_sensor/test_sleepiq.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import requests_mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.binary_sensor import sleepiq from tests.components.test_sleepiq import mock_responses diff --git a/tests/components/binary_sensor/test_tcp.py b/tests/components/binary_sensor/test_tcp.py index 156ebe2c355..8602de84d25 100644 --- a/tests/components/binary_sensor/test_tcp.py +++ b/tests/components/binary_sensor/test_tcp.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import patch, Mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.binary_sensor import tcp as bin_tcp from homeassistant.components.sensor import tcp from tests.common import (get_test_home_assistant, assert_setup_component) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 77818c339e2..4e829b42fe3 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -5,7 +5,7 @@ from unittest import mock from homeassistant.core import CoreState, State from homeassistant.const import MATCH_ALL -import homeassistant.bootstrap as bootstrap +from homeassistant import setup from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr @@ -45,13 +45,13 @@ class TestBinarySensorTemplate(unittest.TestCase): }, } with assert_setup_component(1): - assert bootstrap.setup_component( + assert setup.setup_component( self.hass, 'binary_sensor', config) def test_setup_no_sensors(self): """"Test setup with no sensors.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'template' } @@ -60,7 +60,7 @@ class TestBinarySensorTemplate(unittest.TestCase): def test_setup_invalid_device(self): """"Test the setup with invalid devices.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'template', 'sensors': { @@ -72,7 +72,7 @@ class TestBinarySensorTemplate(unittest.TestCase): def test_setup_invalid_device_class(self): """"Test setup with invalid sensor class.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'template', 'sensors': { @@ -87,7 +87,7 @@ class TestBinarySensorTemplate(unittest.TestCase): def test_setup_invalid_missing_template(self): """"Test setup with invalid and missing template.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'template', 'sensors': { @@ -134,7 +134,7 @@ class TestBinarySensorTemplate(unittest.TestCase): }, } with assert_setup_component(1): - assert bootstrap.setup_component( + assert setup.setup_component( self.hass, 'binary_sensor', config) self.hass.start() @@ -187,7 +187,7 @@ def test_restore_state(hass): }, }, } - yield from bootstrap.async_setup_component(hass, 'binary_sensor', config) + yield from setup.async_setup_component(hass, 'binary_sensor', config) state = hass.states.get('binary_sensor.test') assert state.state == 'on' diff --git a/tests/components/binary_sensor/test_threshold.py b/tests/components/binary_sensor/test_threshold.py index 6af2bbe5b39..5bc62654a1f 100644 --- a/tests/components/binary_sensor/test_threshold.py +++ b/tests/components/binary_sensor/test_threshold.py @@ -1,7 +1,7 @@ """The test for the threshold sensor platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) from tests.common import get_test_home_assistant diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py index 8b522db4a58..dd3c0ba9890 100644 --- a/tests/components/binary_sensor/test_trend.py +++ b/tests/components/binary_sensor/test_trend.py @@ -1,5 +1,5 @@ """The test for the Trend sensor platform.""" -import homeassistant.bootstrap as bootstrap +from homeassistant import setup from tests.common import get_test_home_assistant, assert_setup_component @@ -19,7 +19,7 @@ class TestTrendBinarySensor: def test_up(self): """Test up trend.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -40,7 +40,7 @@ class TestTrendBinarySensor: def test_down(self): """Test down trend.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -61,7 +61,7 @@ class TestTrendBinarySensor: def test__invert_up(self): """Test up trend with custom message.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -83,7 +83,7 @@ class TestTrendBinarySensor: def test_invert_down(self): """Test down trend with custom message.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -105,7 +105,7 @@ class TestTrendBinarySensor: def test_attribute_up(self): """Test attribute up trend.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -126,7 +126,7 @@ class TestTrendBinarySensor: def test_attribute_down(self): """Test attribute down trend.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -149,7 +149,7 @@ class TestTrendBinarySensor: def test_non_numeric(self): """Test up trend.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -170,7 +170,7 @@ class TestTrendBinarySensor: def test_missing_attribute(self): """Test attribute down trend.""" - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend', 'sensors': { @@ -195,7 +195,7 @@ class TestTrendBinarySensor: # pylint: disable=invalid-name """Test invalid name.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'template', 'sensors': { @@ -212,7 +212,7 @@ class TestTrendBinarySensor: # pylint: disable=invalid-name """Test invalid sensor.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'template', 'sensors': { @@ -228,7 +228,7 @@ class TestTrendBinarySensor: def test_no_sensors_does_not_create(self): """Test no sensors.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'binary_sensor', { + assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { 'platform': 'trend' } diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py index ac7b0063158..7b1263dd3da 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -2,7 +2,7 @@ import asyncio from unittest import mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component @asyncio.coroutine diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 13286beae61..4b69116f010 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import ATTR_ENTITY_PICTURE import homeassistant.components.camera as camera import homeassistant.components.http as http diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index 55ddbd10741..ccca77386d8 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -6,7 +6,7 @@ from unittest import mock # https://bugs.python.org/issue23004 from mock_open import MockOpen -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import mock_http_component import logging diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index cd11321baa4..f949d1e728e 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -7,7 +7,7 @@ import requests from uvcclient import camera from uvcclient import nvr -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.camera import uvc from tests.common import get_test_home_assistant, mock_http_component diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 898f6ba2df6..27d79b40aa8 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -4,7 +4,7 @@ import unittest from homeassistant.util.unit_system import ( METRIC_SYSTEM ) -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import climate from tests.common import get_test_home_assistant diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index d4a5b3d21bb..16dbe5ae895 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -7,7 +7,7 @@ from unittest import mock import homeassistant.core as ha from homeassistant.core import callback -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, SERVICE_TURN_OFF, diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 1c37683969b..6f69f886419 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.bootstrap import async_setup_component, ATTR_COMPONENT +from homeassistant.setup import async_setup_component, ATTR_COMPONENT from homeassistant.components import config from tests.common import mock_http_component, mock_coro, mock_component diff --git a/tests/components/cover/test_command_line.py b/tests/components/cover/test_command_line.py index 9d1552b2e73..b7049d35021 100644 --- a/tests/components/cover/test_command_line.py +++ b/tests/components/cover/test_command_line.py @@ -5,7 +5,7 @@ import tempfile import unittest from unittest import mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.cover as cover from homeassistant.components.cover import ( command_line as cmd_rs) diff --git a/tests/components/cover/test_demo.py b/tests/components/cover/test_demo.py index daed13ab691..83907de7708 100644 --- a/tests/components/cover/test_demo.py +++ b/tests/components/cover/test_demo.py @@ -3,7 +3,7 @@ import unittest from datetime import timedelta import homeassistant.util.dt as dt_util -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import cover from tests.common import get_test_home_assistant, fire_time_changed diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 1d670d81b6e..5cd79fdb74c 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT cover platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN import homeassistant.components.cover as cover diff --git a/tests/components/cover/test_rfxtrx.py b/tests/components/cover/test_rfxtrx.py index 2d11e03cb41..be2c456296b 100644 --- a/tests/components/cover/test_rfxtrx.py +++ b/tests/components/cover/test_rfxtrx.py @@ -3,7 +3,7 @@ import unittest import pytest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import rfxtrx as rfxtrx_core from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 406087b7b99..81d3c7a1900 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -6,7 +6,7 @@ from unittest import mock import voluptuous as vol -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_TRACK_NEW) diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py index a0433b04d01..4d4f22f2181 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -8,7 +8,7 @@ import requests import requests_mock from homeassistant import config -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.const import ( CONF_PLATFORM, CONF_HOST, CONF_PASSWORD, CONF_USERNAME) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 3d0a99ec939..d4f301c6fc5 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -10,7 +10,7 @@ import os from homeassistant.components import zone from homeassistant.core import callback, State -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.helpers import discovery from homeassistant.loader import get_component from homeassistant.util.async import run_coroutine_threadsafe diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py index 33f1b078166..5f3f12ba82a 100644 --- a/tests/components/device_tracker/test_locative.py +++ b/tests/components/device_tracker/test_locative.py @@ -4,7 +4,7 @@ from unittest.mock import patch import requests -from homeassistant import bootstrap, const +from homeassistant import setup, const import homeassistant.components.device_tracker as device_tracker import homeassistant.components.http as http from homeassistant.const import CONF_PLATFORM @@ -33,7 +33,7 @@ def setUpModule(): hass = get_test_home_assistant() # http is not platform based, assert_setup_component not applicable - bootstrap.setup_component(hass, http.DOMAIN, { + setup.setup_component(hass, http.DOMAIN, { http.DOMAIN: { http.CONF_SERVER_PORT: SERVER_PORT }, @@ -41,7 +41,7 @@ def setUpModule(): # Set up device tracker with assert_setup_component(1, device_tracker.DOMAIN): - bootstrap.setup_component(hass, device_tracker.DOMAIN, { + setup.setup_component(hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'locative' } diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 08aab93a5a5..eb461062971 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -5,7 +5,7 @@ from unittest.mock import patch import logging import os -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 31f9a6b96a0..434950c175c 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -10,7 +10,7 @@ from tests.common import (assert_setup_component, fire_mqtt_message, get_test_home_assistant, mock_mqtt_component) import homeassistant.components.device_tracker.owntracks as owntracks -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME from homeassistant.util.async import run_coroutine_threadsafe diff --git a/tests/components/device_tracker/test_upc_connect.py b/tests/components/device_tracker/test_upc_connect.py index 7a1e14a7dfc..87e84c000d0 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -4,7 +4,7 @@ import os from unittest.mock import patch import logging -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import device_tracker from homeassistant.const import ( CONF_PLATFORM, CONF_HOST, CONF_PASSWORD) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index fdd9bc90946..a6f1b71ee75 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -5,7 +5,7 @@ import json from unittest.mock import patch import pytest -from homeassistant import bootstrap, const, core +from homeassistant import setup, const, core import homeassistant.components as core_components from homeassistant.components import ( emulated_hue, http, light, script, media_player, fan @@ -33,14 +33,14 @@ def hass_hue(loop, hass): loop.run_until_complete( core_components.async_setup(hass, {core.DOMAIN: {}})) - loop.run_until_complete(bootstrap.async_setup_component( + loop.run_until_complete(setup.async_setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})) with patch('homeassistant.components' '.emulated_hue.UPNPResponderThread'): loop.run_until_complete( - bootstrap.async_setup_component(hass, emulated_hue.DOMAIN, { + setup.async_setup_component(hass, emulated_hue.DOMAIN, { emulated_hue.DOMAIN: { emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, emulated_hue.CONF_EXPOSE_BY_DEFAULT: True @@ -48,7 +48,7 @@ def hass_hue(loop, hass): })) loop.run_until_complete( - bootstrap.async_setup_component(hass, light.DOMAIN, { + setup.async_setup_component(hass, light.DOMAIN, { 'light': [ { 'platform': 'demo', @@ -57,7 +57,7 @@ def hass_hue(loop, hass): })) loop.run_until_complete( - bootstrap.async_setup_component(hass, script.DOMAIN, { + setup.async_setup_component(hass, script.DOMAIN, { 'script': { 'set_kitchen_light': { 'sequence': [ @@ -75,7 +75,7 @@ def hass_hue(loop, hass): })) loop.run_until_complete( - bootstrap.async_setup_component(hass, media_player.DOMAIN, { + setup.async_setup_component(hass, media_player.DOMAIN, { 'media_player': [ { 'platform': 'demo', @@ -84,7 +84,7 @@ def hass_hue(loop, hass): })) loop.run_until_complete( - bootstrap.async_setup_component(hass, fan.DOMAIN, { + setup.async_setup_component(hass, fan.DOMAIN, { 'fan': [ { 'platform': 'demo', diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 03b9e993a9b..3706ce224be 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch import requests -from homeassistant import bootstrap, const, core +from homeassistant import setup, const, core import homeassistant.components as core_components from homeassistant.components import emulated_hue, http from homeassistant.util.async import run_coroutine_threadsafe @@ -28,11 +28,11 @@ def setup_hass_instance(emulated_hue_config): core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop ).result() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) - bootstrap.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config) + setup.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config) return hass @@ -57,13 +57,13 @@ class TestEmulatedHue(unittest.TestCase): core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop ).result() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) with patch('homeassistant.components' '.emulated_hue.UPNPResponderThread'): - bootstrap.setup_component(hass, emulated_hue.DOMAIN, { + setup.setup_component(hass, emulated_hue.DOMAIN, { emulated_hue.DOMAIN: { emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT }}) diff --git a/tests/components/fan/test_demo.py b/tests/components/fan/test_demo.py index 2a0de549b99..078dd56bf1b 100644 --- a/tests/components/fan/test_demo.py +++ b/tests/components/fan/test_demo.py @@ -2,7 +2,7 @@ import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import fan from homeassistant.components.fan.demo import FAN_ENTITY_ID from homeassistant.const import STATE_OFF, STATE_ON diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index d41a1f03d1b..729e6f22be6 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -6,7 +6,7 @@ from unittest.mock import patch import requests -from homeassistant import bootstrap, const +from homeassistant import setup, const import homeassistant.components.http as http from homeassistant.components.http.const import ( KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) @@ -43,7 +43,7 @@ def setUpModule(): hass = get_test_home_assistant() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, { http.DOMAIN: { http.CONF_API_PASSWORD: API_PASSWORD, @@ -52,7 +52,7 @@ def setUpModule(): } ) - bootstrap.setup_component(hass, 'api') + setup.setup_component(hass, 'api') hass.http.app[KEY_TRUSTED_NETWORKS] = [ ip_network(trusted_network) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index b01535206ff..0d8f1a92c7f 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -5,7 +5,7 @@ from unittest.mock import patch, mock_open import requests -from homeassistant import bootstrap, const +from homeassistant import setup, const import homeassistant.components.http as http from homeassistant.components.http.const import ( KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, KEY_BANNED_IPS) @@ -38,7 +38,7 @@ def setUpModule(): hass = get_test_home_assistant() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, { http.DOMAIN: { http.CONF_API_PASSWORD: API_PASSWORD, @@ -47,7 +47,7 @@ def setUpModule(): } ) - bootstrap.setup_component(hass, 'api') + setup.setup_component(hass, 'api') hass.http.app[KEY_BANNED_IPS] = [IpBan(banned_ip) for banned_ip in BANNED_IPS] diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 36f434664d7..4428b5043fd 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -2,7 +2,7 @@ import asyncio import requests -from homeassistant import bootstrap, const +from homeassistant import setup, const import homeassistant.components.http as http from tests.common import get_test_instance_port, get_test_home_assistant @@ -32,7 +32,7 @@ def setUpModule(): hass = get_test_home_assistant() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, { http.DOMAIN: { http.CONF_API_PASSWORD: API_PASSWORD, @@ -42,7 +42,7 @@ def setUpModule(): } ) - bootstrap.setup_component(hass, 'api') + setup.setup_component(hass, 'api') # Registering static path as it caused CORS to blow up hass.http.register_static_path( @@ -131,7 +131,7 @@ class TestView(http.HomeAssistantView): @asyncio.coroutine def test_registering_view_while_running(hass, test_client): """Test that we can register a view while the server is running.""" - yield from bootstrap.async_setup_component( + yield from setup.async_setup_component( hass, http.DOMAIN, { http.DOMAIN: { http.CONF_SERVER_PORT: get_test_instance_port(), @@ -139,7 +139,7 @@ def test_registering_view_while_running(hass, test_client): } ) - yield from bootstrap.async_setup_component(hass, 'api') + yield from setup.async_setup_component(hass, 'api') yield from hass.async_start() @@ -159,7 +159,7 @@ def test_registering_view_while_running(hass, test_client): @asyncio.coroutine def test_api_base_url_with_domain(hass): """Test setting api url.""" - result = yield from bootstrap.async_setup_component(hass, 'http', { + result = yield from setup.async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' } @@ -171,7 +171,7 @@ def test_api_base_url_with_domain(hass): @asyncio.coroutine def test_api_base_url_with_ip(hass): """Test setting api url.""" - result = yield from bootstrap.async_setup_component(hass, 'http', { + result = yield from setup.async_setup_component(hass, 'http', { 'http': { 'server_host': '1.1.1.1' } @@ -183,7 +183,7 @@ def test_api_base_url_with_ip(hass): @asyncio.coroutine def test_api_base_url_with_ip_port(hass): """Test setting api url.""" - result = yield from bootstrap.async_setup_component(hass, 'http', { + result = yield from setup.async_setup_component(hass, 'http', { 'http': { 'base_url': '1.1.1.1:8124' } @@ -195,7 +195,7 @@ def test_api_base_url_with_ip_port(hass): @asyncio.coroutine def test_api_no_base_url(hass): """Test setting api url.""" - result = yield from bootstrap.async_setup_component(hass, 'http', { + result = yield from setup.async_setup_component(hass, 'http', { 'http': { } }) diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 2ac64891e95..816976751a7 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import patch, PropertyMock from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.exceptions import HomeAssistantError import homeassistant.components.http as http import homeassistant.components.image_processing as ip diff --git a/tests/components/image_processing/test_microsoft_face_detect.py b/tests/components/image_processing/test_microsoft_face_detect.py index 801db56ed20..f398db991c2 100644 --- a/tests/components/image_processing/test_microsoft_face_detect.py +++ b/tests/components/image_processing/test_microsoft_face_detect.py @@ -3,7 +3,7 @@ from unittest.mock import patch, PropertyMock from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.image_processing as ip import homeassistant.components.microsoft_face as mf diff --git a/tests/components/image_processing/test_microsoft_face_identify.py b/tests/components/image_processing/test_microsoft_face_identify.py index c6490369859..a7958b68de7 100644 --- a/tests/components/image_processing/test_microsoft_face_identify.py +++ b/tests/components/image_processing/test_microsoft_face_identify.py @@ -3,7 +3,7 @@ from unittest.mock import patch, PropertyMock from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.image_processing as ip import homeassistant.components.microsoft_face as mf diff --git a/tests/components/image_processing/test_openalpr_cloud.py b/tests/components/image_processing/test_openalpr_cloud.py index 8bce672e0d9..e35ac8185d0 100644 --- a/tests/components/image_processing/test_openalpr_cloud.py +++ b/tests/components/image_processing/test_openalpr_cloud.py @@ -4,7 +4,7 @@ from unittest.mock import patch, PropertyMock from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.image_processing as ip from homeassistant.components.image_processing.openalpr_cloud import ( OPENALPR_API_URL) diff --git a/tests/components/image_processing/test_openalpr_local.py b/tests/components/image_processing/test_openalpr_local.py index ffe2eadc8d6..fc40f8e17fb 100644 --- a/tests/components/image_processing/test_openalpr_local.py +++ b/tests/components/image_processing/test_openalpr_local.py @@ -4,7 +4,7 @@ from unittest.mock import patch, PropertyMock, MagicMock from homeassistant.core import callback from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.image_processing as ip from tests.common import ( diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py index de89d434e89..f51b5a45b20 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -4,7 +4,7 @@ import asyncio import unittest from homeassistant.core import State, CoreState -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component import homeassistant.components.light as light from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 757f144ca57..d024df20629 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -3,7 +3,7 @@ import unittest import os -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.loader as loader from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, diff --git a/tests/components/light/test_litejet.py b/tests/components/light/test_litejet.py index 10b205a8c7a..001c419066f 100644 --- a/tests/components/light/test_litejet.py +++ b/tests/components/light/test_litejet.py @@ -3,7 +3,7 @@ import logging import unittest from unittest import mock -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.components import litejet from tests.common import get_test_home_assistant import homeassistant.components.light as light @@ -47,7 +47,7 @@ class TestLiteJetLight(unittest.TestCase): self.mock_lj.on_load_activated.side_effect = on_load_activated self.mock_lj.on_load_deactivated.side_effect = on_load_deactivated - assert bootstrap.setup_component( + assert setup.setup_component( self.hass, litejet.DOMAIN, { diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 410f947178c..89a74805361 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -76,7 +76,7 @@ light: import unittest from unittest import mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE import homeassistant.components.light as light from tests.common import ( diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 55c437cdc79..21c88405a6d 100755 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -30,7 +30,7 @@ light: import json import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE import homeassistant.components.light as light from tests.common import ( diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 020ded1bd80..52847a7be9a 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -22,7 +22,7 @@ If your light doesn't support rgb feature, omit `(red|green|blue)_template`. """ import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE import homeassistant.components.light as light from tests.common import ( diff --git a/tests/components/light/test_rfxtrx.py b/tests/components/light/test_rfxtrx.py index 135e51380cd..eef54a6c258 100644 --- a/tests/components/light/test_rfxtrx.py +++ b/tests/components/light/test_rfxtrx.py @@ -3,7 +3,7 @@ import unittest import pytest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import rfxtrx as rfxtrx_core from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/lock/test_demo.py b/tests/components/lock/test_demo.py index e7a086ad51a..12007d2b8ad 100644 --- a/tests/components/lock/test_demo.py +++ b/tests/components/lock/test_demo.py @@ -1,7 +1,7 @@ """The tests for the Demo lock platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import lock from tests.common import get_test_home_assistant diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index 14714e9a3d1..5815329717c 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT lock platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED, ATTR_ASSUMED_STATE) import homeassistant.components.lock as lock diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index 1e53245d8a5..a798c5f3987 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -3,7 +3,7 @@ import unittest from unittest.mock import patch import asyncio -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import HTTP_HEADER_HA_AUTH import homeassistant.components.media_player as mp import homeassistant.components.http as http diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index 58691d44516..51751618d57 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -5,7 +5,7 @@ import soco.snapshot from unittest import mock import soco -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.media_player import sonos, DOMAIN from homeassistant.components.media_player.sonos import CONF_INTERFACE_ADDR, \ CONF_ADVERTISE_ADDR diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 255d5f6a96c..f476ed4be09 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,7 +8,7 @@ import socket import voluptuous as vol from homeassistant.core import callback -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component import homeassistant.components.mqtt as mqtt from homeassistant.const import ( EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_START, diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index db9e963d84c..7ce9ec00797 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -1,7 +1,7 @@ """The tests for the MQTT component embedded server.""" from unittest.mock import Mock, MagicMock, patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt from tests.common import ( diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 9bacd1391f1..0bd0333a6fb 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -7,7 +7,7 @@ from apns2.errors import Unregistered import yaml import homeassistant.components.notify as notify -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.notify import apns from homeassistant.core import State diff --git a/tests/components/notify/test_command_line.py b/tests/components/notify/test_command_line.py index cebcd2a13fb..e66f2647d4f 100644 --- a/tests/components/notify/test_command_line.py +++ b/tests/components/notify/test_command_line.py @@ -4,7 +4,7 @@ import tempfile import unittest from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.notify as notify from tests.common import assert_setup_component, get_test_home_assistant diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 43c5e78c5da..5bd3270b922 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -3,7 +3,7 @@ import unittest from unittest.mock import patch import homeassistant.components.notify as notify -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.notify import demo from homeassistant.core import callback from homeassistant.helpers import discovery, script diff --git a/tests/components/notify/test_file.py b/tests/components/notify/test_file.py index ea0aaaaa71d..42b9eb9d82d 100644 --- a/tests/components/notify/test_file.py +++ b/tests/components/notify/test_file.py @@ -3,7 +3,7 @@ import os import unittest from unittest.mock import call, mock_open, patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.notify as notify from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT) diff --git a/tests/components/notify/test_group.py b/tests/components/notify/test_group.py index 1aa07fed583..ed988b0f9b5 100644 --- a/tests/components/notify/test_group.py +++ b/tests/components/notify/test_group.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import MagicMock, patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.notify as notify from homeassistant.components.notify import group, demo from homeassistant.util.async import run_coroutine_threadsafe diff --git a/tests/components/remote/test_demo.py b/tests/components/remote/test_demo.py index 8277ef12c8e..0ede5d52a35 100755 --- a/tests/components/remote/test_demo.py +++ b/tests/components/remote/test_demo.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.remote as remote from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 60a049fa291..2cdbf9d9045 100755 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -3,7 +3,7 @@ import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, SERVICE_TURN_ON, SERVICE_TURN_OFF) diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 6e46e55e221..d84d6ad37f4 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -1,7 +1,7 @@ """The tests for the Scene component.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant import loader from homeassistant.components import light, scene diff --git a/tests/components/scene/test_litejet.py b/tests/components/scene/test_litejet.py index 17ba4ce7304..37a9aa5b2b5 100644 --- a/tests/components/scene/test_litejet.py +++ b/tests/components/scene/test_litejet.py @@ -3,7 +3,7 @@ import logging import unittest from unittest import mock -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.components import litejet from tests.common import get_test_home_assistant import homeassistant.components.scene as scene @@ -35,7 +35,7 @@ class TestLiteJetScene(unittest.TestCase): self.mock_lj.scenes.return_value = range(1, 3) self.mock_lj.get_scene_name.side_effect = get_scene_name - assert bootstrap.setup_component( + assert setup.setup_component( self.hass, litejet.DOMAIN, { diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py index fddcf789427..722f5b0fc8f 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -3,7 +3,7 @@ import unittest from homeassistant.helpers.template import Template from homeassistant.components.sensor import command_line -from homeassistant import bootstrap +from homeassistant import setup from tests.common import get_test_home_assistant @@ -45,7 +45,7 @@ class TestCommandSensorSensor(unittest.TestCase): 'platform': 'not_command_line', } - self.assertFalse(bootstrap.setup_component(self.hass, 'test', { + self.assertFalse(setup.setup_component(self.hass, 'test', { 'command_line': config, })) diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py index effa7b3dbd8..54453f42d43 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -9,7 +9,7 @@ import requests_mock from datetime import timedelta from homeassistant.components.sensor import darksky -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import load_fixture, get_test_home_assistant diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index 29d353e09ba..db1ccd95a99 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -4,7 +4,7 @@ from datetime import timedelta import unittest from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.sensor.history_stats import HistoryStatsSensor import homeassistant.core as ha from homeassistant.helpers.template import Template diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py index a55250c8872..8b037209cbc 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -4,7 +4,7 @@ import unittest.mock as mock import requests -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor import homeassistant.components.sensor.mfi as mfi from homeassistant.const import TEMP_CELSIUS diff --git a/tests/components/sensor/test_mhz19.py b/tests/components/sensor/test_mhz19.py index 4311493ac97..6948a952c31 100644 --- a/tests/components/sensor/test_mhz19.py +++ b/tests/components/sensor/test_mhz19.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import patch, DEFAULT, Mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.sensor import DOMAIN import homeassistant.components.sensor.mhz19 as mhz19 from homeassistant.const import TEMP_FAHRENHEIT diff --git a/tests/components/sensor/test_min_max.py b/tests/components/sensor/test_min_max.py index 11b08575f46..b610775b39b 100644 --- a/tests/components/sensor/test_min_max.py +++ b/tests/components/sensor/test_min_max.py @@ -1,7 +1,7 @@ """The test for the min/max sensor platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import ( STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py index 3b2eaabac9c..32cd0206dec 100644 --- a/tests/components/sensor/test_moldindicator.py +++ b/tests/components/sensor/test_moldindicator.py @@ -1,7 +1,7 @@ """The tests for the MoldIndicator sensor.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor from homeassistant.components.sensor.mold_indicator import (ATTR_DEWPOINT, ATTR_CRITICAL_TEMP) diff --git a/tests/components/sensor/test_moon.py b/tests/components/sensor/test_moon.py index 1125dab1201..4de3d241fc7 100644 --- a/tests/components/sensor/test_moon.py +++ b/tests/components/sensor/test_moon.py @@ -4,7 +4,7 @@ from datetime import datetime from unittest.mock import patch import homeassistant.util.dt as dt_util -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 1de9d2f731a..c70fddb67fc 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT sensor platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor from tests.common import mock_mqtt_component, fire_mqtt_message diff --git a/tests/components/sensor/test_mqtt_room.py b/tests/components/sensor/test_mqtt_room.py index e85057d827c..c79017338e1 100644 --- a/tests/components/sensor/test_mqtt_room.py +++ b/tests/components/sensor/test_mqtt_room.py @@ -4,7 +4,7 @@ import datetime import unittest from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS, DEFAULT_QOS) diff --git a/tests/components/sensor/test_pilight.py b/tests/components/sensor/test_pilight.py index 35b6924a35a..b952377118d 100644 --- a/tests/components/sensor/test_pilight.py +++ b/tests/components/sensor/test_pilight.py @@ -1,7 +1,7 @@ """The tests for the Pilight sensor platform.""" import logging -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor from homeassistant.components import pilight diff --git a/tests/components/sensor/test_random.py b/tests/components/sensor/test_random.py index 902edfc3ee4..eeefef74c02 100644 --- a/tests/components/sensor/test_random.py +++ b/tests/components/sensor/test_random.py @@ -1,7 +1,7 @@ """The test for the random number sensor platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index 1c4910927a5..99eec9552f7 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -6,7 +6,7 @@ import requests from requests.exceptions import Timeout, MissingSchema, RequestException import requests_mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.sensor as sensor import homeassistant.components.sensor.rest as rest from homeassistant.const import STATE_UNKNOWN diff --git a/tests/components/sensor/test_rfxtrx.py b/tests/components/sensor/test_rfxtrx.py index 96b5623b7b1..e049eabbe56 100644 --- a/tests/components/sensor/test_rfxtrx.py +++ b/tests/components/sensor/test_rfxtrx.py @@ -3,7 +3,7 @@ import unittest import pytest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import rfxtrx as rfxtrx_core from homeassistant.const import TEMP_CELSIUS diff --git a/tests/components/sensor/test_sleepiq.py b/tests/components/sensor/test_sleepiq.py index 2d754daa6d8..a79db86dc79 100644 --- a/tests/components/sensor/test_sleepiq.py +++ b/tests/components/sensor/test_sleepiq.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import requests_mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.sensor import sleepiq from tests.components.test_sleepiq import mock_responses diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 75649a0c140..d51c88b85d5 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -2,7 +2,7 @@ import unittest import statistics -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) from tests.common import get_test_home_assistant diff --git a/tests/components/sensor/test_tcp.py b/tests/components/sensor/test_tcp.py index fe6fa44b020..4c1e976ea51 100644 --- a/tests/components/sensor/test_tcp.py +++ b/tests/components/sensor/test_tcp.py @@ -6,7 +6,7 @@ from uuid import uuid4 from unittest.mock import patch, Mock from tests.common import (get_test_home_assistant, assert_setup_component) -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.sensor import tcp from homeassistant.helpers.entity import Entity from homeassistant.helpers.template import Template diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index adfdc08d510..62a38abd317 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -2,7 +2,7 @@ import asyncio from homeassistant.core import CoreState, State -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( diff --git a/tests/components/sensor/test_worldclock.py b/tests/components/sensor/test_worldclock.py index 40dd4ee0a5d..9c5392675fb 100644 --- a/tests/components/sensor/test_worldclock.py +++ b/tests/components/sensor/test_worldclock.py @@ -1,7 +1,7 @@ """The test for the World clock sensor platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant import homeassistant.util.dt as dt_util diff --git a/tests/components/sensor/test_wsdot.py b/tests/components/sensor/test_wsdot.py index 4a2dc345f10..ee2cec3bb2a 100644 --- a/tests/components/sensor/test_wsdot.py +++ b/tests/components/sensor/test_wsdot.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor.wsdot import ( WashingtonStateTravelTimeSensor, ATTR_DESCRIPTION, ATTR_TIME_UPDATED, CONF_API_KEY, CONF_NAME, CONF_ID, CONF_TRAVEL_TIMES, SCAN_INTERVAL) -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import load_fixture, get_test_home_assistant diff --git a/tests/components/sensor/test_yahoo_finance.py b/tests/components/sensor/test_yahoo_finance.py index 4823458652b..7b46ad99d41 100644 --- a/tests/components/sensor/test_yahoo_finance.py +++ b/tests/components/sensor/test_yahoo_finance.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch import homeassistant.components.sensor as sensor -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, load_fixture, assert_setup_component) diff --git a/tests/components/switch/test_command_line.py b/tests/components/switch/test_command_line.py index 87eb12d8508..40f999fa43b 100644 --- a/tests/components/switch/test_command_line.py +++ b/tests/components/switch/test_command_line.py @@ -4,7 +4,7 @@ import os import tempfile import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF import homeassistant.components.switch as switch import homeassistant.components.switch.command_line as command_line diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index 1ee865ef3ac..b42177a5f06 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -3,7 +3,7 @@ from datetime import timedelta import unittest from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import switch, light from homeassistant.const import CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON import homeassistant.loader as loader diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 464bc21dd4e..090e3c74bf1 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -2,7 +2,7 @@ # pylint: disable=protected-access import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant import loader from homeassistant.components import switch from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM diff --git a/tests/components/switch/test_litejet.py b/tests/components/switch/test_litejet.py index 3d090fd173d..e0d6e290def 100644 --- a/tests/components/switch/test_litejet.py +++ b/tests/components/switch/test_litejet.py @@ -3,7 +3,7 @@ import logging import unittest from unittest import mock -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.components import litejet from tests.common import get_test_home_assistant import homeassistant.components.switch as switch @@ -56,7 +56,7 @@ class TestLiteJetSwitch(unittest.TestCase): elif method != self.test_include_switches_unspecified: config['litejet']['include_switches'] = True - assert bootstrap.setup_component(self.hass, litejet.DOMAIN, config) + assert setup.setup_component(self.hass, litejet.DOMAIN, config) self.hass.block_till_done() def teardown_method(self, method): diff --git a/tests/components/switch/test_mfi.py b/tests/components/switch/test_mfi.py index a73b35af2f8..a50c5d449f4 100644 --- a/tests/components/switch/test_mfi.py +++ b/tests/components/switch/test_mfi.py @@ -2,7 +2,7 @@ import unittest import unittest.mock as mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.switch as switch import homeassistant.components.switch.mfi as mfi from tests.components.sensor import test_mfi as test_mfi_sensor diff --git a/tests/components/switch/test_mochad.py b/tests/components/switch/test_mochad.py index c6c570449cb..fad5b424399 100644 --- a/tests/components/switch/test_mochad.py +++ b/tests/components/switch/test_mochad.py @@ -2,7 +2,7 @@ import unittest import unittest.mock as mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import switch from homeassistant.components.switch import mochad diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 33de6de52a9..e5e68fe021e 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT switch platform.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE import homeassistant.components.switch as switch from tests.common import ( diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py index 38ddad5e9a2..a2da94b614a 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -4,7 +4,7 @@ import asyncio import aiohttp import homeassistant.components.switch.rest as rest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.util.async import run_coroutine_threadsafe from homeassistant.helpers.template import Template from tests.common import get_test_home_assistant, assert_setup_component diff --git a/tests/components/switch/test_rfxtrx.py b/tests/components/switch/test_rfxtrx.py index b4eb1259515..938093aa95b 100644 --- a/tests/components/switch/test_rfxtrx.py +++ b/tests/components/switch/test_rfxtrx.py @@ -3,7 +3,7 @@ import unittest import pytest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import rfxtrx as rfxtrx_core from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index dabdaa2b4d7..4a03877b2fa 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -2,7 +2,7 @@ import asyncio from homeassistant.core import callback, State, CoreState -import homeassistant.bootstrap as bootstrap +from homeassistant import setup import homeassistant.components as core from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE @@ -37,7 +37,7 @@ class TestTemplateSwitch: def test_template_state_text(self): """"Test the state text of a template.""" with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -75,7 +75,7 @@ class TestTemplateSwitch: def test_template_state_boolean_on(self): """Test the setting of the state with boolean on.""" with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -104,7 +104,7 @@ class TestTemplateSwitch: def test_template_state_boolean_off(self): """Test the setting of the state with off.""" with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -133,7 +133,7 @@ class TestTemplateSwitch: def test_template_syntax_error(self): """Test templating syntax error.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -161,7 +161,7 @@ class TestTemplateSwitch: def test_invalid_name_does_not_create(self): """Test invalid name.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -189,7 +189,7 @@ class TestTemplateSwitch: def test_invalid_switch_does_not_create(self): """Test invalid switch.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -206,7 +206,7 @@ class TestTemplateSwitch: def test_no_switches_does_not_create(self): """Test if there are no switches no creation.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template' } @@ -220,7 +220,7 @@ class TestTemplateSwitch: def test_missing_template_does_not_create(self): """Test missing template.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -248,7 +248,7 @@ class TestTemplateSwitch: def test_missing_on_does_not_create(self): """Test missing on.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -276,7 +276,7 @@ class TestTemplateSwitch: def test_missing_off_does_not_create(self): """Test missing off.""" with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -303,7 +303,7 @@ class TestTemplateSwitch: def test_on_action(self): """Test on action.""" - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -338,7 +338,7 @@ class TestTemplateSwitch: def test_off_action(self): """Test off action.""" - assert bootstrap.setup_component(self.hass, 'switch', { + assert setup.setup_component(self.hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { @@ -384,7 +384,7 @@ def test_restore_state(hass): hass.state = CoreState.starting mock_component(hass, 'recorder') - yield from bootstrap.async_setup_component(hass, 'switch', { + yield from setup.async_setup_component(hass, 'switch', { 'switch': { 'platform': 'template', 'switches': { diff --git a/tests/components/test_alert.py b/tests/components/test_alert.py index 00e4abec25b..7fc25068732 100644 --- a/tests/components/test_alert.py +++ b/tests/components/test_alert.py @@ -3,7 +3,7 @@ from copy import deepcopy import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.core import callback import homeassistant.components.alert as alert import homeassistant.components.notify as notify diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py index fe980bf05b3..f25eb2b0970 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -7,7 +7,7 @@ import unittest import requests from homeassistant.core import callback -from homeassistant import bootstrap, const +from homeassistant import setup, const from homeassistant.components import alexa, http from tests.common import get_test_instance_port, get_test_home_assistant @@ -43,7 +43,7 @@ def setUpModule(): hass = get_test_home_assistant() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: SERVER_PORT}}) @@ -54,7 +54,7 @@ def setUpModule(): hass.services.register("test", "alexa", mock_service) - bootstrap.setup_component(hass, alexa.DOMAIN, { + setup.setup_component(hass, alexa.DOMAIN, { # Key is here to verify we allow other keys in config too "homeassistant": {}, "alexa": { diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 38222ff8f00..2cdc359bfea 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -9,7 +9,7 @@ from unittest.mock import Mock, patch from aiohttp import web import requests -from homeassistant import bootstrap, const +from homeassistant import setup, const import homeassistant.core as ha import homeassistant.components.http as http @@ -41,12 +41,12 @@ def setUpModule(): hass.bus.listen('test_event', lambda _: _) hass.states.set('test.test', 'a_state') - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: SERVER_PORT}}) - bootstrap.setup_component(hass, 'api') + setup.setup_component(hass, 'api') hass.start() diff --git a/tests/components/test_apiai.py b/tests/components/test_apiai.py index 9023ee161c5..f5624f3c209 100644 --- a/tests/components/test_apiai.py +++ b/tests/components/test_apiai.py @@ -6,7 +6,7 @@ import unittest import requests from homeassistant.core import callback -from homeassistant import bootstrap, const +from homeassistant import setup, const from homeassistant.components import apiai, http from tests.common import get_test_instance_port, get_test_home_assistant @@ -45,7 +45,7 @@ def setUpModule(): hass = get_test_home_assistant() - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: SERVER_PORT}}) @@ -57,7 +57,7 @@ def setUpModule(): hass.services.register("test", "apiai", mock_service) - bootstrap.setup_component(hass, apiai.DOMAIN, { + setup.setup_component(hass, apiai.DOMAIN, { # Key is here to verify we allow other keys in config too "homeassistant": {}, "apiai": { diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index abe3a8f36f1..76d582c8856 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components as core_components from homeassistant.components import conversation from homeassistant.const import ATTR_ENTITY_ID diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py index 9691500c451..1bb39b053a5 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -3,7 +3,7 @@ import json import os import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import demo, device_tracker from homeassistant.remote import JSONEncoder diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index c42b50ef390..2b11a420fdc 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -3,7 +3,7 @@ import os import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.loader as loader from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.components import ( diff --git a/tests/components/test_ffmpeg.py b/tests/components/test_ffmpeg.py index 0af90ad7836..9cc706b5690 100644 --- a/tests/components/test_ffmpeg.py +++ b/tests/components/test_ffmpeg.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import patch, MagicMock import homeassistant.components.ffmpeg as ffmpeg -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_coro) diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index a56fac9ed5d..0c42d05f3ae 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -5,7 +5,7 @@ import unittest import requests -import homeassistant.bootstrap as bootstrap +from homeassistant import setup from homeassistant.components import http from homeassistant.const import HTTP_HEADER_HA_AUTH @@ -31,12 +31,12 @@ def setUpModule(): hass = get_test_home_assistant() - assert bootstrap.setup_component( + assert setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: SERVER_PORT}}) - assert bootstrap.setup_component(hass, 'frontend') + assert setup.setup_component(hass, 'frontend') hass.start() diff --git a/tests/components/test_google.py b/tests/components/test_google.py index fbaddb1ed32..004a6e0edaf 100644 --- a/tests/components/test_google.py +++ b/tests/components/test_google.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch import homeassistant.components.google as google -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/test_graphite.py b/tests/components/test_graphite.py index fcbdbd85b19..280704fdc31 100644 --- a/tests/components/test_graphite.py +++ b/tests/components/test_graphite.py @@ -4,7 +4,7 @@ import unittest from unittest import mock from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.core as ha import homeassistant.components.graphite as graphite from homeassistant.const import ( diff --git a/tests/components/test_group.py b/tests/components/test_group.py index 00b75c3a854..af1cadc2466 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -4,7 +4,7 @@ from collections import OrderedDict import unittest from unittest.mock import patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_HOME, STATE_UNKNOWN, ATTR_ICON, ATTR_HIDDEN, ATTR_ASSUMED_STATE, STATE_NOT_HOME, ) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 7324a5e9b32..d2ea03b1873 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -4,7 +4,7 @@ from datetime import timedelta import unittest from unittest.mock import patch, sentinel -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.core as ha import homeassistant.util.dt as dt_util from homeassistant.components import history, recorder diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index 96a6460a2a4..c1ad2672365 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -4,7 +4,7 @@ from unittest import mock import influxdb as influx_client -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.influxdb as influxdb from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py index 62b9f681703..f9042b7de4c 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -7,7 +7,7 @@ import logging from tests.common import get_test_home_assistant, mock_component from homeassistant.core import CoreState, State -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.components.input_boolean import ( DOMAIN, is_on, toggle, turn_off, turn_on) from homeassistant.const import ( diff --git a/tests/components/test_input_select.py b/tests/components/test_input_select.py index 4602b059837..e2549acd35f 100644 --- a/tests/components/test_input_select.py +++ b/tests/components/test_input_select.py @@ -6,7 +6,7 @@ import unittest from tests.common import get_test_home_assistant, mock_restore_cache from homeassistant.core import State -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.components.input_select import ( ATTR_OPTIONS, DOMAIN, SERVICE_SET_OPTIONS, select_option, select_next, select_previous) diff --git a/tests/components/test_input_slider.py b/tests/components/test_input_slider.py index bc8921d000a..f4f9efe687f 100644 --- a/tests/components/test_input_slider.py +++ b/tests/components/test_input_slider.py @@ -6,7 +6,7 @@ import unittest from tests.common import get_test_home_assistant, mock_component from homeassistant.core import CoreState, State -from homeassistant.bootstrap import setup_component, async_setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.components.input_slider import (DOMAIN, select_value) from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE diff --git a/tests/components/test_introduction.py b/tests/components/test_introduction.py index 31201db092e..99b373961cc 100644 --- a/tests/components/test_introduction.py +++ b/tests/components/test_introduction.py @@ -1,7 +1,7 @@ """The tests for the Introduction component.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import introduction from tests.common import get_test_home_assistant diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 13735df0a11..aa4bc9fdf8c 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ATTR_HIDDEN, STATE_NOT_HOME, STATE_ON, STATE_OFF) import homeassistant.util.dt as dt_util from homeassistant.components import logbook -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( mock_http_component, init_recorder_component, get_test_home_assistant) diff --git a/tests/components/test_logentries.py b/tests/components/test_logentries.py index 5d3a9d79f97..bff80c958f3 100644 --- a/tests/components/test_logentries.py +++ b/tests/components/test_logentries.py @@ -3,7 +3,7 @@ import unittest from unittest import mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.logentries as logentries from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED diff --git a/tests/components/test_logger.py b/tests/components/test_logger.py index 099137bdf4b..61cb42e8bb5 100644 --- a/tests/components/test_logger.py +++ b/tests/components/test_logger.py @@ -3,7 +3,7 @@ from collections import namedtuple import logging import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import logger from tests.common import get_test_home_assistant diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py index 859ac9023af..bb95c7e51c1 100644 --- a/tests/components/test_microsoft_face.py +++ b/tests/components/test_microsoft_face.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import patch import homeassistant.components.microsoft_face as mf -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_coro, load_fixture) diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py index dd08904a8e1..91175024ea6 100644 --- a/tests/components/test_mqtt_eventstream.py +++ b/tests/components/test_mqtt_eventstream.py @@ -2,7 +2,7 @@ import json from unittest.mock import ANY, patch -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import State, callback diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py index 46de75bf8bd..073a2fdcce9 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -4,7 +4,7 @@ import shutil import unittest from unittest.mock import Mock, patch -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.components import panel_custom from tests.common import get_test_home_assistant @@ -34,15 +34,15 @@ class TestPanelCustom(unittest.TestCase): } } - assert not bootstrap.setup_component(self.hass, 'panel_custom', config) + assert not setup.setup_component(self.hass, 'panel_custom', config) assert not mock_register.called path = self.hass.config.path(panel_custom.PANEL_DIR) os.mkdir(path) - self.hass.data.pop(bootstrap.DATA_SETUP) + self.hass.data.pop(setup.DATA_SETUP) with open(os.path.join(path, 'todomvc.html'), 'a'): - assert bootstrap.setup_component(self.hass, 'panel_custom', config) + assert setup.setup_component(self.hass, 'panel_custom', config) assert mock_register.called @patch('homeassistant.components.panel_custom.register_panel') @@ -62,16 +62,16 @@ class TestPanelCustom(unittest.TestCase): } with patch('os.path.isfile', Mock(return_value=False)): - assert not bootstrap.setup_component( + assert not setup.setup_component( self.hass, 'panel_custom', config ) assert not mock_register.called - self.hass.data.pop(bootstrap.DATA_SETUP) + self.hass.data.pop(setup.DATA_SETUP) with patch('os.path.isfile', Mock(return_value=True)): with patch('os.access', Mock(return_value=True)): - assert bootstrap.setup_component( + assert setup.setup_component( self.hass, 'panel_custom', config ) diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index cf2fdc23b09..ec1e5bf3650 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import patch -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.components import frontend from tests.common import get_test_home_assistant @@ -28,7 +28,7 @@ class TestPanelIframe(unittest.TestCase): 'url': 'not-a-url'}}] for conf in to_try: - assert not bootstrap.setup_component( + assert not setup.setup_component( self.hass, 'panel_iframe', { 'panel_iframe': conf }) @@ -37,7 +37,7 @@ class TestPanelIframe(unittest.TestCase): 'panels/ha-panel-iframe.html': 'md5md5'}) def test_correct_config(self): """Test correct config.""" - assert bootstrap.setup_component( + assert setup.setup_component( self.hass, 'panel_iframe', { 'panel_iframe': { 'router': { diff --git a/tests/components/test_persistent_notification.py b/tests/components/test_persistent_notification.py index 079fdaf8078..55c78676858 100644 --- a/tests/components/test_persistent_notification.py +++ b/tests/components/test_persistent_notification.py @@ -1,5 +1,5 @@ """The tests for the persistent notification component.""" -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.persistent_notification as pn from tests.common import get_test_home_assistant diff --git a/tests/components/test_pilight.py b/tests/components/test_pilight.py index 036beb0c139..7bdd44136e8 100644 --- a/tests/components/test_pilight.py +++ b/tests/components/test_pilight.py @@ -6,7 +6,7 @@ import socket from datetime import timedelta from homeassistant import core as ha -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import pilight from homeassistant.util import dt as dt_util diff --git a/tests/components/test_proximity.py b/tests/components/test_proximity.py index 532a5e10ab4..42f1cbf4b43 100644 --- a/tests/components/test_proximity.py +++ b/tests/components/test_proximity.py @@ -4,7 +4,7 @@ import unittest from homeassistant.components import proximity from homeassistant.components.proximity import DOMAIN -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant diff --git a/tests/components/test_rest_command.py b/tests/components/test_rest_command.py index 8fe9523801d..a62bddc4a0f 100644 --- a/tests/components/test_rest_command.py +++ b/tests/components/test_rest_command.py @@ -4,7 +4,7 @@ import asyncio import aiohttp import homeassistant.components.rest_command as rc -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component) diff --git a/tests/components/test_rfxtrx.py b/tests/components/test_rfxtrx.py index a1041777ebc..1730d3a5371 100644 --- a/tests/components/test_rfxtrx.py +++ b/tests/components/test_rfxtrx.py @@ -5,7 +5,7 @@ import unittest import pytest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import rfxtrx as rfxtrx from tests.common import get_test_home_assistant diff --git a/tests/components/test_script.py b/tests/components/test_script.py index 14aa75eb963..87391ce5c68 100644 --- a/tests/components/test_script.py +++ b/tests/components/test_script.py @@ -3,7 +3,7 @@ import unittest from homeassistant.core import callback -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import script from tests.common import get_test_home_assistant, mock_component diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py index 16e4296a5b8..b75a95e23cd 100644 --- a/tests/components/test_shell_command.py +++ b/tests/components/test_shell_command.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch from subprocess import SubprocessError -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import shell_command from tests.common import get_test_home_assistant diff --git a/tests/components/test_sleepiq.py b/tests/components/test_sleepiq.py index 965e0b6304a..3c7551d130c 100644 --- a/tests/components/test_sleepiq.py +++ b/tests/components/test_sleepiq.py @@ -2,7 +2,7 @@ import unittest import requests_mock -from homeassistant import bootstrap +from homeassistant import setup import homeassistant.components.sleepiq as sleepiq from tests.common import load_fixture, get_test_home_assistant @@ -66,11 +66,11 @@ class TestSleepIQ(unittest.TestCase): """Test the setup when no login is configured.""" conf = self.config.copy() del conf['sleepiq']['username'] - assert not bootstrap.setup_component(self.hass, sleepiq.DOMAIN, conf) + assert not setup.setup_component(self.hass, sleepiq.DOMAIN, conf) def test_setup_component_no_password(self): """Test the setup when no password is configured.""" conf = self.config.copy() del conf['sleepiq']['password'] - assert not bootstrap.setup_component(self.hass, sleepiq.DOMAIN, conf) + assert not setup.setup_component(self.hass, sleepiq.DOMAIN, conf) diff --git a/tests/components/test_splunk.py b/tests/components/test_splunk.py index 78720850317..661f53b533a 100644 --- a/tests/components/test_splunk.py +++ b/tests/components/test_splunk.py @@ -2,7 +2,7 @@ import unittest from unittest import mock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.components.splunk as splunk from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED diff --git a/tests/components/test_statsd.py b/tests/components/test_statsd.py index b0cba0e41f9..eb8933b77be 100644 --- a/tests/components/test_statsd.py +++ b/tests/components/test_statsd.py @@ -4,7 +4,7 @@ from unittest import mock import voluptuous as vol -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.core as ha import homeassistant.components.statsd as statsd from homeassistant.const import (STATE_ON, STATE_OFF, EVENT_STATE_CHANGED) diff --git a/tests/components/test_sun.py b/tests/components/test_sun.py index 9e5b15e6c2f..3d5a27294a9 100644 --- a/tests/components/test_sun.py +++ b/tests/components/test_sun.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch from datetime import timedelta, datetime -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.core as ha import homeassistant.util.dt as dt_util import homeassistant.components.sun as sun diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index 8ca136bd8d7..da9775e17e6 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -8,7 +8,7 @@ import requests import requests_mock import voluptuous as vol -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import updater from tests.common import ( diff --git a/tests/components/test_weblink.py b/tests/components/test_weblink.py index 78cf6b75db7..e8768342db9 100644 --- a/tests/components/test_weblink.py +++ b/tests/components/test_weblink.py @@ -1,9 +1,8 @@ """The tests for the weblink component.""" import unittest -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components import weblink -from homeassistant import bootstrap from tests.common import get_test_home_assistant @@ -21,7 +20,7 @@ class TestComponentWeblink(unittest.TestCase): def test_bad_config(self): """Test if new entity is created.""" - self.assertFalse(bootstrap.setup_component(self.hass, 'weblink', { + self.assertFalse(setup_component(self.hass, 'weblink', { 'weblink': { 'entities': [{}], } diff --git a/tests/components/test_zone.py b/tests/components/test_zone.py index b0d4f06688d..0ea84324362 100644 --- a/tests/components/test_zone.py +++ b/tests/components/test_zone.py @@ -1,7 +1,7 @@ """Test zone component.""" import unittest -from homeassistant import bootstrap +from homeassistant import setup from homeassistant.components import zone from tests.common import get_test_home_assistant @@ -20,8 +20,8 @@ class TestComponentZone(unittest.TestCase): def test_setup_no_zones_still_adds_home_zone(self): """Test if no config is passed in we still get the home zone.""" - assert bootstrap.setup_component(self.hass, zone.DOMAIN, - {'zone': None}) + assert setup.setup_component(self.hass, zone.DOMAIN, + {'zone': None}) assert len(self.hass.states.entity_ids('zone')) == 1 state = self.hass.states.get('zone.home') @@ -39,7 +39,7 @@ class TestComponentZone(unittest.TestCase): 'radius': 250, 'passive': True } - assert bootstrap.setup_component(self.hass, zone.DOMAIN, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': info }) @@ -52,7 +52,7 @@ class TestComponentZone(unittest.TestCase): def test_active_zone_skips_passive_zones(self): """Test active and passive zones.""" - assert bootstrap.setup_component(self.hass, zone.DOMAIN, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { 'name': 'Passive Zone', @@ -69,7 +69,7 @@ class TestComponentZone(unittest.TestCase): def test_active_zone_skips_passive_zones_2(self): """Test active and passive zones.""" - assert bootstrap.setup_component(self.hass, zone.DOMAIN, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { 'name': 'Active Zone', @@ -87,7 +87,7 @@ class TestComponentZone(unittest.TestCase): """Test zone size preferences.""" latitude = 32.880600 longitude = -117.237561 - assert bootstrap.setup_component(self.hass, zone.DOMAIN, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { 'name': 'Small Zone', @@ -111,7 +111,7 @@ class TestComponentZone(unittest.TestCase): """Test zone size preferences.""" latitude = 32.880600 longitude = -117.237561 - assert bootstrap.setup_component(self.hass, zone.DOMAIN, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { 'name': 'Smallest Zone', @@ -129,7 +129,7 @@ class TestComponentZone(unittest.TestCase): """Test working in passive zones.""" latitude = 32.880600 longitude = -117.237561 - assert bootstrap.setup_component(self.hass, zone.DOMAIN, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { 'name': 'Passive Zone', diff --git a/tests/components/tts/test_google.py b/tests/components/tts/test_google.py index 3483a4830fa..4cbec95dc2b 100644 --- a/tests/components/tts/test_google.py +++ b/tests/components/tts/test_google.py @@ -7,7 +7,7 @@ from unittest.mock import patch import homeassistant.components.tts as tts from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP) -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 0db7c1a5bef..d43dcda8baf 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.tts.demo import DemoProvider from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP) -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component, diff --git a/tests/components/tts/test_voicerss.py b/tests/components/tts/test_voicerss.py index b8f73487831..79629df6d82 100644 --- a/tests/components/tts/test_voicerss.py +++ b/tests/components/tts/test_voicerss.py @@ -6,7 +6,7 @@ import shutil import homeassistant.components.tts as tts from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP) -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import ( get_test_home_assistant, assert_setup_component, mock_service) diff --git a/tests/components/tts/test_yandextts.py b/tests/components/tts/test_yandextts.py index 2baa94ae2b8..b7724d7d913 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -4,7 +4,7 @@ import os import shutil import homeassistant.components.tts as tts -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP) from tests.common import ( diff --git a/tests/components/weather/test_weather.py b/tests/components/weather/test_weather.py index 8ebe4b5355d..1563dd377c4 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/weather/test_weather.py @@ -7,7 +7,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, ATTR_FORECAST, ATTR_FORECAST_TEMP) from homeassistant.util.unit_system import METRIC_SYSTEM -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from tests.common import get_test_home_assistant diff --git a/tests/conftest.py b/tests/conftest.py index 33c5d9f0917..c8afa70173e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest import requests_mock as _requests_mock -from homeassistant import util, bootstrap +from homeassistant import util, setup from homeassistant.util import location from homeassistant.components import mqtt @@ -76,7 +76,7 @@ def mqtt_mock(loop, hass): """Fixture to mock MQTT.""" with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt: mock_mqtt().async_connect.return_value = mock_coro(True) - assert loop.run_until_complete(bootstrap.async_setup_component( + assert loop.run_until_complete(setup.async_setup_component( hass, mqtt.DOMAIN, { mqtt.DOMAIN: { mqtt.CONF_BROKER: 'mock-broker', diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 65fd0386299..42e2697b7a7 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -5,7 +5,7 @@ import unittest import aiohttp from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.helpers.aiohttp_client as client from homeassistant.util.async import run_callback_threadsafe diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index 5e3f9cd8c88..e1f2e114ba1 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant import loader, bootstrap +from homeassistant import loader, setup from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery @@ -24,7 +24,7 @@ class TestHelpersDiscovery: """Stop everything that was started.""" self.hass.stop() - @patch('homeassistant.bootstrap.async_setup_component') + @patch('homeassistant.setup.async_setup_component') def test_listen(self, mock_setup_component): """Test discovery listen/discover combo.""" calls_single = [] @@ -63,7 +63,7 @@ class TestHelpersDiscovery: assert ['test service', 'another service'] == [info[0] for info in calls_multi] - @patch('homeassistant.bootstrap.async_setup_component', + @patch('homeassistant.setup.async_setup_component', return_value=mock_coro(True)) def test_platform(self, mock_setup_component): """Test discover platform method.""" @@ -136,7 +136,7 @@ class TestHelpersDiscovery: MockPlatform(setup_platform, dependencies=['test_component'])) - bootstrap.setup_component(self.hass, 'test_component', { + setup.setup_component(self.hass, 'test_component', { 'test_component': None, 'switch': [{ 'platform': 'test_circular', @@ -184,14 +184,14 @@ class TestHelpersDiscovery: MockModule('test_component2', setup=component2_setup)) @callback - def setup(): + def do_setup(): """Setup 2 components.""" - self.hass.async_add_job(bootstrap.async_setup_component( + self.hass.async_add_job(setup.async_setup_component( self.hass, 'test_component1', {})) - self.hass.async_add_job(bootstrap.async_setup_component( + self.hass.async_add_job(setup.async_setup_component( self.hass, 'test_component2', {})) - self.hass.add_job(setup) + self.hass.add_job(do_setup) self.hass.block_till_done() # test_component will only be setup once diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index d95ec3a87f8..395ef103fd3 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -301,7 +301,7 @@ class TestHelpersEntityComponent(unittest.TestCase): @patch('homeassistant.helpers.entity_component.EntityComponent' '._async_setup_platform', return_value=mock_coro()) - @patch('homeassistant.bootstrap.async_setup_component', + @patch('homeassistant.setup.async_setup_component', return_value=mock_coro(True)) def test_setup_does_discovery(self, mock_setup_component, mock_setup): """Test setup for discovery.""" diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 39691097545..ac60aae3fab 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from astral import Astral -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component import homeassistant.core as ha from homeassistant.const import MATCH_ALL from homeassistant.helpers.event import ( diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index f46f33c333f..826ddc5dd82 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta from unittest.mock import patch, MagicMock -from homeassistant.bootstrap import setup_component +from homeassistant.setup import setup_component from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import CoreState, split_entity_id, State import homeassistant.util.dt as dt_util diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 173cea1957a..9047f26b2d1 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -2,23 +2,14 @@ # pylint: disable=protected-access import asyncio import os -from unittest import mock -import threading +from unittest.mock import Mock, patch import logging -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.config as config_util -from homeassistant import bootstrap, loader +from homeassistant import bootstrap import homeassistant.util.dt as dt_util -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA -from homeassistant.helpers import discovery -from tests.common import \ - get_test_home_assistant, MockModule, MockPlatform, \ - assert_setup_component, patch_yaml_files, get_test_config_dir +from tests.common import patch_yaml_files, get_test_config_dir ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) @@ -26,431 +17,38 @@ VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) _LOGGER = logging.getLogger(__name__) -class TestBootstrap: - """Test the bootstrap utils.""" - - hass = None - backup_cache = None - - # pylint: disable=invalid-name, no-self-use - def setup_method(self, method): - """Setup the test.""" - self.backup_cache = loader._COMPONENT_CACHE - - if method == self.test_from_config_file: - return - - self.hass = get_test_home_assistant() - - def teardown_method(self, method): - """Clean up.""" - if method == self.test_from_config_file: - return - - dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE - self.hass.stop() - loader._COMPONENT_CACHE = self.backup_cache - if os.path.isfile(VERSION_PATH): - os.remove(VERSION_PATH) - - @mock.patch( - # prevent .HA_VERISON file from being written - 'homeassistant.bootstrap.conf_util.process_ha_config_upgrade', - autospec=True) - @mock.patch('homeassistant.util.location.detect_location_info', - autospec=True, return_value=None) - @mock.patch('homeassistant.bootstrap.async_register_signal_handling') - def test_from_config_file(self, mock_upgrade, mock_detect, mock_signal): - """Test with configuration file.""" - components = set(['browser', 'conversation', 'script']) - files = { - 'config.yaml': ''.join( - '{}:\n'.format(comp) - for comp in components - ) - } - - with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \ - mock.patch('os.access', mock.Mock(return_value=True)), \ - mock.patch('homeassistant.bootstrap.async_enable_logging', - mock.Mock(return_value=True)), \ - patch_yaml_files(files, True): - self.hass = bootstrap.from_config_file('config.yaml') - - assert components == self.hass.config.components - - def test_validate_component_config(self): - """Test validating component configuration.""" - config_schema = vol.Schema({ - 'comp_conf': { - 'hello': str - } - }, required=True) - loader.set_component( - 'comp_conf', MockModule('comp_conf', config_schema=config_schema)) - - with assert_setup_component(0): - assert not bootstrap.setup_component(self.hass, 'comp_conf', {}) - - self.hass.data.pop(bootstrap.DATA_SETUP) - - with assert_setup_component(0): - assert not bootstrap.setup_component(self.hass, 'comp_conf', { - 'comp_conf': None - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - - with assert_setup_component(0): - assert not bootstrap.setup_component(self.hass, 'comp_conf', { - 'comp_conf': {} - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - - with assert_setup_component(0): - assert not bootstrap.setup_component(self.hass, 'comp_conf', { - 'comp_conf': { - 'hello': 'world', - 'invalid': 'extra', - } - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - - with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'comp_conf', { - 'comp_conf': { - 'hello': 'world', - } - }) - - def test_validate_platform_config(self): - """Test validating platform configuration.""" - platform_schema = PLATFORM_SCHEMA.extend({ - 'hello': str, - }) - loader.set_component( - 'platform_conf', - MockModule('platform_conf', platform_schema=platform_schema)) - - loader.set_component( - 'platform_conf.whatever', MockPlatform('whatever')) - - with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'hello': 'world', - 'invalid': 'extra', - } - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - }, - 'platform_conf 2': { - 'invalid': True - } - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'not_existing', - 'hello': 'world', - } - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': { - 'platform': 'whatever', - 'hello': 'world', - } - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': [{ - 'platform': 'whatever', - 'hello': 'world', - }] - }) - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('platform_conf') - - # Any falsey platform config will be ignored (None, {}, etc) - with assert_setup_component(0) as config: - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': None - }) - assert 'platform_conf' in self.hass.config.components - assert not config['platform_conf'] # empty - - assert bootstrap.setup_component(self.hass, 'platform_conf', { - 'platform_conf': {} - }) - assert 'platform_conf' in self.hass.config.components - assert not config['platform_conf'] # empty - - def test_component_not_found(self): - """setup_component should not crash if component doesn't exist.""" - assert not bootstrap.setup_component(self.hass, 'non_existing') - - def test_component_not_double_initialized(self): - """Test we do not setup a component twice.""" - mock_setup = mock.MagicMock(return_value=True) - - loader.set_component('comp', MockModule('comp', setup=mock_setup)) - - assert bootstrap.setup_component(self.hass, 'comp') - assert mock_setup.called - - mock_setup.reset_mock() - - assert bootstrap.setup_component(self.hass, 'comp') - assert not mock_setup.called - - @mock.patch('homeassistant.util.package.install_package', - return_value=False) - def test_component_not_installed_if_requirement_fails(self, mock_install): - """Component setup should fail if requirement can't install.""" - self.hass.config.skip_pip = False - loader.set_component( - 'comp', MockModule('comp', requirements=['package==0.0.1'])) - - assert not bootstrap.setup_component(self.hass, 'comp') - assert 'comp' not in self.hass.config.components - - def test_component_not_setup_twice_if_loaded_during_other_setup(self): - """Test component setup while waiting for lock is not setup twice.""" - result = [] - - @asyncio.coroutine - def async_setup(hass, config): - """Tracking Setup.""" - result.append(1) - - loader.set_component( - 'comp', MockModule('comp', async_setup=async_setup)) - - def setup_component(): - """Setup the component.""" - bootstrap.setup_component(self.hass, 'comp') - - thread = threading.Thread(target=setup_component) - thread.start() - bootstrap.setup_component(self.hass, 'comp') - - thread.join() - - assert len(result) == 1 - - def test_component_not_setup_missing_dependencies(self): - """Test we do not setup a component if not all dependencies loaded.""" - deps = ['non_existing'] - loader.set_component('comp', MockModule('comp', dependencies=deps)) - - assert not bootstrap.setup_component(self.hass, 'comp', {}) - assert 'comp' not in self.hass.config.components - - self.hass.data.pop(bootstrap.DATA_SETUP) - - loader.set_component('non_existing', MockModule('non_existing')) - assert bootstrap.setup_component(self.hass, 'comp', {}) - - def test_component_failing_setup(self): - """Test component that fails setup.""" - loader.set_component( - 'comp', MockModule('comp', setup=lambda hass, config: False)) - - assert not bootstrap.setup_component(self.hass, 'comp', {}) - assert 'comp' not in self.hass.config.components - - def test_component_exception_setup(self): - """Test component that raises exception during setup.""" - def exception_setup(hass, config): - """Setup that raises exception.""" - raise Exception('fail!') - - loader.set_component('comp', MockModule('comp', setup=exception_setup)) - - assert not bootstrap.setup_component(self.hass, 'comp', {}) - assert 'comp' not in self.hass.config.components - - @mock.patch('homeassistant.bootstrap.async_enable_logging') - @mock.patch('homeassistant.bootstrap.async_register_signal_handling') - def test_home_assistant_core_config_validation(self, log_mock, sig_mock): - """Test if we pass in wrong information for HA conf.""" - # Extensive HA conf validation testing is done in test_config.py - assert None is bootstrap.from_config_dict({ - 'homeassistant': { - 'latitude': 'some string' - } - }) - - def test_component_setup_with_validation_and_dependency(self): - """Test all config is passed to dependencies.""" - def config_check_setup(hass, config): - """Setup method that tests config is passed in.""" - if config.get('comp_a', {}).get('valid', False): - return True - raise Exception('Config not passed in: {}'.format(config)) - - loader.set_component('comp_a', - MockModule('comp_a', setup=config_check_setup)) - - loader.set_component('switch.platform_a', MockPlatform('comp_b', - ['comp_a'])) - - bootstrap.setup_component(self.hass, 'switch', { - 'comp_a': { - 'valid': True - }, - 'switch': { - 'platform': 'platform_a', - } - }) - assert 'comp_a' in self.hass.config.components - - def test_platform_specific_config_validation(self): - """Test platform that specifies config.""" - platform_schema = PLATFORM_SCHEMA.extend({ - 'valid': True, - }, extra=vol.PREVENT_EXTRA) - - mock_setup = mock.MagicMock(spec_set=True) - - loader.set_component( - 'switch.platform_a', - MockPlatform(platform_schema=platform_schema, - setup_platform=mock_setup)) - - with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'platform_a', - 'invalid': True - } - }) - assert mock_setup.call_count == 0 - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('switch') - - with assert_setup_component(0): - assert bootstrap.setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'platform_a', - 'valid': True, - 'invalid_extra': True, - } - }) - assert mock_setup.call_count == 0 - - self.hass.data.pop(bootstrap.DATA_SETUP) - self.hass.config.components.remove('switch') - - with assert_setup_component(1): - assert bootstrap.setup_component(self.hass, 'switch', { - 'switch': { - 'platform': 'platform_a', - 'valid': True - } - }) - assert mock_setup.call_count == 1 - - def test_disable_component_if_invalid_return(self): - """Test disabling component if invalid return.""" - loader.set_component( - 'disabled_component', - MockModule('disabled_component', setup=lambda hass, config: None)) - - assert not bootstrap.setup_component(self.hass, 'disabled_component') - assert loader.get_component('disabled_component') is None - assert 'disabled_component' not in self.hass.config.components - - self.hass.data.pop(bootstrap.DATA_SETUP) - loader.set_component( - 'disabled_component', - MockModule('disabled_component', setup=lambda hass, config: False)) - - assert not bootstrap.setup_component(self.hass, 'disabled_component') - assert loader.get_component('disabled_component') is not None - assert 'disabled_component' not in self.hass.config.components - - self.hass.data.pop(bootstrap.DATA_SETUP) - loader.set_component( - 'disabled_component', - MockModule('disabled_component', setup=lambda hass, config: True)) - - assert bootstrap.setup_component(self.hass, 'disabled_component') - assert loader.get_component('disabled_component') is not None - assert 'disabled_component' in self.hass.config.components - - @mock.patch('homeassistant.bootstrap.async_register_signal_handling') - def test_all_work_done_before_start(self, signal_mock): - """Test all init work done till start.""" - call_order = [] - - def component1_setup(hass, config): - """Setup mock component.""" - discovery.discover(hass, 'test_component2', - component='test_component2') - discovery.discover(hass, 'test_component3', - component='test_component3') - return True - - def component_track_setup(hass, config): - """Setup mock component.""" - call_order.append(1) - return True - - loader.set_component( - 'test_component1', - MockModule('test_component1', setup=component1_setup)) - - loader.set_component( - 'test_component2', - MockModule('test_component2', setup=component_track_setup)) - - loader.set_component( - 'test_component3', - MockModule('test_component3', setup=component_track_setup)) - - @callback - def track_start(event): - """Track start event.""" - call_order.append(2) - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, track_start) - - self.hass.add_job(bootstrap.async_setup_component( - self.hass, 'test_component1', {})) - self.hass.block_till_done() - self.hass.start() - assert call_order == [1, 1, 2] +# prevent .HA_VERISON file from being written +@patch( + 'homeassistant.bootstrap.conf_util.process_ha_config_upgrade', Mock()) +@patch('homeassistant.util.location.detect_location_info', + Mock(return_value=None)) +@patch('homeassistant.bootstrap.async_register_signal_handling', Mock()) +@patch('os.path.isfile', Mock(return_value=True)) +@patch('os.access', Mock(return_value=True)) +@patch('homeassistant.bootstrap.async_enable_logging', + Mock(return_value=True)) +def test_from_config_file(hass): + """Test with configuration file.""" + components = set(['browser', 'conversation', 'script']) + files = { + 'config.yaml': ''.join('{}:\n'.format(comp) for comp in components) + } + + with patch_yaml_files(files, True): + yield from bootstrap.async_from_config_file('config.yaml') + + assert components == hass.config.components @asyncio.coroutine -def test_component_cannot_depend_config(hass): - """Test config is not allowed to be a dependency.""" - result = yield from bootstrap._async_process_dependencies( - hass, None, 'test', ['config']) - assert not result +@patch('homeassistant.bootstrap.async_enable_logging', Mock()) +@patch('homeassistant.bootstrap.async_register_signal_handling', Mock()) +def test_home_assistant_core_config_validation(hass): + """Test if we pass in wrong information for HA conf.""" + # Extensive HA conf validation testing is done + result = yield from bootstrap.async_from_config_dict({ + 'homeassistant': { + 'latitude': 'some string' + } + }, hass) + assert result is None diff --git a/tests/test_remote.py b/tests/test_remote.py index d20acc88857..eec7b4cf98d 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -5,9 +5,7 @@ import threading import unittest from unittest.mock import patch -import homeassistant.core as ha -import homeassistant.bootstrap as bootstrap -import homeassistant.remote as remote +from homeassistant import remote, setup, core as ha import homeassistant.components.http as http from homeassistant.const import HTTP_HEADER_HA_AUTH, EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util @@ -42,12 +40,12 @@ def setUpModule(): hass.bus.listen('test_event', lambda _: _) hass.states.set('test.test', 'a_state') - bootstrap.setup_component( + setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: MASTER_PORT}}) - bootstrap.setup_component(hass, 'api') + setup.setup_component(hass, 'api') hass.start() @@ -64,7 +62,7 @@ def setUpModule(): slave.async_track_tasks() slave.config.config_dir = get_test_config_dir() slave.config.skip_pip = True - bootstrap.setup_component( + setup.setup_component( slave, http.DOMAIN, {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, http.CONF_SERVER_PORT: SLAVE_PORT}}) diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 00000000000..f14561a0c48 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,409 @@ +"""Test component/platform setup.""" +# pylint: disable=protected-access +import asyncio +import os +from unittest import mock +import threading +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import EVENT_HOMEASSISTANT_START +import homeassistant.config as config_util +from homeassistant import setup, loader +import homeassistant.util.dt as dt_util +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers import discovery + +from tests.common import \ + get_test_home_assistant, MockModule, MockPlatform, \ + assert_setup_component, get_test_config_dir + +ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE +VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) + +_LOGGER = logging.getLogger(__name__) + + +class TestSetup: + """Test the bootstrap utils.""" + + hass = None + backup_cache = None + + # pylint: disable=invalid-name, no-self-use + def setup_method(self, method): + """Setup the test.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Clean up.""" + self.hass.stop() + + # if os.path.isfile(VERSION_PATH): + # os.remove(VERSION_PATH) + + def test_validate_component_config(self): + """Test validating component configuration.""" + config_schema = vol.Schema({ + 'comp_conf': { + 'hello': str + } + }, required=True) + loader.set_component( + 'comp_conf', MockModule('comp_conf', config_schema=config_schema)) + + with assert_setup_component(0): + assert not setup.setup_component(self.hass, 'comp_conf', {}) + + self.hass.data.pop(setup.DATA_SETUP) + + with assert_setup_component(0): + assert not setup.setup_component(self.hass, 'comp_conf', { + 'comp_conf': None + }) + + self.hass.data.pop(setup.DATA_SETUP) + + with assert_setup_component(0): + assert not setup.setup_component(self.hass, 'comp_conf', { + 'comp_conf': {} + }) + + self.hass.data.pop(setup.DATA_SETUP) + + with assert_setup_component(0): + assert not setup.setup_component(self.hass, 'comp_conf', { + 'comp_conf': { + 'hello': 'world', + 'invalid': 'extra', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'comp_conf', { + 'comp_conf': { + 'hello': 'world', + } + }) + + def test_validate_platform_config(self): + """Test validating platform configuration.""" + platform_schema = PLATFORM_SCHEMA.extend({ + 'hello': str, + }) + loader.set_component( + 'platform_conf', + MockModule('platform_conf', platform_schema=platform_schema)) + + loader.set_component( + 'platform_conf.whatever', MockPlatform('whatever')) + + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': { + 'hello': 'world', + 'invalid': 'extra', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + }, + 'platform_conf 2': { + 'invalid': True + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': { + 'platform': 'not_existing', + 'hello': 'world', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + } + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': [{ + 'platform': 'whatever', + 'hello': 'world', + }] + }) + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('platform_conf') + + # Any falsey platform config will be ignored (None, {}, etc) + with assert_setup_component(0) as config: + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': None + }) + assert 'platform_conf' in self.hass.config.components + assert not config['platform_conf'] # empty + + assert setup.setup_component(self.hass, 'platform_conf', { + 'platform_conf': {} + }) + assert 'platform_conf' in self.hass.config.components + assert not config['platform_conf'] # empty + + def test_component_not_found(self): + """setup_component should not crash if component doesn't exist.""" + assert not setup.setup_component(self.hass, 'non_existing') + + def test_component_not_double_initialized(self): + """Test we do not setup a component twice.""" + mock_setup = mock.MagicMock(return_value=True) + + loader.set_component('comp', MockModule('comp', setup=mock_setup)) + + assert setup.setup_component(self.hass, 'comp') + assert mock_setup.called + + mock_setup.reset_mock() + + assert setup.setup_component(self.hass, 'comp') + assert not mock_setup.called + + @mock.patch('homeassistant.util.package.install_package', + return_value=False) + def test_component_not_installed_if_requirement_fails(self, mock_install): + """Component setup should fail if requirement can't install.""" + self.hass.config.skip_pip = False + loader.set_component( + 'comp', MockModule('comp', requirements=['package==0.0.1'])) + + assert not setup.setup_component(self.hass, 'comp') + assert 'comp' not in self.hass.config.components + + def test_component_not_setup_twice_if_loaded_during_other_setup(self): + """Test component setup while waiting for lock is not setup twice.""" + result = [] + + @asyncio.coroutine + def async_setup(hass, config): + """Tracking Setup.""" + result.append(1) + + loader.set_component( + 'comp', MockModule('comp', async_setup=async_setup)) + + def setup_component(): + """Setup the component.""" + setup.setup_component(self.hass, 'comp') + + thread = threading.Thread(target=setup_component) + thread.start() + setup.setup_component(self.hass, 'comp') + + thread.join() + + assert len(result) == 1 + + def test_component_not_setup_missing_dependencies(self): + """Test we do not setup a component if not all dependencies loaded.""" + deps = ['non_existing'] + loader.set_component('comp', MockModule('comp', dependencies=deps)) + + assert not setup.setup_component(self.hass, 'comp', {}) + assert 'comp' not in self.hass.config.components + + self.hass.data.pop(setup.DATA_SETUP) + + loader.set_component('non_existing', MockModule('non_existing')) + assert setup.setup_component(self.hass, 'comp', {}) + + def test_component_failing_setup(self): + """Test component that fails setup.""" + loader.set_component( + 'comp', MockModule('comp', setup=lambda hass, config: False)) + + assert not setup.setup_component(self.hass, 'comp', {}) + assert 'comp' not in self.hass.config.components + + def test_component_exception_setup(self): + """Test component that raises exception during setup.""" + def exception_setup(hass, config): + """Setup that raises exception.""" + raise Exception('fail!') + + loader.set_component('comp', MockModule('comp', setup=exception_setup)) + + assert not setup.setup_component(self.hass, 'comp', {}) + assert 'comp' not in self.hass.config.components + + def test_component_setup_with_validation_and_dependency(self): + """Test all config is passed to dependencies.""" + def config_check_setup(hass, config): + """Setup method that tests config is passed in.""" + if config.get('comp_a', {}).get('valid', False): + return True + raise Exception('Config not passed in: {}'.format(config)) + + loader.set_component('comp_a', + MockModule('comp_a', setup=config_check_setup)) + + loader.set_component('switch.platform_a', MockPlatform('comp_b', + ['comp_a'])) + + setup.setup_component(self.hass, 'switch', { + 'comp_a': { + 'valid': True + }, + 'switch': { + 'platform': 'platform_a', + } + }) + assert 'comp_a' in self.hass.config.components + + def test_platform_specific_config_validation(self): + """Test platform that specifies config.""" + platform_schema = PLATFORM_SCHEMA.extend({ + 'valid': True, + }, extra=vol.PREVENT_EXTRA) + + mock_setup = mock.MagicMock(spec_set=True) + + loader.set_component( + 'switch.platform_a', + MockPlatform(platform_schema=platform_schema, + setup_platform=mock_setup)) + + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'switch', { + 'switch': { + 'platform': 'platform_a', + 'invalid': True + } + }) + assert mock_setup.call_count == 0 + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('switch') + + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'switch', { + 'switch': { + 'platform': 'platform_a', + 'valid': True, + 'invalid_extra': True, + } + }) + assert mock_setup.call_count == 0 + + self.hass.data.pop(setup.DATA_SETUP) + self.hass.config.components.remove('switch') + + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'switch', { + 'switch': { + 'platform': 'platform_a', + 'valid': True + } + }) + assert mock_setup.call_count == 1 + + def test_disable_component_if_invalid_return(self): + """Test disabling component if invalid return.""" + loader.set_component( + 'disabled_component', + MockModule('disabled_component', setup=lambda hass, config: None)) + + assert not setup.setup_component(self.hass, 'disabled_component') + assert loader.get_component('disabled_component') is None + assert 'disabled_component' not in self.hass.config.components + + self.hass.data.pop(setup.DATA_SETUP) + loader.set_component( + 'disabled_component', + MockModule('disabled_component', setup=lambda hass, config: False)) + + assert not setup.setup_component(self.hass, 'disabled_component') + assert loader.get_component('disabled_component') is not None + assert 'disabled_component' not in self.hass.config.components + + self.hass.data.pop(setup.DATA_SETUP) + loader.set_component( + 'disabled_component', + MockModule('disabled_component', setup=lambda hass, config: True)) + + assert setup.setup_component(self.hass, 'disabled_component') + assert loader.get_component('disabled_component') is not None + assert 'disabled_component' in self.hass.config.components + + def test_all_work_done_before_start(self): + """Test all init work done till start.""" + call_order = [] + + def component1_setup(hass, config): + """Setup mock component.""" + discovery.discover(hass, 'test_component2', + component='test_component2') + discovery.discover(hass, 'test_component3', + component='test_component3') + return True + + def component_track_setup(hass, config): + """Setup mock component.""" + call_order.append(1) + return True + + loader.set_component( + 'test_component1', + MockModule('test_component1', setup=component1_setup)) + + loader.set_component( + 'test_component2', + MockModule('test_component2', setup=component_track_setup)) + + loader.set_component( + 'test_component3', + MockModule('test_component3', setup=component_track_setup)) + + @callback + def track_start(event): + """Track start event.""" + call_order.append(2) + + self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, track_start) + + self.hass.add_job(setup.async_setup_component( + self.hass, 'test_component1', {})) + self.hass.block_till_done() + self.hass.start() + assert call_order == [1, 1, 2] + + +@asyncio.coroutine +def test_component_cannot_depend_config(hass): + """Test config is not allowed to be a dependency.""" + result = yield from setup._async_process_dependencies( + hass, None, 'test', ['config']) + assert not result From e8a22cb4a881f743365ef596aef92b913d6036f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Mar 2017 01:52:08 -0800 Subject: [PATCH 136/198] Tweak recorder/restore_state (#6412) * Tweak recorder/restore_state * Lint --- homeassistant/components/recorder/__init__.py | 60 ++++++++++++------- homeassistant/helpers/restore_state.py | 15 ++++- tests/common.py | 5 +- tests/helpers/test_restore_state.py | 2 +- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 907ae8ba51b..dcd4eeb0a0e 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -76,7 +76,7 @@ def wait_connection_ready(hass): Returns a coroutine object. """ - return hass.data[DATA_INSTANCE].async_db_ready.wait() + return hass.data[DATA_INSTANCE].async_db_ready def run_information(hass, point_in_time: Optional[datetime]=None): @@ -113,13 +113,13 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: include = conf.get(CONF_INCLUDE, {}) exclude = conf.get(CONF_EXCLUDE, {}) - hass.data[DATA_INSTANCE] = Recorder( + instance = hass.data[DATA_INSTANCE] = Recorder( hass, purge_days=purge_days, uri=db_url, include=include, exclude=exclude) - hass.data[DATA_INSTANCE].async_initialize() - hass.data[DATA_INSTANCE].start() + instance.async_initialize() + instance.start() - return True + return (yield from instance.async_db_ready) class Recorder(threading.Thread): @@ -135,7 +135,7 @@ class Recorder(threading.Thread): self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri - self.async_db_ready = asyncio.Event(loop=hass.loop) + self.async_db_ready = asyncio.Future(loop=hass.loop) self.engine = None # type: Any self.run_info = None # type: Any @@ -156,22 +156,33 @@ class Recorder(threading.Thread): from .models import States, Events from homeassistant.components import persistent_notification - while True: + tries = 1 + connected = False + + while not connected and tries < 5: try: self._setup_connection() migration.migrate_schema(self) self._setup_run() - self.hass.loop.call_soon_threadsafe(self.async_db_ready.set) - break + connected = True except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error during connection setup: %s (retrying " "in %s seconds)", err, CONNECT_RETRY_WAIT) time.sleep(CONNECT_RETRY_WAIT) - retry = locals().setdefault('retry', 10) - 1 - if retry == 0: - msg = "The recorder could not start, please check the log" - persistent_notification.create(self.hass, msg, 'Recorder') - return + tries += 1 + + if not connected: + @callback + def connection_failed(): + """Connection failed tasks.""" + self.async_db_ready.set_result(False) + persistent_notification.async_create( + self.hass, + "The recorder could not start, please check the log", + "Recorder") + + self.hass.add_job(connection_failed) + return purge_task = object() shutdown_task = object() @@ -180,6 +191,8 @@ class Recorder(threading.Thread): @callback def register(): """Post connection initialize.""" + self.async_db_ready.set_result(True) + def shutdown(event): """Shut down the Recorder.""" if not hass_started.done(): @@ -279,19 +292,20 @@ class Recorder(threading.Thread): from sqlalchemy.orm import sessionmaker from . import models + kwargs = {} + if self.db_url == 'sqlite://' or ':memory:' in self.db_url: from sqlalchemy.pool import StaticPool - self.engine = create_engine( - 'sqlite://', - connect_args={'check_same_thread': False}, - poolclass=StaticPool, - pool_reset_on_return=None) - else: - self.engine = create_engine(self.db_url, echo=False) + kwargs['connect_args'] = {'check_same_thread': False} + kwargs['poolclass'] = StaticPool + kwargs['pool_reset_on_return'] = None + else: + kwargs['echo'] = False + + self.engine = create_engine(self.db_url, **kwargs) models.Base.metadata.create_all(self.engine) - session_factory = sessionmaker(bind=self.engine) - self.get_session = scoped_session(session_factory) + self.get_session = scoped_session(sessionmaker(bind=self.engine)) def _close_connection(self): """Close the connection.""" diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 4ac1e442546..5b567841111 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -3,6 +3,8 @@ import asyncio import logging from datetime import timedelta +import async_timeout + from homeassistant.core import HomeAssistant, CoreState, callback from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.components.history import get_states, last_recorder_run @@ -10,10 +12,10 @@ from homeassistant.components.recorder import ( wait_connection_ready, DOMAIN as _RECORDER) import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) - +RECORDER_TIMEOUT = 10 DATA_RESTORE_CACHE = 'restore_state_cache' _LOCK = 'restore_lock' +_LOGGER = logging.getLogger(__name__) def _load_restore_cache(hass: HomeAssistant): @@ -58,7 +60,14 @@ def async_get_last_state(hass, entity_id: str): hass.state) return None - yield from wait_connection_ready(hass) + try: + with async_timeout.timeout(RECORDER_TIMEOUT, loop=hass.loop): + connected = yield from wait_connection_ready(hass) + except asyncio.TimeoutError: + return None + + if not connected: + return None if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) diff --git a/tests/common.py b/tests/common.py index 509e72fe3a7..88d5e146dab 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,8 +29,7 @@ from homeassistant.components import sun, mqtt, recorder from homeassistant.components.http.auth import auth_middleware from homeassistant.components.http.const import ( KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS) -from homeassistant.util.async import ( - run_callback_threadsafe, run_coroutine_threadsafe) +from homeassistant.util.async import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) INST_COUNT = 0 @@ -477,8 +476,6 @@ def init_recorder_component(hass, add_config=None): assert setup_component(hass, recorder.DOMAIN, {recorder.DOMAIN: config}) assert recorder.DOMAIN in hass.config.components - run_coroutine_threadsafe( - recorder.wait_connection_ready(hass), hass.loop).result() _LOGGER.info("In-memory recorder successfully started") diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 826ddc5dd82..5027e36a7f2 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -34,7 +34,7 @@ def test_caching_data(hass): patch('homeassistant.helpers.restore_state.get_states', return_value=states), \ patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro()): + return_value=mock_coro(True)): state = yield from async_get_last_state(hass, 'input_boolean.b1') assert DATA_RESTORE_CACHE in hass.data From 10bf6597734eb9510d8c3d2cf96433099efc5d16 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Mar 2017 01:53:21 -0800 Subject: [PATCH 137/198] Fix unnecessary warning for ip bans.yaml (#6417) --- homeassistant/components/http/ban.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 96a32d1ae6e..8ae18ef6e80 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -4,6 +4,7 @@ from collections import defaultdict from datetime import datetime from ipaddress import ip_address import logging +import os from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol @@ -115,13 +116,14 @@ def load_ip_bans_config(path: str): """Loading list of banned IPs from config file.""" ip_list = [] + if not os.path.isfile(path): + return ip_list + try: list_ = load_yaml_config_file(path) - except FileNotFoundError: - return [] except HomeAssistantError as err: _LOGGER.error('Unable to load %s: %s', path, str(err)) - return [] + return ip_list for ip_ban, ip_info in list_.items(): try: From 7655b6271df5039138316f54e4433edfab67ab1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 5 Mar 2017 01:54:49 -0800 Subject: [PATCH 138/198] Better restore_state warnings (#6418) --- homeassistant/helpers/restore_state.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 5b567841111..c022d5ae8f3 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -54,10 +54,12 @@ def async_get_last_state(hass, entity_id: str): if DATA_RESTORE_CACHE in hass.data: return hass.data[DATA_RESTORE_CACHE].get(entity_id) - if (_RECORDER not in hass.config.components or - hass.state not in (CoreState.starting, CoreState.not_running)): - _LOGGER.error("Cache can only be loaded during startup, not %s", - hass.state) + if _RECORDER not in hass.config.components: + return None + + if hass.state not in (CoreState.starting, CoreState.not_running): + _LOGGER.debug("Cache for %s can only be loaded during startup, not %s", + entity_id, hass.state) return None try: @@ -83,9 +85,9 @@ def async_get_last_state(hass, entity_id: str): @asyncio.coroutine def async_restore_state(entity, extract_info): """Helper to call entity.async_restore_state with cached info.""" - if entity.hass.state != CoreState.starting: - _LOGGER.debug("Not restoring state: State is not starting: %s", - entity.hass.state) + if entity.hass.state not in (CoreState.starting, CoreState.not_running): + _LOGGER.debug("Not restoring state for %s: Hass is not starting: %s", + entity.entity_id, entity.hass.state) return state = yield from async_get_last_state(entity.hass, entity.entity_id) From 7774f0ae53970b32602853aaed548a88b5694393 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 5 Mar 2017 11:11:33 +0100 Subject: [PATCH 139/198] Set new color before turning LIFX bulbs on (#6402) A LIFX bulb maintains its previous color even when the light is off. For example, if the previous color is blue and the bulb is turned on and then set to a red color, it will transition through purple colors. After this commit, the target color is set while the bulb is still turned off. This overrides the previous color and brightness that the bulb remembered. The light is then turned on with the requested transition duration. For the example, this gives the expected result of only going through red colors. --- homeassistant/components/light/lifx.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 039e22e73df..6b0c8a63f99 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -230,10 +230,12 @@ class LIFXLight(Light): hue, saturation, brightness, kelvin, fade) if self._power == 0: + self._liffylights.set_color(self._ip, hue, saturation, + brightness, kelvin, 0) self._liffylights.set_power(self._ip, 65535, fade) - - self._liffylights.set_color(self._ip, hue, saturation, - brightness, kelvin, fade) + else: + self._liffylights.set_color(self._ip, hue, saturation, + brightness, kelvin, fade) def turn_off(self, **kwargs): """Turn the device off.""" From de038bae6510294fb0a02f7cbb1324019b1ea88b Mon Sep 17 00:00:00 2001 From: Igor Shults Date: Sun, 5 Mar 2017 10:07:09 -0600 Subject: [PATCH 140/198] Don't log username and password in camera url (#6390) * Don't log username and password in camera url * Attempt fix of tox issues * Attempt to fix indentation issue --- homeassistant/components/camera/foscam.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index e84794356b2..a374d19f4d1 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -47,15 +47,21 @@ class FoscamCamera(Camera): port = device_info.get(CONF_PORT) self._base_url = 'http://{}:{}/'.format(ip_address, port) + + uri_template = self._base_url \ + + 'cgi-bin/CGIProxy.fcgi?' \ + + 'cmd=snapPicture2&usr={}&pwd={}' + self._username = device_info.get(CONF_USERNAME) self._password = device_info.get(CONF_PASSWORD) - self._snap_picture_url = self._base_url \ - + 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture2&usr=' \ - + self._username + '&pwd=' + self._password + self._snap_picture_url = uri_template.format( + self._username, + self._password + ) self._name = device_info.get(CONF_NAME) _LOGGER.info('Using the following URL for %s: %s', - self._name, self._snap_picture_url) + self._name, uri_template.format('***', '***')) def camera_image(self): """Return a still image reponse from the camera.""" From 660e777f01e709f286889190a9713c0d970738eb Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 5 Mar 2017 17:15:25 +0100 Subject: [PATCH 141/198] Ignore deleted mails in IMAP unread count (#6394) (#6395) Message deletion in IMAP is a two step process: first delete, then expunge. Deleting a message just sets a flag that usually makes the mail client hide the message. It is the expunge that actually removes the message. Thus, exclude the deleted messages so that the unread count matches up with that of most mail clients. --- homeassistant/components/sensor/imap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index 69fc2eb88a7..4d7f34ef682 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -85,7 +85,7 @@ class ImapSensor(Entity): try: self.connection.select() self._unread_count = len(self.connection.search( - None, 'UnSeen')[1][0].split()) + None, 'UnSeen UnDeleted')[1][0].split()) except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) From 46ec6d6dcea1a2be97ff4a88b5d75ae44c2d1818 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 5 Mar 2017 19:29:59 +0200 Subject: [PATCH 142/198] Delay zwave updates for 100ms to group them. (#6420) * Add Zwave refresh services * services file * Use dispatcher * Add zwave prefix to signal * Delay zwave updates for 100ms to group them. * Fixes * lint * Access _scheduled_update from loop thread only. * More async * Some optimizations * Fix --- homeassistant/components/zwave/__init__.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 5e651f69213..3dbb2e4b224 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -12,6 +12,7 @@ from pprint import pprint import voluptuous as vol +from homeassistant.core import callback from homeassistant.loader import get_platform from homeassistant.helpers import discovery from homeassistant.const import ( @@ -769,6 +770,7 @@ class ZWaveDeviceEntity(Entity): self._wakeup_value_id = None self._battery_value_id = None self._power_value_id = None + self._scheduled_update = False self._update_attributes() dispatcher.connect( @@ -793,8 +795,8 @@ class ZWaveDeviceEntity(Entity): self.update_properties() # If value changed after device was created but before setup_platform # was called - skip updating state. - if self.hass: - self.schedule_update_ha_state() + if self.hass and not self._scheduled_update: + self.hass.add_job(self._schedule_update) def _update_ids(self): """Update value_ids from which to pull attributes.""" @@ -916,3 +918,18 @@ class ZWaveDeviceEntity(Entity): return for value_id in dependent_ids + [self._value.value_id]: self._value.node.refresh_value(value_id) + + @callback + def _schedule_update(self): + """Schedule delayed update.""" + if self._scheduled_update: + return + + @callback + def do_update(): + """Really update.""" + self.hass.async_add_job(self.async_update_ha_state) + self._scheduled_update = False + + self._scheduled_update = True + self.hass.loop.call_later(0.1, do_update) From d5435cf066768fdff6b1c1d38777382ac723fc36 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 5 Mar 2017 20:53:47 +0200 Subject: [PATCH 143/198] Rename _scheduled_update to _update_scheduled (#6434) --- homeassistant/components/zwave/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 3dbb2e4b224..2d249146ea4 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -770,7 +770,7 @@ class ZWaveDeviceEntity(Entity): self._wakeup_value_id = None self._battery_value_id = None self._power_value_id = None - self._scheduled_update = False + self._update_scheduled = False self._update_attributes() dispatcher.connect( @@ -795,7 +795,7 @@ class ZWaveDeviceEntity(Entity): self.update_properties() # If value changed after device was created but before setup_platform # was called - skip updating state. - if self.hass and not self._scheduled_update: + if self.hass and not self._update_scheduled: self.hass.add_job(self._schedule_update) def _update_ids(self): @@ -922,14 +922,14 @@ class ZWaveDeviceEntity(Entity): @callback def _schedule_update(self): """Schedule delayed update.""" - if self._scheduled_update: + if self._update_scheduled: return @callback def do_update(): """Really update.""" self.hass.async_add_job(self.async_update_ha_state) - self._scheduled_update = False + self._update_scheduled = False - self._scheduled_update = True + self._update_scheduled = True self.hass.loop.call_later(0.1, do_update) From 1a139234af0adcfe0d3d488859f645bb861c5a01 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sun, 5 Mar 2017 15:14:21 -0500 Subject: [PATCH 144/198] Revert "Use dynamic port allocation for tests" (#6436) --- tests/common.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/common.py b/tests/common.py index 88d5e146dab..31741ecda67 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,6 @@ import threading from contextlib import contextmanager from aiohttp import web -from aiohttp.test_utils import unused_port as get_test_instance_port # noqa from homeassistant import core as ha, loader from homeassistant.setup import setup_component, DATA_SETUP @@ -24,13 +23,14 @@ import homeassistant.util.yaml as yaml from homeassistant.const import ( STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, - ATTR_DISCOVERED, EVENT_HOMEASSISTANT_STOP) + ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.components import sun, mqtt, recorder from homeassistant.components.http.auth import auth_middleware from homeassistant.components.http.const import ( KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS) from homeassistant.util.async import run_callback_threadsafe +_TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) INST_COUNT = 0 @@ -139,6 +139,18 @@ def async_test_home_assistant(loop): return hass +def get_test_instance_port(): + """Return unused port for running test instance. + + The socket that holds the default port does not get released when we stop + HA in a different test case. Until I have figured out what is going on, + let's run each test on a different port. + """ + global _TEST_INSTANCE_PORT + _TEST_INSTANCE_PORT += 1 + return _TEST_INSTANCE_PORT + + def mock_service(hass, domain, service): """Setup a fake service & return a list that logs calls to this service.""" calls = [] From bc9f2d21c4b621471f4012847ec5b94c8f53fb02 Mon Sep 17 00:00:00 2001 From: Job Vermeulen Date: Sun, 5 Mar 2017 21:38:14 +0100 Subject: [PATCH 145/198] Tado device_tracker exception when mobile device has geofencing enabled but location is currently unknown. (#6401) --- homeassistant/components/device_tracker/tado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py index ca6e5d5ef7c..ca0bec29706 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -142,7 +142,7 @@ class TadoDeviceScanner(DeviceScanner): # Find devices that have geofencing enabled, and are currently at home. for mobile_device in tado_json: - if 'location' in mobile_device: + if mobile_device.get('location'): if mobile_device['location']['atHome']: device_id = mobile_device['id'] device_name = mobile_device['name'] From eaaa0442e28469fab9b28ed5fdbe5295f99f9933 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 5 Mar 2017 22:55:52 +0200 Subject: [PATCH 146/198] Add a Z-wave workaround to do full refresh on update (#6403) * Add Zwave refresh services * services file * Use dispatcher * Add zwave prefix to signal * Add a Z-wave workaround to do full refresh on update --- homeassistant/components/switch/zwave.py | 12 ++++++++++-- homeassistant/components/zwave/workaround.py | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index a9166c8352f..bbae1c1c68c 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -5,11 +5,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.zwave/ """ import logging +import time # Because we do not compile openzwave on CI # pylint: disable=import-error from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import workaround, async_setup_platform # noqa # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -25,11 +26,18 @@ class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice): def __init__(self, value): """Initialize the Z-Wave switch device.""" zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN) - self.update_properties() + self.refresh_on_update = (workaround.get_device_mapping(value) == + workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) + self.last_update = time.perf_counter() + self._state = self._value.data def update_properties(self): """Callback on data changes for node values.""" self._state = self._value.data + if self.refresh_on_update and \ + time.perf_counter() - self.last_update > 30: + self.last_update = time.perf_counter() + self._value.node.request_state() @property def is_on(self): diff --git a/homeassistant/components/zwave/workaround.py b/homeassistant/components/zwave/workaround.py index 9522917ed09..17dbf1437f3 100644 --- a/homeassistant/components/zwave/workaround.py +++ b/homeassistant/components/zwave/workaround.py @@ -10,10 +10,12 @@ SOMFY = 0x47 # Product IDs PHILIO_SLIM_SENSOR = 0x0002 PHILIO_3_IN_1_SENSOR_GEN_4 = 0x000d +PHILIO_PAN07 = 0x0005 # Product Types FGFS101_FLOOD_SENSOR_TYPE = 0x0b00 FGRM222_SHUTTER2 = 0x0301 +PHILIO_SWITCH = 0x0001 PHILIO_SENSOR = 0x0002 SOMFY_ZRTSI = 0x5a52 @@ -21,6 +23,7 @@ SOMFY_ZRTSI = 0x5a52 PHILIO_SLIM_SENSOR_MOTION_MTII = (PHILIO, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0) PHILIO_3_IN_1_SENSOR_GEN_4_MOTION_MTII = ( PHILIO, PHILIO_SENSOR, PHILIO_3_IN_1_SENSOR_GEN_4, 0) +PHILIO_PAN07_MTII = (PHILIO, PHILIO_SWITCH, PHILIO_PAN07, 0) WENZHOU_SLIM_SENSOR_MOTION_MTII = ( WENZHOU, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0) @@ -28,6 +31,7 @@ WENZHOU_SLIM_SENSOR_MOTION_MTII = ( WORKAROUND_NO_OFF_EVENT = 'trigger_no_off_event' WORKAROUND_NO_POSITION = 'workaround_no_position' WORKAROUND_REVERSE_OPEN_CLOSE = 'reverse_open_close' +WORKAROUND_REFRESH_NODE_ON_UPDATE = 'refresh_node_on_update' WORKAROUND_IGNORE = 'workaround_ignore' # List of workarounds by (manufacturer_id, product_type, product_id, index) @@ -35,6 +39,7 @@ DEVICE_MAPPINGS_MTII = { PHILIO_SLIM_SENSOR_MOTION_MTII: WORKAROUND_NO_OFF_EVENT, PHILIO_3_IN_1_SENSOR_GEN_4_MOTION_MTII: WORKAROUND_NO_OFF_EVENT, WENZHOU_SLIM_SENSOR_MOTION_MTII: WORKAROUND_NO_OFF_EVENT, + PHILIO_PAN07_MTII: WORKAROUND_REFRESH_NODE_ON_UPDATE, } SOMFY_ZRTSI_CONTROLLER_MT = (SOMFY, SOMFY_ZRTSI) From 1b23b3281782b6ddd73df9ced3ca48eba2a526f4 Mon Sep 17 00:00:00 2001 From: Dennis de Greef Date: Sun, 5 Mar 2017 23:08:29 +0100 Subject: [PATCH 147/198] Use bundled certificates if port matches mqtts (#6429) * Use bundled certificates if port matches mqtts * Move import requests.certs to top, since it's used in more places * Add happy and non-happy path tests for default certificate bundle on mqtts port --- homeassistant/components/mqtt/__init__.py | 5 ++++ tests/components/mqtt/test_init.py | 34 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 331d32e83be..034d1154679 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -9,6 +9,7 @@ import logging import os import socket import time +import requests.certs import voluptuous as vol @@ -310,6 +311,10 @@ def async_setup(hass, config): certificate = os.path.join(os.path.dirname(__file__), 'addtrustexternalcaroot.crt') + # When the port indicates mqtts, use bundled certificates from requests + if certificate is None and port == 8883: + certificate = requests.certs.where() + will_message = conf.get(CONF_WILL_MESSAGE) birth_message = conf.get(CONF_BIRTH_MESSAGE) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index f476ed4be09..f29ef15a37f 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -380,6 +380,40 @@ def test_setup_fails_if_no_connect_broker(hass): assert not result +@asyncio.coroutine +def test_setup_uses_certificate_on_mqtts_port(hass): + """Test setup uses bundled certificates when mqtts port is requested.""" + test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker', + 'port': 8883}} + + with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: + yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + + assert mock_MQTT.called + assert mock_MQTT.mock_calls[0][1][2] == 8883 + + import requests.certs + expectedCertificate = requests.certs.where() + assert mock_MQTT.mock_calls[0][1][7] == expectedCertificate + + +@asyncio.coroutine +def test_setup_uses_certificate_not_on_mqtts_port(hass): + """Test setup doesn't use bundled certificates when not mqtts port.""" + test_broker_cfg = {mqtt.DOMAIN: {mqtt.CONF_BROKER: 'test-broker', + 'port': 1883}} + + with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT: + yield from async_setup_component(hass, mqtt.DOMAIN, test_broker_cfg) + + assert mock_MQTT.called + assert mock_MQTT.mock_calls[0][1][2] == 1883 + + import requests.certs + mqttsCertificateBundle = requests.certs.where() + assert mock_MQTT.mock_calls[0][1][7] != mqttsCertificateBundle + + @asyncio.coroutine def test_birth_message(hass): """Test sending birth message.""" From a8add06a40652265ac0271f81bf90b9632bb3778 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sun, 5 Mar 2017 23:52:15 +0100 Subject: [PATCH 148/198] Bugfix samsungtv discovery (#6438) --- homeassistant/components/media_player/samsungtv.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 0de775562a5..b71e37fda19 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -62,6 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = "{} ({})".format(tv_name, model) port = DEFAULT_PORT timeout = DEFAULT_TIMEOUT + mac = None else: _LOGGER.warning( 'Internal error on samsungtv component. Cannot determine device') From 2baa838ba76221a1ebb467078e51df487261ec03 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Mon, 6 Mar 2017 06:15:08 -0500 Subject: [PATCH 149/198] Added unittest for Ring sensor (#6447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional extended description… --- .coveragerc | 1 - tests/components/sensor/test_ring.py | 221 +++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 tests/components/sensor/test_ring.py diff --git a/.coveragerc b/.coveragerc index 77398e84b11..59c0c63ebe5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -363,7 +363,6 @@ omit = homeassistant/components/sensor/pocketcasts.py homeassistant/components/sensor/pvoutput.py homeassistant/components/sensor/qnap.py - homeassistant/components/sensor/ring.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py diff --git a/tests/components/sensor/test_ring.py b/tests/components/sensor/test_ring.py new file mode 100644 index 00000000000..c7bf966a3e9 --- /dev/null +++ b/tests/components/sensor/test_ring.py @@ -0,0 +1,221 @@ +"""The tests for the Ring sensor platform.""" +import unittest +from unittest import mock + +from homeassistant.components.sensor import ring +from tests.common import get_test_home_assistant + +VALID_CONFIG = { + "platform": "ring", + "username": "foo", + "password": "bar", + "monitored_conditions": [ + "battery", "last_activity", "volume" + ] +} + +ATTRIBUTION = 'Data provided by Ring.com' + + +def mocked_requests_get(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + if str(args[0]).startswith('https://api.ring.com/clients_api/session'): + return MockResponse({ + "profile": { + "authentication_token": "12345678910", + "email": "foo@bar.org", + "features": { + "chime_dnd_enabled": False, + "chime_pro_enabled": True, + "delete_all_enabled": True, + "delete_all_settings_enabled": False, + "device_health_alerts_enabled": True, + "floodlight_cam_enabled": True, + "live_view_settings_enabled": True, + "lpd_enabled": True, + "lpd_motion_announcement_enabled": False, + "multiple_calls_enabled": True, + "multiple_delete_enabled": True, + "nw_enabled": True, + "nw_larger_area_enabled": False, + "nw_user_activated": False, + "owner_proactive_snoozing_enabled": True, + "power_cable_enabled": False, + "proactive_snoozing_enabled": False, + "reactive_snoozing_enabled": False, + "remote_logging_format_storing": False, + "remote_logging_level": 1, + "ringplus_enabled": True, + "starred_events_enabled": True, + "stickupcam_setup_enabled": True, + "subscriptions_enabled": True, + "ujet_enabled": False, + "video_search_enabled": False, + "vod_enabled": False}, + "first_name": "Home", + "id": 999999, + "last_name": "Assistant"} + }, 201) + elif str(args[0])\ + .startswith("https://api.ring.com/clients_api/ring_devices"): + return MockResponse({ + "authorized_doorbots": [], + "chimes": [ + { + "address": "123 Main St", + "alerts": {"connection": "online"}, + "description": "Downstairs", + "device_id": "abcdef123", + "do_not_disturb": {"seconds_left": 0}, + "features": {"ringtones_enabled": True}, + "firmware_version": "1.2.3", + "id": 999999, + "kind": "chime", + "latitude": 12.000000, + "longitude": -70.12345, + "owned": True, + "owner": { + "email": "foo@bar.org", + "first_name": "Marcelo", + "id": 999999, + "last_name": "Assistant"}, + "settings": { + "ding_audio_id": None, + "ding_audio_user_id": None, + "motion_audio_id": None, + "motion_audio_user_id": None, + "volume": 2}, + "time_zone": "America/New_York"}], + "doorbots": [ + { + "address": "123 Main St", + "alerts": {"connection": "online"}, + "battery_life": 4081, + "description": "Front Door", + "device_id": "aacdef123", + "external_connection": False, + "features": { + "advanced_motion_enabled": False, + "motion_message_enabled": False, + "motions_enabled": True, + "people_only_enabled": False, + "shadow_correction_enabled": False, + "show_recordings": True}, + "firmware_version": "1.4.26", + "id": 987652, + "kind": "lpd_v1", + "latitude": 12.000000, + "longitude": -70.12345, + "motion_snooze": None, + "owned": True, + "owner": { + "email": "foo@bar.org", + "first_name": "Home", + "id": 999999, + "last_name": "Assistant"}, + "settings": { + "chime_settings": { + "duration": 3, + "enable": True, + "type": 0}, + "doorbell_volume": 1, + "enable_vod": True, + "live_view_preset_profile": "highest", + "live_view_presets": [ + "low", + "middle", + "high", + "highest"], + "motion_announcement": False, + "motion_snooze_preset_profile": "low", + "motion_snooze_presets": [ + "none", + "low", + "medium", + "high"]}, + "subscribed": True, + "subscribed_motions": True, + "time_zone": "America/New_York"}] + }, 200) + elif str(args[0]).startswith("https://api.ring.com/clients_api/doorbots"): + return MockResponse([{ + "answered": False, + "created_at": "2017-03-05T15:03:40.000Z", + "events": [], + "favorite": False, + "id": 987654321, + "kind": "motion", + "recording": {"status": "ready"}, + "snapshot_url": "" + }], 200) + + +class TestRingSetup(unittest.TestCase): + """Test the Ring platform.""" + + # pylint: disable=invalid-name + DEVICES = [] + + def add_devices(self, devices, action): + """Mock add devices.""" + for device in devices: + self.DEVICES.append(device) + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @mock.patch('requests.Session.get', side_effect=mocked_requests_get) + @mock.patch('requests.Session.post', side_effect=mocked_requests_get) + def test_setup(self, get_mock, post_mock): + """Test if component loaded successfully.""" + self.assertTrue( + ring.setup_platform(self.hass, VALID_CONFIG, + self.add_devices, None)) + + @mock.patch('requests.Session.get', side_effect=mocked_requests_get) + @mock.patch('requests.Session.post', side_effect=mocked_requests_get) + def test_sensor(self, get_mock, post_mock): + """Test the Ring sensor class and methods.""" + ring.setup_platform(self.hass, VALID_CONFIG, self.add_devices, None) + + for device in self.DEVICES: + device.update() + if device.name == 'Front Door Battery': + self.assertEqual(100, device.state) + self.assertEqual('lpd_v1', + device.device_state_attributes['kind']) + self.assertNotEqual('chimes', + device.device_state_attributes['type']) + if device.name == 'Downstairs Volume': + self.assertEqual(2, device.state) + self.assertEqual('1.2.3', + device.device_state_attributes['firmware']) + self.assertEqual('mdi:bell-ring', device.icon) + self.assertEqual('chimes', + device.device_state_attributes['type']) + if device.name == 'Front Door Last Activity': + self.assertFalse(device.device_state_attributes['answered']) + self.assertEqual('America/New_York', + device.device_state_attributes['timezone']) + + self.assertIsNone(device.entity_picture) + self.assertEqual(ATTRIBUTION, + device.device_state_attributes['attribution']) From 90ad54da7def7471e2ca9e9444e300e05ff3caad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Mar 2017 03:20:12 -0800 Subject: [PATCH 150/198] Shorten recorder connection init (#6432) * Wait up to 9 seconds * Set number of recorder retries to 8 * Do not sleep when reporting last connection error if no retries left * Make sure we clean up old engine if connection is retrying * Update __init__.py --- homeassistant/components/recorder/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index dcd4eeb0a0e..985ec240f71 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -43,8 +43,7 @@ DEFAULT_DB_FILE = 'home-assistant_v2.db' CONF_DB_URL = 'db_url' CONF_PURGE_DAYS = 'purge_days' -CONNECT_RETRY_WAIT = 10 -ERROR_QUERY = "Error during query: %s" +CONNECT_RETRY_WAIT = 3 FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ @@ -159,7 +158,9 @@ class Recorder(threading.Thread): tries = 1 connected = False - while not connected and tries < 5: + while not connected and tries <= 10: + if tries != 1: + time.sleep(CONNECT_RETRY_WAIT) try: self._setup_connection() migration.migrate_schema(self) @@ -168,7 +169,6 @@ class Recorder(threading.Thread): except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error during connection setup: %s (retrying " "in %s seconds)", err, CONNECT_RETRY_WAIT) - time.sleep(CONNECT_RETRY_WAIT) tries += 1 if not connected: @@ -303,6 +303,9 @@ class Recorder(threading.Thread): else: kwargs['echo'] = False + if self.engine is not None: + self.engine.dispose() + self.engine = create_engine(self.db_url, **kwargs) models.Base.metadata.create_all(self.engine) self.get_session = scoped_session(sessionmaker(bind=self.engine)) From ff3c90fb80d508919a3dbe2f525195434556f891 Mon Sep 17 00:00:00 2001 From: Markus Peter Date: Mon, 6 Mar 2017 17:37:29 +0100 Subject: [PATCH 151/198] KWB Easyfire support (#6018) * KWB Easyfire Support * requirements, coverage * Initialization fun * lint * requirements bump * lint * Second best validation ... * changes * reworked validation --- .coveragerc | 1 + homeassistant/components/sensor/kwb.py | 115 +++++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 119 insertions(+) create mode 100644 homeassistant/components/sensor/kwb.py diff --git a/.coveragerc b/.coveragerc index 59c0c63ebe5..0a226dd147e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -342,6 +342,7 @@ omit = homeassistant/components/sensor/imap.py homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/influxdb.py + homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py diff --git a/homeassistant/components/sensor/kwb.py b/homeassistant/components/sensor/kwb.py new file mode 100644 index 00000000000..54799ccc6b4 --- /dev/null +++ b/homeassistant/components/sensor/kwb.py @@ -0,0 +1,115 @@ +""" +Support for KWB Easyfire. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.kwb/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_DEVICE, + CONF_NAME, EVENT_HOMEASSISTANT_STOP, + STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['pykwb==0.0.8'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_RAW = False +DEFAULT_NAME = 'KWB' + +MODE_SERIAL = 0 +MODE_TCP = 1 + +CONF_TYPE = 'type' +CONF_RAW = 'raw' + +SERIAL_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_RAW, default=DEFAULT_RAW): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_DEVICE): cv.string, + vol.Required(CONF_TYPE): 'serial', +}) + +ETHERNET_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_RAW, default=DEFAULT_RAW): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_TYPE): 'tcp', +}) + +PLATFORM_SCHEMA = vol.Schema( + vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA) +) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the KWB component.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + device = config.get(CONF_DEVICE) + connection_type = config.get(CONF_TYPE) + raw = config.get(CONF_RAW) + client_name = config.get(CONF_NAME) + + from pykwb import kwb + + if connection_type == 'serial': + easyfire = kwb.KWBEasyfire(MODE_SERIAL, "", 0, device) + elif connection_type == 'tcp': + easyfire = kwb.KWBEasyfire(MODE_TCP, host, port) + else: + return False + + easyfire.run_thread() + + sensors = [] + for sensor in easyfire.get_sensors(): + if ((sensor.sensor_type != kwb.PROP_SENSOR_RAW) + or (sensor.sensor_type == kwb.PROP_SENSOR_RAW and raw)): + sensors.append(KWBSensor(easyfire, sensor, client_name)) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: easyfire.stop_thread()) + + add_devices(sensors) + + +class KWBSensor(Entity): + """Representation of a KWB Easyfire sensor.""" + + def __init__(self, easyfire, sensor, client_name): + """Initialize the KWB sensor.""" + self._easyfire = easyfire + self._sensor = sensor + self._client_name = client_name + self._name = self._sensor.name + + @property + def name(self): + """Return the name.""" + return '{} {}'.format(self._client_name, self._name) + + @property + def available(self) -> bool: + """Return if sensor is available.""" + return self._sensor.available + + @property + def state(self): + """Return the state of value.""" + if self._sensor.value is not None and self._sensor.available: + return self._sensor.value + else: + return STATE_UNKNOWN + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._sensor.unit_of_measurement diff --git a/requirements_all.txt b/requirements_all.txt index 978950046ed..120d3e169db 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -517,6 +517,9 @@ pyiss==1.0.1 # homeassistant.components.remote.itach pyitachip2ir==0.0.6 +# homeassistant.components.sensor.kwb +pykwb==0.0.8 + # homeassistant.components.sensor.lastfm pylast==1.8.0 From 9522fe3a92b2879f1f7a40f72f541f03f8ccddbb Mon Sep 17 00:00:00 2001 From: Barry Williams Date: Tue, 7 Mar 2017 04:38:33 +0000 Subject: [PATCH 152/198] Bumped version number for supporting lib (#6462) --- homeassistant/components/media_player/openhome.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index 46e8263999b..af58b4cb654 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF) -REQUIREMENTS = ['openhomedevice==0.2'] +REQUIREMENTS = ['openhomedevice==0.2.1'] SUPPORT_OPENHOME = SUPPORT_SELECT_SOURCE | \ SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ diff --git a/requirements_all.txt b/requirements_all.txt index 120d3e169db..b1b9e05e8e9 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -383,7 +383,7 @@ oemthermostat==1.1 openevsewifi==0.4 # homeassistant.components.media_player.openhome -openhomedevice==0.2 +openhomedevice==0.2.1 # homeassistant.components.switch.orvibo orvibo==1.1.1 From 5fb7aa212b829947676f419692fb28623725fdfc Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Tue, 7 Mar 2017 04:56:31 +0000 Subject: [PATCH 153/198] Send a logo with webostv notifications (#6380) * Update to pylgtv 0.1.4 * Send icon with webostv notifications Default to the homeassistant logo, but allow customizing it on the component and for individual notifications --- .../components/media_player/webostv.py | 4 +-- homeassistant/components/notify/webostv.py | 27 +++++++++++++------ requirements_all.txt | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index da498dc3d5b..fe029af163e 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -25,8 +25,8 @@ from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['https://github.com/TheRealLink/pylgtv' - '/archive/v0.1.3.zip' - '#pylgtv==0.1.3', + '/archive/v0.1.4.zip' + '#pylgtv==0.1.4', 'websockets==3.2', 'wakeonlan==0.2.2'] diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py index 476f7b9053e..e82971e0064 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -5,24 +5,29 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.webostv/ """ import logging +import os import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - BaseNotificationService, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_FILENAME, CONF_HOST) + ATTR_DATA, BaseNotificationService, PLATFORM_SCHEMA) +from homeassistant.const import (CONF_FILENAME, CONF_HOST, CONF_ICON) -REQUIREMENTS = ['https://github.com/TheRealLink/pylgtv/archive/v0.1.3.zip' - '#pylgtv==0.1.3'] +REQUIREMENTS = ['https://github.com/TheRealLink/pylgtv/archive/v0.1.4.zip' + '#pylgtv==0.1.4'] _LOGGER = logging.getLogger(__name__) WEBOSTV_CONFIG_FILE = 'webostv.conf' +HOME_ASSISTANT_ICON_PATH = os.path.join(os.path.dirname(__file__), '..', + 'frontend', 'www_static', 'icons', + 'favicon-1024x1024.png') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string + vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string, + vol.Optional(CONF_ICON, default=HOME_ASSISTANT_ICON_PATH): cv.string }) @@ -44,23 +49,29 @@ def get_service(hass, config, discovery_info=None): _LOGGER.error("TV unreachable") return None - return LgWebOSNotificationService(client) + return LgWebOSNotificationService(client, config.get(CONF_ICON)) class LgWebOSNotificationService(BaseNotificationService): """Implement the notification service for LG WebOS TV.""" - def __init__(self, client): + def __init__(self, client, icon_path): """Initialize the service.""" self._client = client + self._icon_path = icon_path def send_message(self, message="", **kwargs): """Send a message to the tv.""" from pylgtv import PyLGTVPairException try: - self._client.send_message(message) + data = kwargs.get(ATTR_DATA) + icon_path = data.get(CONF_ICON, self._icon_path) if data else \ + self._icon_path + self._client.send_message(message, icon_path=icon_path) except PyLGTVPairException: _LOGGER.error("Pairing with TV failed") + except FileNotFoundError: + _LOGGER.error("Icon %s not found", icon_path) except OSError: _LOGGER.error("TV unreachable") diff --git a/requirements_all.txt b/requirements_all.txt index b1b9e05e8e9..f6d14f384e5 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -217,7 +217,7 @@ https://github.com/LinuxChristian/pyW215/archive/v0.4.zip#pyW215==0.4 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv -https://github.com/TheRealLink/pylgtv/archive/v0.1.3.zip#pylgtv==0.1.3 +https://github.com/TheRealLink/pylgtv/archive/v0.1.4.zip#pylgtv==0.1.4 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner From 470702261a735aeb60262df3d9c5cc148066488a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Mar 2017 22:35:49 -0800 Subject: [PATCH 154/198] Upgrade netdisco to 0.9.2 (#6466) --- homeassistant/components/discovery.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 421ba321c8d..26036342452 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==0.9.1'] +REQUIREMENTS = ['netdisco==0.9.2'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index f6d14f384e5..87f72ce81ae 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ mutagen==1.36.2 myusps==1.0.3 # homeassistant.components.discovery -netdisco==0.9.1 +netdisco==0.9.2 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From 44d498753654abf1ed827fa870ecc4e26ea29470 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Mar 2017 01:11:41 -0800 Subject: [PATCH 155/198] Allow testing against uvloop (#6468) --- tests/common.py | 2 +- tests/conftest.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index 31741ecda67..66460110449 100644 --- a/tests/common.py +++ b/tests/common.py @@ -56,7 +56,6 @@ def get_test_home_assistant(): # pylint: disable=protected-access loop._thread_ident = threading.get_ident() loop.run_forever() - loop.close() stop_event.set() orig_start = hass.start @@ -73,6 +72,7 @@ def get_test_home_assistant(): """Stop hass.""" orig_stop() stop_event.wait() + loop.close() hass.start = start_hass hass.stop = stop_hass diff --git a/tests/conftest.py b/tests/conftest.py index c8afa70173e..bc773de8489 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ """Setup some common test helper things.""" +import asyncio import functools import logging +import os from unittest.mock import patch import pytest @@ -13,6 +15,10 @@ from homeassistant.components import mqtt from .common import async_test_home_assistant, mock_coro from .test_util.aiohttp import mock_aiohttp_client +if os.environ.get('UVLOOP') == '1': + import uvloop + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + logging.basicConfig() logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) From d16bc632da35a31957ba3ac399dab4769591af17 Mon Sep 17 00:00:00 2001 From: Kevin Siml Date: Tue, 7 Mar 2017 21:18:28 +0100 Subject: [PATCH 156/198] fix issue (#6470) * fix issue fix issue: https://community.home-assistant.io/t/error-in-new-notification-pushsafer/13308 * Update pushsafer.py * Update requirements_all.txt * Update pushsafer.py * Update pushsafer.py * Update pushsafer.py --- homeassistant/components/notify/pushsafer.py | 61 ++++++-------------- requirements_all.txt | 3 - 2 files changed, 19 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/notify/pushsafer.py b/homeassistant/components/notify/pushsafer.py index e39b94a18d6..78a600ab8d6 100644 --- a/homeassistant/components/notify/pushsafer.py +++ b/homeassistant/components/notify/pushsafer.py @@ -6,65 +6,42 @@ https://home-assistant.io/components/notify.pushsafer/ """ import logging +import requests import voluptuous as vol from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, ATTR_DATA, - BaseNotificationService) -from homeassistant.const import CONF_API_KEY + ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-pushsafer==0.2'] _LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://www.pushsafer.com/api' +CONF_DEVICE_KEY = 'private_key' -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, +DEFAULT_TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE_KEY): cv.string, }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): - """Get the Pushsafer notification service.""" - from pushsafer import InitError - - try: - return PushsaferNotificationService(config[CONF_API_KEY]) - except InitError: - _LOGGER.error( - 'Wrong private key supplied. Get it at https://www.pushsafer.com') - return None + """Get the Pushsafer.com notification service.""" + return PushsaferNotificationService(config.get(CONF_DEVICE_KEY)) class PushsaferNotificationService(BaseNotificationService): - """Implement the notification service for Pushsafer.""" + """Implementation of the notification service for Pushsafer.com.""" - def __init__(self, privatekey): + def __init__(self, private_key): """Initialize the service.""" - from pushsafer import Client - self._privatekey = privatekey - self.pushsafer = Client( - "", privatekey=self._privatekey) + self._private_key = private_key def send_message(self, message='', **kwargs): """Send a message to a user.""" - # Make a copy and use empty dict if necessary - data = dict(kwargs.get(ATTR_DATA) or {}) - - data['title'] = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - - targets = kwargs.get(ATTR_TARGET) - - if not isinstance(targets, list): - targets = [targets] - - for target in targets: - if target is not None: - data['device'] = target - - try: - self.pushsafer.send_message(message, data['title'], "", "", - "", "", "", "", - "0", "", "", "") - except ValueError as val_err: - _LOGGER.error(str(val_err)) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + payload = {'k': self._private_key, 't': title, 'm': message} + response = requests.get(_RESOURCE, params=payload, + timeout=DEFAULT_TIMEOUT) + if response.status_code != 200: + _LOGGER.error("Not possible to send notification") diff --git a/requirements_all.txt b/requirements_all.txt index 87f72ce81ae..1116e5c62e8 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -598,9 +598,6 @@ python-nmap==0.6.1 # homeassistant.components.notify.pushover python-pushover==0.2 -# homeassistant.components.notify.pushsafer -python-pushsafer==0.2 - # homeassistant.components.sensor.synologydsm python-synology==0.1.0 From 3508f74fb2b191f05e30e972e0496c6223beb503 Mon Sep 17 00:00:00 2001 From: Johan Bloemberg Date: Tue, 7 Mar 2017 23:20:27 +0100 Subject: [PATCH 157/198] Remove connection status state. (#6475) Current implementation of connection status doesn't follow convention and is not properly configurable. Might be added again in the future as a full fledged entity or some other way. For now users can rely on error logging to determine connection status. --- homeassistant/components/rflink.py | 9 --------- tests/components/test_rflink.py | 4 ---- 2 files changed, 13 deletions(-) diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 10ccf32068f..5999957066f 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -152,9 +152,6 @@ def async_setup(hass, config): def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" _LOGGER.info('Initiating Rflink connection') - hass.states.async_set( - '{domain}.connection_status'.format( - domain=DOMAIN), 'connecting') # Rflink create_rflink_connection decides based on the value of host # (string or None) if serial or tcp mode should be used @@ -180,9 +177,6 @@ def async_setup(hass, config): _LOGGER.exception( "Error connecting to Rflink, reconnecting in %s", reconnect_interval) - hass.states.async_set( - '{domain}.connection_status'.format( - domain=DOMAIN), 'error') hass.loop.call_later(reconnect_interval, reconnect, exc) return @@ -195,9 +189,6 @@ def async_setup(hass, config): lambda x: transport.close()) _LOGGER.info('Connected to Rflink') - hass.states.async_set( - '{domain}.connection_status'.format( - domain=DOMAIN), 'connected') hass.async_add_job(connect) return True diff --git a/tests/components/test_rflink.py b/tests/components/test_rflink.py index 555ec9372ba..9a83644dcfd 100644 --- a/tests/components/test_rflink.py +++ b/tests/components/test_rflink.py @@ -205,15 +205,11 @@ def test_error_when_not_connected(hass, monkeypatch): _, mock_create, _, disconnect_callback = yield from mock_rflink( hass, config, domain, monkeypatch, failures=failures) - assert hass.states.get('rflink.connection_status').state == 'connected' - # rflink initiated disconnect disconnect_callback(None) yield from asyncio.sleep(0, loop=hass.loop) - assert hass.states.get('rflink.connection_status').state == 'error' - success = yield from hass.services.async_call( domain, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: 'switch.test'}) assert not success, 'changing state should not succeed when disconnected' From 629b2e81bac0db928dea9fb23e8c70422f820d34 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Tue, 7 Mar 2017 17:26:53 -0500 Subject: [PATCH 158/198] Support for Blink Camera System (#6444) * Passing pep8, no tests yet * Fixed some issues with the request throttling * Removed ability to set throttle time because it was causing more issues than it was worth * Added blink to .coveragerc * Changed blinkpy version * Removed global var, fixed per PR requests * Added services for camera, migrated switch to binary_sensor * Added schema for service, fixed naming, removed unused function --- .coveragerc | 3 + .../components/binary_sensor/blink.py | 74 ++++++++++++++++ homeassistant/components/blink.py | 87 +++++++++++++++++++ homeassistant/components/camera/blink.py | 81 +++++++++++++++++ homeassistant/components/sensor/blink.py | 84 ++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 332 insertions(+) create mode 100644 homeassistant/components/binary_sensor/blink.py create mode 100644 homeassistant/components/blink.py create mode 100644 homeassistant/components/camera/blink.py create mode 100644 homeassistant/components/sensor/blink.py diff --git a/.coveragerc b/.coveragerc index 0a226dd147e..cd9ca93b5c8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -16,6 +16,9 @@ omit = homeassistant/components/bbb_gpio.py homeassistant/components/*/bbb_gpio.py + + homeassistant/components/blink.py + homeassistant/components/*/blink.py homeassistant/components/bloomsky.py homeassistant/components/*/bloomsky.py diff --git a/homeassistant/components/binary_sensor/blink.py b/homeassistant/components/binary_sensor/blink.py new file mode 100644 index 00000000000..8d84ffb9c90 --- /dev/null +++ b/homeassistant/components/binary_sensor/blink.py @@ -0,0 +1,74 @@ +""" +Support for Blink system camera control. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.blink/ +""" +from homeassistant.components.blink import DOMAIN +from homeassistant.components.binary_sensor import BinarySensorDevice + +DEPENDENCIES = ['blink'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the blink binary sensors.""" + if discovery_info is None: + return + + data = hass.data[DOMAIN].blink + devs = list() + for name in data.cameras: + devs.append(BlinkCameraMotionSensor(name, data)) + devs.append(BlinkSystemSensor(data)) + add_devices(devs, True) + + +class BlinkCameraMotionSensor(BinarySensorDevice): + """A representation of a Blink binary sensor.""" + + def __init__(self, name, data): + """Initialize the sensor.""" + self._name = 'blink_' + name + '_motion_enabled' + self._camera_name = name + self.data = data + self._state = self.data.cameras[self._camera_name].armed + + @property + def name(self): + """Return the name of the blink sensor.""" + return self._name + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def update(self): + """Update sensor state.""" + self.data.refresh() + self._state = self.data.cameras[self._camera_name].armed + + +class BlinkSystemSensor(BinarySensorDevice): + """A representation of a Blink system sensor.""" + + def __init__(self, data): + """Initialize the sensor.""" + self._name = 'blink armed status' + self.data = data + self._state = self.data.arm + + @property + def name(self): + """Return the name of the blink sensor.""" + return self._name.replace(" ", "_") + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def update(self): + """Update sensor state.""" + self.data.refresh() + self._state = self.data.arm diff --git a/homeassistant/components/blink.py b/homeassistant/components/blink.py new file mode 100644 index 00000000000..94635e2ae59 --- /dev/null +++ b/homeassistant/components/blink.py @@ -0,0 +1,87 @@ +""" +Support for Blink Home Camera System. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/blink/ +""" +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_USERNAME, + CONF_PASSWORD, + ATTR_FRIENDLY_NAME, + ATTR_ARMED) +from homeassistant.helpers import discovery +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'blink' +REQUIREMENTS = ['blinkpy==0.4.4'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + +ARM_SYSTEM_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ARMED): cv.boolean +}) + +ARM_CAMERA_SCHEMA = vol.Schema({ + vol.Required(ATTR_FRIENDLY_NAME): cv.string, + vol.Optional(ATTR_ARMED): cv.boolean +}) + +SNAP_PICTURE_SCHEMA = vol.Schema({ + vol.Required(ATTR_FRIENDLY_NAME): cv.string +}) + + +class BlinkSystem(object): + """Blink System class.""" + + def __init__(self, config_info): + """Initialize the system.""" + import blinkpy + self.blink = blinkpy.Blink(username=config_info[DOMAIN][CONF_USERNAME], + password=config_info[DOMAIN][CONF_PASSWORD]) + self.blink.setup_system() + + +def setup(hass, config): + """Setup Blink System.""" + hass.data[DOMAIN] = BlinkSystem(config) + discovery.load_platform(hass, 'camera', DOMAIN, {}, config) + discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) + discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + + def snap_picture(call): + """Take a picture.""" + cameras = hass.data[DOMAIN].blink.cameras + name = call.data.get(ATTR_FRIENDLY_NAME, '') + if name in cameras: + cameras[name].snap_picture() + + def arm_camera(call): + """Arm a camera.""" + cameras = hass.data[DOMAIN].blink.cameras + name = call.data.get(ATTR_FRIENDLY_NAME, '') + value = call.data.get(ATTR_ARMED, True) + if name in cameras: + cameras[name].set_motion_detect(value) + + def arm_system(call): + """Arm the system.""" + value = call.data.get(ATTR_ARMED, True) + hass.data[DOMAIN].blink.arm = value + hass.data[DOMAIN].blink.refresh() + + hass.services.register(DOMAIN, 'snap_picture', snap_picture, + schema=SNAP_PICTURE_SCHEMA) + hass.services.register(DOMAIN, 'arm_camera', arm_camera, + schema=ARM_CAMERA_SCHEMA) + hass.services.register(DOMAIN, 'arm_system', arm_system, + schema=ARM_SYSTEM_SCHEMA) + + return True diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py new file mode 100644 index 00000000000..685ee5bd0fa --- /dev/null +++ b/homeassistant/components/camera/blink.py @@ -0,0 +1,81 @@ +""" +Support for Blink system camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.blink/ +""" +import logging + +from datetime import timedelta +import requests + +from homeassistant.components.blink import DOMAIN +from homeassistant.components.camera import Camera +from homeassistant.util import Throttle + +DEPENDENCIES = ['blink'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup a Blink Camera.""" + if discovery_info is None: + return + + data = hass.data[DOMAIN].blink + devs = list() + for name in data.cameras: + devs.append(BlinkCamera(hass, config, data, name)) + + add_devices(devs) + + +class BlinkCamera(Camera): + """An implementation of a Blink Camera.""" + + def __init__(self, hass, config, data, name): + """Initialize a camera.""" + super().__init__() + self.data = data + self.hass = hass + self._name = name + self.notifications = self.data.cameras[self._name].notifications + self.response = None + + _LOGGER.info("Initialized blink camera %s", self._name) + + @property + def name(self): + """A camera name.""" + return self._name + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def request_image(self): + """An image request from Blink servers.""" + _LOGGER.info("Requesting new image from blink servers") + image_url = self.check_for_motion() + header = self.data.cameras[self._name].header + self.response = requests.get(image_url, headers=header, stream=True) + + def check_for_motion(self): + """A method to check if motion has been detected since last update.""" + self.data.refresh() + notifs = self.data.cameras[self._name].notifications + if notifs > self.notifications: + # We detected motion at some point + self.data.last_motion() + self.notifications = notifs + # returning motion image currently not working + # return self.data.cameras[self._name].motion['image'] + elif notifs < self.notifications: + self.notifications = notifs + + return self.data.camera_thumbs[self._name] + + def camera_image(self): + """Return a still image reponse from the camera.""" + self.request_image() + return self.response.content diff --git a/homeassistant/components/sensor/blink.py b/homeassistant/components/sensor/blink.py new file mode 100644 index 00000000000..738f8cb2768 --- /dev/null +++ b/homeassistant/components/sensor/blink.py @@ -0,0 +1,84 @@ +""" +Support for Blink system camera sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.blink/ +""" +import logging + +from homeassistant.components.blink import DOMAIN +from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['blink'] +SENSOR_TYPES = { + 'temperature': ['Temperature', TEMP_FAHRENHEIT], + 'battery': ['Battery', ''], + 'notifications': ['Notifications', ''] +} + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup a Blink sensor.""" + if discovery_info is None: + return + + data = hass.data[DOMAIN].blink + devs = list() + index = 0 + for name in data.cameras: + devs.append(BlinkSensor(name, 'temperature', index, data)) + devs.append(BlinkSensor(name, 'battery', index, data)) + devs.append(BlinkSensor(name, 'notifications', index, data)) + index += 1 + + add_devices(devs, True) + + +class BlinkSensor(Entity): + """A Blink camera sensor.""" + + def __init__(self, name, sensor_type, index, data): + """A method to initialize sensors from Blink camera.""" + self._name = 'blink_' + name + '_' + SENSOR_TYPES[sensor_type][0] + self._camera_name = name + self._type = sensor_type + self.data = data + self.index = index + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """A method to return the name of the camera.""" + return self._name + + @property + def state(self): + """A camera's current state.""" + return self._state + + @property + def unique_id(self): + """A unique camera sensor identifier.""" + return "sensor_{}_{}".format(self._name, self.index) + + @property + def unit_of_measurement(self): + """A method to determine the unit of measurement for temperature.""" + return self._unit_of_measurement + + def update(self): + """A method to retrieve sensor data from the camera.""" + camera = self.data.cameras[self._camera_name] + if self._type == 'temperature': + self._state = camera.temperature + elif self._type == 'battery': + self._state = camera.battery + elif self._type == 'notifications': + self._state = camera.notifications + else: + self._state = None + _LOGGER.warning("Could not retrieve state from %s", self.name) diff --git a/requirements_all.txt b/requirements_all.txt index 1116e5c62e8..6e4f3912ab2 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -67,6 +67,9 @@ batinfo==0.4.2 # homeassistant.components.sensor.scrape beautifulsoup4==4.5.3 +# homeassistant.components.blink +blinkpy==0.4.4 + # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From bb4f23f8e74247d1c10292a227c242e6d6ee5625 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Mar 2017 20:31:57 -0800 Subject: [PATCH 159/198] Add warning for slow platforms/components (#6467) * Add warning for slow platforms/components * Add test for slow component setup. * Add test for slow platform setup * Fix tests on Py34 --- homeassistant/helpers/entity_component.py | 10 +++++++- homeassistant/setup.py | 10 +++++++- tests/helpers/test_entity_component.py | 31 +++++++++++++++++++++-- tests/test_setup.py | 20 +++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 26c633820cf..908685205e7 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -18,6 +18,7 @@ from homeassistant.util.async import ( run_callback_threadsafe, run_coroutine_threadsafe) DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) +SLOW_SETUP_WARNING = 10 class EntityComponent(object): @@ -134,8 +135,13 @@ class EntityComponent(object): self, platform_type, scan_interval, entity_namespace) entity_platform = self._platforms[key] + self.logger.info("Setting up %s.%s", self.domain, platform_type) + warn_task = self.hass.loop.call_later( + SLOW_SETUP_WARNING, self.logger.warning, + 'Setup of platform %s is taking over %s seconds.', platform_type, + SLOW_SETUP_WARNING) + try: - self.logger.info("Setting up %s.%s", self.domain, platform_type) if getattr(platform, 'async_setup_platform', None): yield from platform.async_setup_platform( self.hass, platform_config, @@ -154,6 +160,8 @@ class EntityComponent(object): except Exception: # pylint: disable=broad-except self.logger.exception( 'Error while setting up platform %s', platform_type) + finally: + warn_task.cancel() def add_entity(self, entity, platform=None, update_before_add=False): """Add entity to component.""" diff --git a/homeassistant/setup.py b/homeassistant/setup.py index b9652787eff..4a4737dab03 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -21,6 +21,8 @@ ATTR_COMPONENT = 'component' DATA_SETUP = 'setup_tasks' DATA_PIP_LOCK = 'pip_lock' +SLOW_SETUP_WARNING = 10 + def setup_component(hass: core.HomeAssistant, domain: str, config: Optional[Dict]=None) -> bool: @@ -172,8 +174,12 @@ def _async_setup_component(hass: core.HomeAssistant, async_comp = hasattr(component, 'async_setup') + _LOGGER.info("Setting up %s", domain) + warn_task = hass.loop.call_later( + SLOW_SETUP_WARNING, _LOGGER.warning, + 'Setup of %s is taking over %s seconds.', domain, SLOW_SETUP_WARNING) + try: - _LOGGER.info("Setting up %s", domain) if async_comp: result = yield from component.async_setup(hass, processed_config) else: @@ -183,6 +189,8 @@ def _async_setup_component(hass: core.HomeAssistant, _LOGGER.exception('Error during setup of component %s', domain) async_notify_setup_error(hass, domain, True) return False + finally: + warn_task.cancel() if result is False: log_error('Component failed to initialize.') diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 395ef103fd3..3af01140c4d 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -4,7 +4,7 @@ import asyncio from collections import OrderedDict import logging import unittest -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, MagicMock from datetime import timedelta import homeassistant.core as ha @@ -12,7 +12,7 @@ import homeassistant.loader as loader from homeassistant.components import group from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.helpers.entity_component import ( - EntityComponent, DEFAULT_SCAN_INTERVAL) + EntityComponent, DEFAULT_SCAN_INTERVAL, SLOW_SETUP_WARNING) from homeassistant.helpers import discovery import homeassistant.util.dt as dt_util @@ -410,3 +410,30 @@ class TestHelpersEntityComponent(unittest.TestCase): return entity component.add_entities(create_entity(i) for i in range(2)) + + +@asyncio.coroutine +def test_platform_warn_slow_setup(hass): + """Warn we log when platform setup takes a long time.""" + platform = MockPlatform() + + loader.set_component('test_domain.platform', platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + + with patch.object(hass.loop, 'call_later', MagicMock()) \ + as mock_call: + yield from component.async_setup({ + DOMAIN: { + 'platform': 'platform', + } + }) + assert mock_call.called + assert len(mock_call.mock_calls) == 2 + + timeout, logger_method = mock_call.mock_calls[0][1][:2] + + assert timeout == SLOW_SETUP_WARNING + assert logger_method == _LOGGER.warning + + assert mock_call().cancel.called diff --git a/tests/test_setup.py b/tests/test_setup.py index f14561a0c48..9d29961da10 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -407,3 +407,23 @@ def test_component_cannot_depend_config(hass): result = yield from setup._async_process_dependencies( hass, None, 'test', ['config']) assert not result + + +@asyncio.coroutine +def test_component_warn_slow_setup(hass): + """Warn we log when a component setup takes a long time.""" + loader.set_component('test_component1', MockModule('test_component1')) + with mock.patch.object(hass.loop, 'call_later', mock.MagicMock()) \ + as mock_call: + result = yield from setup.async_setup_component( + hass, 'test_component1', {}) + assert result + assert mock_call.called + assert len(mock_call.mock_calls) == 2 + + timeout, logger_method = mock_call.mock_calls[0][1][:2] + + assert timeout == setup.SLOW_SETUP_WARNING + assert logger_method == setup._LOGGER.warning + + assert mock_call().cancel.called From 2c5d3387f23eaff6a689aad46b7b117f3a54bed1 Mon Sep 17 00:00:00 2001 From: siebert Date: Wed, 8 Mar 2017 05:57:35 +0100 Subject: [PATCH 160/198] Fix wake_on_lan ping for Linux. (#6480) --- homeassistant/components/switch/wake_on_lan.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index 57ad4d34f1a..d9f7d0ad637 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -85,11 +85,11 @@ class WOLSwitch(SwitchDevice): def update(self): """Check if device is on and update the state.""" if platform.system().lower() == 'windows': - ping_cmd = 'ping -n 1 -w {} {}'.format( - DEFAULT_PING_TIMEOUT * 1000, self._host) + ping_cmd = ['ping', '-n', '1', '-w', + str(DEFAULT_PING_TIMEOUT * 1000), self._host] else: - ping_cmd = 'ping -c 1 -W {} {}'.format( - DEFAULT_PING_TIMEOUT, self._host) + ping_cmd = ['ping', '-c', '1', '-W', + str(DEFAULT_PING_TIMEOUT), self._host] status = sp.call(ping_cmd, stdout=sp.DEVNULL) self._state = not bool(status) From e7f442d66b8a3fba0a16819b0aaeb904340b2274 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 8 Mar 2017 07:37:23 +0100 Subject: [PATCH 161/198] Add dispatcher camera for internal image. (#6471) * Add dispatcher camera for internal image. * fix lint * Add unittest * Update dispatcher.py --- homeassistant/components/camera/dispatcher.py | 67 +++++++++++++++++++ tests/components/camera/test_dispatcher.py | 36 ++++++++++ 2 files changed, 103 insertions(+) create mode 100644 homeassistant/components/camera/dispatcher.py create mode 100644 tests/components/camera/test_dispatcher.py diff --git a/homeassistant/components/camera/dispatcher.py b/homeassistant/components/camera/dispatcher.py new file mode 100644 index 00000000000..b5a846665ad --- /dev/null +++ b/homeassistant/components/camera/dispatcher.py @@ -0,0 +1,67 @@ +""" +Support for internal dispatcher image push to Camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.dispatcher/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import CONF_NAME +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +CONF_SIGNAL = 'signal' +DEFAULT_NAME = 'Dispatcher Camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SIGNAL): cv.slugify, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Setup a dispatcher camera.""" + if discovery_info: + config = PLATFORM_SCHEMA(discovery_info) + + async_add_devices( + [DispatcherCamera(config[CONF_NAME], config[CONF_SIGNAL])]) + + +class DispatcherCamera(Camera): + """A dispatcher implementation of an camera.""" + + def __init__(self, name, signal): + """Initialize a dispatcher camera.""" + super().__init__() + self._name = name + self._signal = signal + self._image = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Register dispatcher and callbacks.""" + @callback + def async_update_image(image): + """Update image from dispatcher call.""" + self._image = image + + async_dispatcher_connect(self.hass, self._signal, async_update_image) + + @asyncio.coroutine + def async_camera_image(self): + """Return a still image response from the camera.""" + return self._image + + @property + def name(self): + """Return the name of this device.""" + return self._name diff --git a/tests/components/camera/test_dispatcher.py b/tests/components/camera/test_dispatcher.py new file mode 100644 index 00000000000..fad5a3de52f --- /dev/null +++ b/tests/components/camera/test_dispatcher.py @@ -0,0 +1,36 @@ +"""The tests for dispatcher camera component.""" +import asyncio + +from homeassistant.setup import async_setup_component +from homeassistant.helpers.dispatcher import async_dispatcher_send + + +@asyncio.coroutine +def test_run_camera_setup(hass, test_client): + """Test that it fetches the given dispatcher data.""" + yield from async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'dispatcher', + 'name': 'dispatcher', + 'signal': 'test_camera', + }}) + + client = yield from test_client(hass.http.app) + + async_dispatcher_send(hass, 'test_camera', b'test') + yield from hass.async_block_till_done() + + resp = yield from client.get('/api/camera_proxy/camera.dispatcher') + + assert resp.status == 200 + body = yield from resp.text() + assert body == 'test' + + async_dispatcher_send(hass, 'test_camera', b'test2') + yield from hass.async_block_till_done() + + resp = yield from client.get('/api/camera_proxy/camera.dispatcher') + + assert resp.status == 200 + body = yield from resp.text() + assert body == 'test2' From c937a7bcb05e7690a97cfd5a0bd69f877d72ff54 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 8 Mar 2017 07:51:34 +0100 Subject: [PATCH 162/198] Add support for remove services / Reload script support (#6441) * Add support for remove services / Reload script support * Reload support for scripts * Add more unittest for services * Add unittest for script reload * Address paulus comments --- .../components/automation/__init__.py | 3 +- homeassistant/components/group.py | 8 +- homeassistant/components/script.py | 76 +++++++++++++----- homeassistant/const.py | 2 + homeassistant/core.py | 29 ++++++- tests/components/test_script.py | 36 +++++++++ tests/test_core.py | 78 +++++++++++++++++-- 7 files changed, 197 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 7233ffc5c66..96d5b0499d2 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -15,7 +15,7 @@ from homeassistant.setup import async_prepare_setup_platform from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE) + SERVICE_TOGGLE, SERVICE_RELOAD) from homeassistant.components import logbook from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition @@ -51,7 +51,6 @@ DEFAULT_INITIAL_STATE = True ATTR_LAST_TRIGGERED = 'last_triggered' ATTR_VARIABLES = 'variables' SERVICE_TRIGGER = 'trigger' -SERVICE_RELOAD = 'reload' _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 06e029ffd8c..f582ff33a07 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -14,7 +14,7 @@ from homeassistant import config as conf_util, core as ha from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED, - STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE) + STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE, SERVICE_RELOAD) from homeassistant.core import callback from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent @@ -42,7 +42,6 @@ SET_VISIBILITY_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_VISIBLE): cv.boolean }) -SERVICE_RELOAD = 'reload' RELOAD_SERVICE_SCHEMA = vol.Schema({}) _LOGGER = logging.getLogger(__name__) @@ -395,17 +394,16 @@ class Group(Entity): self._state = STATE_UNKNOWN self._async_update_group_state() - @asyncio.coroutine def async_remove(self): """Remove group from HASS. - This method must be run in the event loop. + This method must be run in the event loop and returns a coroutine. """ if self._async_unsub_state_changed: self._async_unsub_state_changed() self._async_unsub_state_changed = None - yield from super().async_remove() + return super().async_remove() @asyncio.coroutine def _async_state_changed_listener(self, entity_id, old_state, new_state): diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index cf4843353b5..bcab6465dc1 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_TOGGLE, STATE_ON, CONF_ALIAS) + SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS) from homeassistant.core import split_entity_id from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent @@ -49,6 +49,7 @@ SCRIPT_TURN_ONOFF_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_VARIABLES): dict, }) +RELOAD_SERVICE_SCHEMA = vol.Schema({}) def is_on(hass, entity_id): @@ -56,6 +57,11 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) +def reload(hass): + """Reload script component.""" + hass.services.call(DOMAIN, SERVICE_RELOAD) + + def turn_on(hass, entity_id, variables=None): """Turn script on.""" _, object_id = split_entity_id(entity_id) @@ -76,29 +82,19 @@ def toggle(hass, entity_id): @asyncio.coroutine def async_setup(hass, config): """Load the scripts from the configuration.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, - group_name=GROUP_NAME_ALL_SCRIPTS) + component = EntityComponent( + _LOGGER, DOMAIN, hass, group_name=GROUP_NAME_ALL_SCRIPTS) + + yield from _async_process_config(hass, config, component) @asyncio.coroutine - def service_handler(service): - """Execute a service call to script. \ No newline at end of file +}()); \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index 396ea6fa8a1282f164f101a67d1dfc80990112d9..40657a51e085667c06e92ecc241a895ca0bf2a8b 100644 GIT binary patch delta 62413 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

6uvgNBfZVyv%(`j7pzFNF9lAjuwf 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 diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index e509ed07a08..de1b20b70a1 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit e509ed07a08d35152b9eea6e263411dfc027867b +Subproject commit de1b20b70a16aeb7c48a1b4867c97864c88adb1c diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html index 438a682efaf..07fd6d9e02e 100644 --- a/homeassistant/components/frontend/www_static/mdi.html +++ b/homeassistant/components/frontend/www_static/mdi.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz index 4c71babd599acbcbb62aa953e3aa3dbc62590259..e8350d32a91599c6e65555db7ff961d719c5951a 100644 GIT binary patch literal 193415 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