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 | 81 +++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 3af93d08fc819..985dd8418eb75 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -10,6 +10,7 @@ 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_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 @@ def device_state_attributes(self): 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_mw(self): + """Current power usage in mW.""" + if self.insight_params: + 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 + + @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 current_power_mwh(self): - """Current power usage in mWh.""" + def power_total_mw_min(self): + """This is a total of average mW per minute.""" if self.insight_params: - return self.insight_params['currentpower'] + try: + return self.insight_params['totalmw'] + except: + return None @property - def today_power_mw(self): - """Today total power usage in mW.""" + def power_today_mw_min(self): + """This is the total consumption today in mW per minute.""" if self.insight_params: - return self.insight_params['todaymw'] + 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 985dd8418eb75..1576a203b358c 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -5,12 +5,12 @@ 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 @@ def device_state_attributes(self): 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 @@ 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'] + 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 1576a203b358c..b4d619c98823d 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -157,9 +157,10 @@ def power_threshold(self): 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 b4d619c98823d..4d436aa348eef 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -158,7 +158,7 @@ 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.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 | 43 +++++++++++++------------ 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 4d436aa348eef..89bddb8ecc271 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -138,63 +138,66 @@ def device_state_attributes(self): 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 - - 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) - + 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) + @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 89bddb8ecc271..7fe8de7b699c3 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -168,19 +168,19 @@ def as_uptime(_seconds): 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 7fe8de7b699c3..3c7a3c565c036 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -157,7 +157,7 @@ def power_threshold(self): @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 3c7a3c565c036..afa3a8a023726 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -138,7 +138,10 @@ def device_state_attributes(self): 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 @@ def power_current_watt(self): if self.insight_params: try: return self._current_power_mw() / 1000 - except Exception: + except TypeError: return None @property @@ -157,7 +160,7 @@ def power_threshold(self): @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 @@ def power_total_mw_min(self): if self.insight_params: try: return self.insight_params['totalmw'] - except Exception: + except KeyError: return None @property @@ -197,7 +200,7 @@ def power_today_mw_min(self): 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 afa3a8a023726..0768b62062d33 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -21,16 +21,6 @@ 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 @@ def device_state_attributes(self): 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 @@ def as_uptime(_seconds): uptime.second) @property - def on_for(self): - """On time in seconds.""" - if self.insight_params: - return WemoSwitch.as_uptime(self.insight_params['onfor']) - - @property - def on_today(self): - """On time in seconds.""" - 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.""" + def current_power_mwh(self): + """Current power usage in mWh.""" if self.insight_params: - try: - return self.insight_params['totalmw'] - except KeyError: - return None + return self.insight_params['currentpower'] @property - def power_today_mw_min(self): - """Total consumption today in mW per minute.""" + def today_power_mw(self): + """Today total power usage in mW.""" 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 40c91784a42e1..24c14e7c9a8fc 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -18,7 +18,6 @@ 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 230e0e4567fa5..06e029ffd8c10 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -20,8 +20,7 @@ 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 @@ def async_update_tracked_entity_ids(self, entity_ids): 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 8d716bea0d57f..97d210d584a4c 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -108,8 +108,7 @@ def state_attributes(self): 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 05002788207c4..8b25e2a726bfd 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -24,8 +24,6 @@ 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 b69289db98959..30d52303099d0 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -22,7 +22,6 @@ 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 @@ 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 b4dde02baff70..d7eef848679c7 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification.py @@ -16,7 +16,6 @@ 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 @@ 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 a5712fcbcbe7e..01943bc9c6903 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -21,7 +21,6 @@ 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 6131662dc5fee..dd030c73d1aeb 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 | 147 +++++++++------- .../components/binary_sensor/envisalink.py | 55 +++--- homeassistant/components/envisalink.py | 166 +++++++++--------- homeassistant/components/sensor/envisalink.py | 65 +++---- homeassistant/helpers/dispatcher.py | 42 +++++ requirements_all.txt | 1 - tests/helpers/test_dispatcher.py | 103 +++++++++++ 7 files changed, 372 insertions(+), 207 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 96b0fc83ea72d..cd5bddbad49a4 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -4,16 +4,20 @@ 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 @@ DEPENDENCIES = ['envisalink'] -DEVICES = [] - SERVICE_ALARM_KEYPRESS = 'envisalink_alarm_keypress' ATTR_KEYPRESS = 'keypress' ALARM_KEYPRESS_SCHEMA = vol.Schema({ @@ -32,68 +34,72 @@ }) -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] +@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] + + 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) - for device in _target_devices: - EnvisalinkAlarm.alarm_keypress(device, keypress) + 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) -# pylint: disable=unused-argument -def setup_platform(hass, config, 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) + target_devices = [device for device in devices + if device.entity_id in entity_ids] - add_devices(DEVICES) + 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) + 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 @@ def state(self): 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 3d10736c9ee1d..279dadf120fce 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -4,48 +4,56 @@ 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 @@ def device_class(self): """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 2c101a227cf0b..054392132842e 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -4,20 +4,24 @@ 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_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 @@ }, 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 - - config = base_config.get(DOMAIN) - - _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) + conf = 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) + + 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() - - 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) - - _LOGGER.error("Timeout occurred while establishing evl connection.") - return False + controller.stop() + + 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 - 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 + _LOGGER.info("Start envisalink.") + controller.start() - _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 a29179598a838..20142c13c3b33 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -4,51 +4,55 @@ 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 @@ def device_state_attributes(self): """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 0000000000000..324d4ccc621b6 --- /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 4577801a07d40..35931779e8776 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 0000000000000..fbac0689ff14e --- /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 3a8144d9188b2..71c64a017f7ff 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -10,6 +10,7 @@ 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 @@ 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 @@ def should_poll(self): 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 e069c5a1e1758..a9524729a9f82 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -11,6 +11,7 @@ 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 @@ } -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 2d995ca7acab2..131ce795d935e 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -11,6 +11,7 @@ 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 @@ 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 0c5cf1d081e87..36ef7eca21d9c 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -13,6 +13,7 @@ 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_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 3b01138ccb2d2..86ded53bae997 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -13,6 +13,7 @@ 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 @@ }) -# 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 clear_usercode(service): 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 clear_usercode(service): 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 e220825d526a0..03f85ddbda4bb 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -10,41 +10,25 @@ 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 1a844ebcfe07d..9942743d3264e 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -9,27 +9,21 @@ # 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 f0c5e54bae05f..033cedac70539 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -4,6 +4,7 @@ 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 @@ 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_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 value_added(node, value): 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 value_added(node, value): 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 @@ def value_changed(self, value): """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 e9a1739573553..881f20cd0fc2d 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -15,6 +15,8 @@ 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 71f5a258cdc9e..bf46fd336195f 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -5,8 +5,6 @@ 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 0768b62062d33..1c9cbe15a33c3 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -110,18 +110,16 @@ def device_state_attributes(self): 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 c4eada498daa7..254115c55b185 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -36,6 +36,7 @@ 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 1817181b1843b..290820f3bd4ed 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -146,7 +146,7 @@ def async_added_to_hass(self): 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 dfed0f5241357..1e463d316d495 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -48,13 +48,15 @@ def remove_cache(event): @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 c6709aea7cccf..cb32fc887c90a 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -34,7 +34,7 @@ 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 7016cd72492c8..610f4e79bb2a7 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -39,7 +39,7 @@ 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 765acb56ec9fa..2d754daa6d872 100644 --- a/tests/components/sensor/test_sleepiq.py +++ b/tests/components/sensor/test_sleepiq.py @@ -4,6 +4,7 @@ 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 @@ def test_setup(self, mock): """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 dc9b7f06c1fb8..223b556dce3a0 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -80,6 +80,7 @@ def mock_write(path, data): 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 mock_write(path, data): 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 3c71944000135..5c0fd23300e99 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 f4cb1e5248b32..d6b1151a14f02 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -89,6 +89,7 @@ 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 748c5b5cc2df6..18b69f81a9dfd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,8 @@ 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 @@ 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 @@ def tearDown(self): 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 6c628f4920e3a..76dae8df4c795 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -112,9 +112,9 @@ def __init__(self, interval, ip_addr, mac_addr, timeout): 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 @@ def _update(self, retry=2): 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 df24f41edbe54..ead00d97f6476 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -17,7 +17,7 @@ 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 35931779e8776..3931a14c5fbf7 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 71c64a017f7ff..48ef1479eeccd 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -21,8 +21,6 @@ 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 a9524729a9f82..ad6c89bcea199 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -36,7 +36,6 @@ 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 131ce795d935e..aa2cdf858fd62 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 36ef7eca21d9c..84aebffab0e05 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 86ded53bae997..ba1df32130d25 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -175,12 +175,6 @@ def clear_usercode(service): _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 clear_usercode(service): 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 03f85ddbda4bb..0d10a470b07af 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -17,8 +17,6 @@ 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 9942743d3264e..a9166c8352f7a 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -14,15 +14,8 @@ _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 033cedac70539..c18a87710fe14 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -706,6 +706,7 @@ def __init__(self, value, domain): 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 72fed725c05e8..00d8d24853e14 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -34,6 +34,8 @@ CONF_SERVER_ID = 'server_id' CONF_MANUAL = 'manual' +ICON = 'mdi:speedometer' + SENSOR_TYPES = { 'ping': ['Ping', 'ms'], 'download': ['Download', 'Mbit/s'], @@ -103,6 +105,11 @@ def unit_of_measurement(self): """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 76dae8df4c795..38806959f5552 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -32,7 +32,7 @@ 'air_quality': ['Air Quality', ' '], 'humidity': ['Humidity', '%'], 'light': ['Light', ' '], - 'noise': ['Noise', ' '] + 'noise': ['Noise', ' '], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -110,6 +110,13 @@ def __init__(self, interval, ip_addr, mac_addr, timeout): 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 @@ def __init__(self, interval, ip_addr, mac_addr, timeout): 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 | 167 +++++++++++++++-------- homeassistant/components/weather/zamg.py | 107 +++++++++++++++ 3 files changed, 221 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/weather/zamg.py diff --git a/.coveragerc b/.coveragerc index 43de8df4088ae..50bf08b027948 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 6b500460d7bef..3d5f6146a39ac 100644 --- a/homeassistant/components/sensor/zamg.py +++ b/homeassistant/components/sensor/zamg.py @@ -5,9 +5,13 @@ 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 @@ 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 @@ 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 @@ 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__) - probe = ZamgData(station_id=station_id, logger=logger) - sensors = [ZamgSensor(probe, variable, name) - for variable in config[CONF_MONITORED_CONDITIONS]] + 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 - add_devices(sensors, True) + 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([ZamgSensor(probe, variable, config.get(CONF_NAME)) + for variable in config[CONF_MONITORED_CONDITIONS]], True) class ZamgSensor(Entity): @@ -117,8 +124,7 @@ def device_state_attributes(self): 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 @@ def __init__(self, logger, station_id): 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 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: + 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 + + 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 0000000000000..4a0e092a5adee --- /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 d537d8067cbe8..06d1fd954f2ab 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -17,7 +17,7 @@ _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 3931a14c5fbf7..5f89193bf5952 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 2bb35dd8f3f94..d6e03e7661956 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -19,7 +19,6 @@ 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.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_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 handle(request): 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 b3f17c1dd57c5..96a32d1ae6eb9 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -5,7 +5,7 @@ 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 .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_handler(request): 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_handler(request): @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 70b35e002475a..a3557a301c508 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -18,6 +18,7 @@ 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 @@ def cancel_connection(event): 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 c210bc3f0e0a1..b01535206fff6 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -97,7 +97,6 @@ def call_server(): 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 3cdc77414eeda..658a5e0be53d9 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -9,7 +9,7 @@ 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 57ea0351168b4..7831162325897 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -17,6 +17,8 @@ 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 @@ 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 @@ def _mqtt_on_message(self, _mqttc, _userdata, msg): "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 c4a4b7bc4abfb..bd149b6397d1b 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -19,7 +19,6 @@ 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 _event_publisher(event): 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 324d4ccc621b6..3a1d7d075aa6d 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 762531752ca1d..82623dd0e2da6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,6 +14,7 @@ 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(call): @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 18510dd2ff3d2..255d5f6a96c02 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -13,6 +13,7 @@ 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 @@ def test_receiving_mqtt_message_fires_hass_event(self): 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 @@ def record(event): 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 @@ def test_receiving_non_utf8_message_gets_logged(self): 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 c4e7f7fd673c6..dd08904a8e177 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 @@ def listener(_): 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 fbac0689ff14e..066e7386c6e28 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -28,8 +28,6 @@ def test_funct(data): 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 @@ def test_funct(data): 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 @@ def test_funct(data): 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 @@ def test_funct(data): 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 @@ def test_funct(data1, data2, data3): 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 3ce07cff7efd0..07f8e19283945 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 8a1e42b61bc8e..8182c8ccf39f2 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -22,7 +22,7 @@ _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 938d4b6943f41..8d55759f7b6fa 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 a2f3d5702a885..dad15361ba49d 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -23,7 +23,7 @@ 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 ead00d97f6476..6d132f8a1fc28 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 @@ 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 a0b5bbf5a0adc..9cbeb753b2b76 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 0e358a6abbb73..4d4e0601ca5a6 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 0c743c4498475..fbd6d9b0806d9 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -239,7 +239,7 @@ def run(self): 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 @@ def _commit(session, work): 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 fbd6d9b0806d9..0e301f2a87c24 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -7,6 +7,7 @@ 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 @@ 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 @@ def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, 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 @@ def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, 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 @@ def run(self): 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 @@ def run(self): 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 @@ def shutdown(self, event): """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 @@ def _commit(session, work): 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 1e463d316d495..86cd3e7037f75 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -6,7 +6,8 @@ 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 d411ef2073a12..3a4c058f8534f 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -11,7 +11,8 @@ 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 d6b1151a14f02..852151e83f59b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -309,7 +309,7 @@ def set_time_zone(time_zone_str): # 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 a3b120410c55c..d01fb848eabb4 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -18,7 +18,7 @@ _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 @@ @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_device_message_received(topic, payload, qos): 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 389fb37b4896e..134b679daeaed 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 9a2cc400d5c6f..3a91e972a70d6 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 83d93603f2114..0de54ad7c77e7 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -73,7 +73,7 @@ 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.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 5d223c8da4b43..e509ed07a08d3 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 beae637f6ea49..0bc47086c6bb3 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 0e301f2a87c24..0f8d7b48fe2c9 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -236,7 +236,7 @@ def run(self): 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 093a53786be7b..52a37eb8faa77 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -21,7 +21,7 @@ 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 5f89193bf5952..5b0c020b3161d 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 82623dd0e2da6..93ddc7c2f65ec 100644 --- a/tests/common.py +++ b/tests/common.py @@ -23,7 +23,7 @@ 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 @@ _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) +INST_COUNT = 0 def get_test_config_dir(*add_path): @@ -85,6 +86,8 @@ def stop_hass(): @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 mock_async_start(): 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 1e987a5f0a2cc..33c5d9f09173d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,15 @@ def guard_func(*args, **kwargs): 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 4bf163ff9ebdc..8572bbc044a00 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -4,15 +4,22 @@ 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 @@ _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 @@ }, 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 @@ def filter(self, record): 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 = {} + + # Preserve existing logs + if LOGGER_LOGS in logfilter: + logs.update(logfilter[LOGGER_LOGS]) - logs = OrderedDict( + # 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 661f8be8dab1b..a28a95969fb40 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 e4e8c75d1bdb5..099137bdf4bae 100644 --- a/tests/components/test_logger.py +++ b/tests/components/test_logger.py @@ -10,6 +10,14 @@ 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 @@ def test_logger_setup(self): 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 8c822c5636159..3dd3f35122749 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 @@ def __init__(self, hass, manager, config): """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 @@ def async_start_ffmpeg(self): ) # 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 8db4691d743b6..af5c64186f6da 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 @@ def __init__(self, hass, manager, config): """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 @@ def async_start_ffmpeg(self): ) # 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 c98354662e2ad..5b012ffad4ace 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -14,6 +14,8 @@ 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_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 @@ }) -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_service_handle(service): """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 @@ def __init__(self, hass, ffmpeg_bin, run_test): 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 @@ def __init__(self, initial_state=True): 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 @@ def should_poll(self): """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 d2b999d125595..ffeba1870a606 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 @@ def test_setup_component(self): 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 @@ def test_setup_component_start(self, mock_start): 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 @@ def test_setup_component(self): 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 @@ def test_setup_component_start(self, mock_start): 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 abc69a627de62..0af90ad7836c0 100644 --- a/tests/components/test_ffmpeg.py +++ b/tests/components/test_ffmpeg.py @@ -3,9 +3,7 @@ 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 @@ 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 @@ def test_setup_component_test_service(self): 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(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + ffmpeg.async_start(hass) + yield from hass.async_block_till_done() - ffmpeg.start(self.hass) - self.hass.block_till_done() + assert ffmpeg_dev.called_start - assert ffmpeg_dev.called_start - 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: {}}) +@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: {}}) - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + ffmpeg.async_stop(hass) + yield from hass.async_block_till_done() - ffmpeg.stop(self.hass) - self.hass.block_till_done() + assert ffmpeg_dev.called_stop - assert ffmpeg_dev.called_stop - 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: {}}) +@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: {}}) - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + ffmpeg.async_restart(hass) + yield from hass.async_block_till_done() - ffmpeg.restart(self.hass) - self.hass.block_till_done() + assert ffmpeg_dev.called_stop + assert ffmpeg_dev.called_start - assert ffmpeg_dev.called_restart - 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: {}}) +@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: {}}) - ffmpeg_dev = MockFFmpegDev(False) - manager = self.hass.data[ffmpeg.DATA_FFMPEG] + ffmpeg_dev = MockFFmpegDev(hass, False) + yield from ffmpeg_dev.async_added_to_hass() - run_callback_threadsafe( - self.hass.loop, manager.async_register_device, ffmpeg_dev).result() + ffmpeg.async_start(hass, 'test.ffmpeg_device') + yield from hass.async_block_till_done() - ffmpeg.start(self.hass, 'test.ffmpeg_device') - self.hass.block_till_done() + assert ffmpeg_dev.called_start + assert ffmpeg_dev.called_entities == ['test.ffmpeg_device'] - 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 | 410 +++++------------- 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, 723 insertions(+), 696 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 254115c55b185..5c68f767cd25a 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -20,6 +20,7 @@ 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 @@ 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)) + + if filters: + query = filters.apply(query, entity_ids) + + if end_time is not None: + query = query.filter(States.last_updated < end_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))) + 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(states, start_time, entity_id, filters) + 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 @@ def get(self, request, datetime=None): 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 @@ def apply(self, query, entity_ids=None): * 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 30d52303099d0..92f99887867a1 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -14,7 +14,7 @@ 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 log_message(service): 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 @@ def get(self, request, datetime=None): 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 0f8d7b48fe2c9..c60b95d1cae59 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -8,27 +8,31 @@ 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 @@ 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 @@ }) }, 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. +def wait_connection_ready(hass): + """ + Wait till the connection is ready. - This method also retries a few times in the case of stale connections. + Returns a coroutine object. """ - 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 [] + 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 @@ def run(self): "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 @@ def run(self): 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 @@ def run(self): 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) - - if event.event_type != EVENT_STATE_CHANGED: - self.queue.task_done() - continue + session.add(dbevent) - 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 @@ def event_listener(self, event): """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 @@ def _setup_connection(self): 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 @@ def _close_connection(self): 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 0000000000000..e2716ea982a24 --- /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 0000000000000..09c5e9837c341 --- /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 0000000000000..2b675e72759fe --- /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 0000000000000..e4ea1af1060a1 --- /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 b019e6745fba7..eb54869d66faf 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -164,13 +164,13 @@ def update(self): # 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 86cd3e7037f75..4ac1e4425466e 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -7,7 +7,7 @@ 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 remove_cache(event): 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 remove_cache(event): 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 93ddc7c2f65ec..55d6896d41015 100644 --- a/tests/common.py +++ b/tests/common.py @@ -28,7 +28,8 @@ 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 mock_psc(hass, config_input, domain): .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 fa38a9d378450..0724313dceab0 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 @@ def test_saving_state(self): 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 @@ def event_listener(event): 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 @@ def event_listener(event): 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 setup_recorder(config=None): 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 0000000000000..4990cbc00eb83 --- /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 0000000000000..1a52e0503bb12 --- /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 0000000000000..ad130b1ca91f4 --- /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 d4f1cbcbe9aab..52a229f43c89c 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -5,7 +5,6 @@ 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 @@ def init_recorder(self): """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 65870d1450f29..7324a5e9b322d 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -29,13 +29,12 @@ def init_recorder(self): """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 @@ def test_get_states(self): # 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 @@ def set_state(state): 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 @@ def test_get_significant_states(self): """ 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 @@ def test_get_significant_states_entity_id(self): 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 @@ def check_significant_states(self, zero, four, states, config): \ 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 69497ef8388d1..13735df0a1159 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -138,7 +138,7 @@ def test_exclude_new_entities(self): 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 @@ def test_exclude_removed_entities(self): 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 @@ def test_exclude_events_hidden(self): 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 @@ def test_exclude_events_entity(self): 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 @@ def test_exclude_events_domain(self): 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 @@ def test_exclude_automation_events(self): 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 @@ def test_include_events_entity(self): 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 @@ def test_include_events_domain(self): 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 @@ def test_include_exclude_events(self): 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 3a4c058f8534f..59598823911c8 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -10,6 +10,7 @@ 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 7831162325897..94fc7cc85f07b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -394,8 +394,7 @@ def __init__(self, hass, broker, port, client_id, keepalive, username, 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 @@ def async_publish(self, topic, payload, qos, retain): 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 @@ def async_subscribe(self, topic, qos): 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 0000000000000..3a4ef1384d9c9 --- /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 5b0c020b3161d..9f8ba5d2bcc32 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 9fbb030e96e26..631650077ce40 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 94fc7cc85f07b..e8616e2276162 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -437,6 +437,7 @@ def async_publish(self, topic, payload, qos, retain): 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 @@ def async_subscribe(self, topic, qos): 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 778652872c32c..4cebf12109e38 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -47,9 +47,19 @@ 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 @@ def is_on(self): @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 65defb4557b18..854388203931b 100644 --- a/homeassistant/components/camera/local_file.py +++ b/homeassistant/components/camera/local_file.py @@ -20,7 +20,7 @@ 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 @@ def __init__(self, name, file_path): 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 d43c138c57050..55ddbd107413d 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -8,7 +8,8 @@ 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 setup_platform(): @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 8b25e2a726bfd..502620eb362a4 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -86,7 +86,7 @@ } # 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 4cebf12109e38..645d4b81c8df6 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -376,7 +376,7 @@ def turn_on(self, **kwargs): 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 @@ def turn_off(self, **kwargs): 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 0777396316ac4..69c948bb1e9cc 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -202,7 +202,7 @@ def supported_features(self): 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 @@ def turn_on(self, **kwargs): 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 a395af30cf0bb..86d72baeadadf 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -143,7 +143,7 @@ def wrapper(self, **kwargs): 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 49c69ef348b46..abc05198443a7 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -215,7 +215,7 @@ def async_turn_on(self, **kwargs): 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 @@ def async_turn_off(self, **kwargs): 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 d99db96831513..931b5f68ab3cd 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -269,7 +269,7 @@ def async_turn_on(self, **kwargs): # 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 @@ def async_turn_off(self, **kwargs): # 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 b4c593d83959b..4a4182be89412 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -145,7 +145,7 @@ def turn_on(self, **kwargs): 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 @@ def turn_off(self, **kwargs): _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 5eae4c66bb67b..7e0bd0e253ee5 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -259,7 +259,7 @@ def set_flash(self, flash) -> None: _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 @@ def turn_on(self, **kwargs) -> None: 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 645d4b81c8df6..09444ee57658a 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -379,12 +379,27 @@ def turn_on(self, **kwargs): 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 @@ def turn_on(self, **kwargs): 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 9502849e1d9e5..5a7c3b12e0462 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 e4048cd3cdec4..ada7ccc072e8a 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -39,6 +39,40 @@ def test_color_xy_brightness_to_RGB(self): 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 50bf08b027948..820c53d81eed2 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 0000000000000..77448fd6adc39 --- /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 0000000000000..a04a547f534e0 --- /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 0000000000000..bc201825e83e0 --- /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 9f8ba5d2bcc32..2fe7c9046fafb 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 820c53d81eed2..3f5ba7a35dec8 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 0000000000000..bb9a984c87bac --- /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 2fe7c9046fafb..c13b79529447f 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 ff75f6e73141c..3eeb6a1c8c65d 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ 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 c13b79529447f..b0582587a764e 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 3f5ba7a35dec8..ae71a10f73d33 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 0000000000000..2807dbc2c587c --- /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 c13b79529447f..37022e6efd001 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 6d132f8a1fc28..d1b2bcdab8ec5 100644 --- a/homeassistant/components/light/yeelightsunflower.py +++ b/homeassistant/components/light/yeelightsunflower.py @@ -15,7 +15,7 @@ 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 3cf37eef64424..32f5cb57a8827 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 3eeb6a1c8c65d..7c2c7b744f993 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) - - lights_ids = config.get(CONF_LIGHTS) + # Exclude devices unwanted by user. + devices = [device for device in all_devices + if device.device_id not in exclude_ids] - for device in all_devices: - if device.device_id in exclude: + for device in devices: + device_type = map_vera_device(device, light_ids) + if device_type is None: 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 ae71a10f73d33..4db8323c3a115 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 2ca15898b1878..816b7465f8fb1 100644 --- a/homeassistant/components/sensor/mhz19.py +++ b/homeassistant/components/sensor/mhz19.py @@ -5,25 +5,40 @@ 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 + + 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)) - dev = MHZ19Sensor(config.get(CONF_SERIAL_DEVICE), config.get(CONF_NAME)) - add_devices([dev]) + 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 9704991e9595c..a031f9cbd5647 100644 --- a/homeassistant/components/sensor/serial_pm.py +++ b/homeassistant/components/sensor/serial_pm.py @@ -13,7 +13,7 @@ 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 32f5cb57a8827..26169f838eb37 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 0000000000000..4311493ac97bc --- /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 eb54869d66faf..e436939d036fe 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -187,6 +187,12 @@ def update(self): 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 52a229f43c89c..29d353e09bafe 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -71,13 +71,19 @@ def test_period_parsing(self): 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 @@ def test_measure(self): 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 d76c39bf36a8d..fa424576a116e 100644 --- a/homeassistant/components/remote/itach.py +++ b/homeassistant/components/remote/itach.py @@ -17,7 +17,7 @@ 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 26169f838eb37..6c883ff4f2d0a 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 5221679b6b5c3..7f233d09acc22 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 f4b828a028915..a603cb9c3e30a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -757,18 +757,15 @@ def support_clear_playlist(self): """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 @@ def async_volume_down(self): 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 784c54f6d62d9..6acbf5c2db30d 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -3,6 +3,8 @@ 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 @@ def __init__(self, hass): """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 @@ def async_set_volume_level(self, volume): """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 @@ def __init__(self, hass): """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 @@ def volume_down(self): 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 @@ def test_volume_down(self): 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 @@ def test_volume_down(self): 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 e4de69c3ce859..1aefc11d9c0fb 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -3,8 +3,8 @@ 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 @@ 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 4db8323c3a115..c88856c724efa 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 b8999ee2c43b7..284e8c042da72 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -42,6 +42,7 @@ '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 0000000000000..386a489b646e0 --- /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 6c883ff4f2d0a..9f69acee1b488 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 0000000000000..a2c3223cd9c7e --- /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 3de056753e6bd..422854c482caa 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 | 476 +++++++----------- .../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 | 172 ++++--- .../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 | 25 +- 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, 765 insertions(+), 849 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cb32fc887c90a..b1233594f8998 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -4,38 +4,41 @@ 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,224 +55,163 @@ 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) - - # OrderedSet is empty if component or dependencies could not be resolved - if not components: - _async_persistent_notification(hass, domain, True) - return False + if setup_tasks is None: + setup_tasks = hass.data[DATA_SETUP] = {} - 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 + task = setup_tasks[domain] = hass.async_add_job( + _async_setup_component(hass, domain, config)) - return True + return (yield from task) -def _handle_requirements(hass: core.HomeAssistant, component, - name: str) -> bool: +@asyncio.coroutine +def _async_process_requirements(hass: core.HomeAssistant, name: str, + requirements) -> bool: """Install the requirements for a component. - This method needs to run in an executor. + This method is a coroutine. """ - if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'): + if hass.config.skip_pip: 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 + 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_setup_component(hass: core.HomeAssistant, - domain: str, config) -> bool: - """Setup a component for Home Assistant. +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 - This method is a coroutine. - """ - # pylint: disable=too-many-return-statements - if domain in hass.config.components: - return True + tasks = [async_setup_component(hass, dep, config) for dep + in dependencies] - setup_lock = hass.data.get('setup_lock') - if setup_lock is None: - setup_lock = hass.data['setup_lock'] = asyncio.Lock(loop=hass.loop) + if not tasks: + return True - setup_progress = hass.data.get('setup_progress') - if setup_progress is None: - setup_progress = hass.data['setup_progress'] = [] + results = yield from asyncio.gather(*tasks, loop=hass.loop) - if domain in setup_progress: - _LOGGER.error('Attempt made to setup %s during setup of %s', - domain, domain) - _async_persistent_notification(hass, domain, True) - return False + failed = [dependencies[idx] for idx, res + in enumerate(results) if not res] - 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 + if failed: + _LOGGER.error('Unable to setup dependencies of %s. ' + 'Setup failed for dependencies: %s', + name, ', '.join(failed)) - setup_progress.append(domain) - config = yield from async_prepare_setup_component(hass, config, domain) + return False + return True - if config is None: - return False - component = loader.get_component(domain) - if component is None: - _async_persistent_notification(hass, domain) - return False +@asyncio.coroutine +def _async_setup_component(hass: core.HomeAssistant, + domain: str, config) -> bool: + """Setup a component for Home Assistant. - 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 + This method is a coroutine. - 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: Home Assistant instance. + domain: Domain of component to setup. + config: The Home Assistant configuration. + """ + def log_error(msg): + """Log helper.""" + _LOGGER.error('Setup failed for %s: %s', domain, msg) + async_notify_setup_error(hass, domain, True) - hass.config.components.add(component.DOMAIN) + # Validate no circular dependencies + components = loader.load_order_component(domain) - hass.bus.async_fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: 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 - return True - finally: - setup_progress.remove(domain) - if did_lock: - setup_lock.release() + component = loader.get_component(domain) + processed_config = \ + conf_util.async_process_component_config(hass, config, domain) -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() + 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 -@asyncio.coroutine -def async_prepare_setup_component(hass: core.HomeAssistant, config: dict, - domain: str): - """Prepare setup of a component and return processed config. + if hasattr(component, 'DEPENDENCIES'): + dep_success = yield from _async_process_dependencies( + hass, config, domain, component.DEPENDENCIES) - 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 not dep_success: + log_error('Could not setup all dependencies.') + return False - if missing_deps: - _LOGGER.error( - 'Not initializing %s because not all dependencies loaded: %s', - domain, ", ".join(missing_deps)) - return None + async_comp = hasattr(component, 'async_setup') - 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 + 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 - 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 = yield from async_prepare_setup_platform( - hass, config, domain, p_name) - - if platform is None: - continue - - # 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 - - 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 - - res = yield from hass.loop.run_in_executor( - None, _handle_requirements, hass, component, domain) - if not res: - return None + 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 - return config + hass.config.components.add(component.DOMAIN) + hass.bus.async_fire( + EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} + ) -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,24 +242,21 @@ 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)) - - 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) - return None + if hasattr(platform, 'DEPENDENCIES'): + dep_success = yield from _async_process_dependencies( + hass, config, platform_path, platform.DEPENDENCIES) - res = yield from hass.loop.run_in_executor( - None, _handle_requirements, hass, platform, platform_path) - if not res: - return None + 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 @@ -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) - - for domain in loader.load_order_components(components): - if domain in dependency_blacklist: - raise HomeAssistantError( - '{} is not allowed to be a dependency'.format(domain)) + # stage 1 + for component in components: + if component not in FIRST_INIT_COMPONENT: + continue + hass.async_add_job(async_setup_component(hass, component, config)) - yield from _async_setup_component(hass, domain, config) + yield from hass.async_block_till_done() - 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_stop_async_handler(event): '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 cd5bddbad49a4..248b0124d7784 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 455f60319c63d..b22f50b657551 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -46,7 +46,7 @@ @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 bebace6d8272a..0e734d7214d0a 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -28,8 +28,6 @@ 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 @@ def __init__(self, name, async_attach_triggers, cond_func, async_action, self._enabled = False self._last_triggered = None self._hidden = hidden + self._initial_state = initial_state @property def name(self): @@ -264,6 +263,12 @@ def is_on(self) -> bool: """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 cond_func(variables): 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 279dadf120fce..acc71da3f46d2 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 3dd3f35122749..418a6342172ef 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 af5c64186f6da..c3400150f7416 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 06814d85f8823..d8467a6cbfe77 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 8f11424f54c9e..35666e0ea5528 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 be41fd96556bc..c97ba17b8748b 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 6b00ae240ed34..ed8c84f90dfbd 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 f9a4e8c2f062b..3f50bc799c493 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -44,7 +44,7 @@ # 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 8d52785557b38..fa46ea55e2c88 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -45,7 +45,7 @@ # 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 39939c73d0d5d..c5d87c39086f0 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 12615262b261f..5148ce8b245dd 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 da746270197f0..d4b8ef1698524 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 97ddad74d79b5..6403e0bbc8578 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 170159e1d25c9..e03cb72ea4466 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -4,6 +4,7 @@ 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 @@ ] -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 hue_configuration_callback(data): 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" - ) - - configurator_ids.append(request_id) + 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) + + 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 5aa9765d983ef..c11e25ae1303c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -14,12 +14,11 @@ 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 3463cc01bbc1c..968f666fa72a1 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -78,7 +78,7 @@ @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 43c5c9dd7f08d..bb1a7accd15f5 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 97d210d584a4c..ec4549dfe0ce7 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 7c7d26ce72436..7f8bd83116cbe 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 a9378dd653d49..4040efe3bf436 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 77b804cb49995..3110c2091ad38 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 abc05198443a7..b9fb6c54cb4b5 100755 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -61,7 +61,7 @@ @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 931b5f68ab3cd..2f240ec12a6cd 100755 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -64,7 +64,7 @@ @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 82b7b46b1f86e..4d49186398aea 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 add_new_device(event): 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 add_new_device(event): 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 00540f661508b..43d5788af9b40 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 01b4b32deb2cb..e6fd4e286abc0 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -63,7 +63,7 @@ def async_anthemav_update_callback(message): _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 efab17a61a968..a18bb10e75d0a 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 aea10e3c44d6f..b5f88eb28a4cf 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 7e20338f4ab0d..1abe643240959 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -18,7 +18,6 @@ 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 c7365ea65d914..2081dfe89ab83 100644 --- a/homeassistant/components/scene/homeassistant.py +++ b/homeassistant/components/scene/homeassistant.py @@ -13,7 +13,6 @@ 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 1cca7c8d79046..cf4843353b5fe 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -25,7 +25,6 @@ DOMAIN = "script" ENTITY_ID_FORMAT = DOMAIN + '.{}' GROUP_NAME_ALL_SCRIPTS = 'all scripts' -DEPENDENCIES = ["group"] CONF_SEQUENCE = "sequence" @@ -130,6 +129,7 @@ def toggle_service(service): 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 15cfc200c4d68..e1d6c7751962a 100644 --- a/homeassistant/components/sensor/api_streams.py +++ b/homeassistant/components/sensor/api_streams.py @@ -61,7 +61,7 @@ def remove_logger(event): 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 2807dbc2c587c..67b2e04d15718 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 729b435edbc19..04fe1e9796482 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 20142c13c3b33..1a870114d65d4 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 c1eb57170f4e2..d612ca5cf2698 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 2de5b61306533..71995533b7b25 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 a811d4e691c14..a5ecd029a88a5 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 ad615b5c890fc..432fff6780224 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -59,7 +59,7 @@ @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 a495c4ddb8b6c..21251ab5f3b01 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 eec21e161c1c6..575b2daf674d6 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 add_new_device(event): 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 @@ def _handle_event(self, event): """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 ff2df5ef89395..342724830e3ef 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 aba42519e6088..42481c955106f 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 04bd8a5aa0f23..9182145dc952c 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 bce4895e408bb..7f1e6429ba548 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 f5541f1bef210..047edd0b994e6 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 689ab675b5f5e..a21d98147687b 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 d0f2524e3de75..d94815a1d2e0b 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 cfa11897de90c..74add400850b6 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 737554154c2a9..1abeb3eeadae8 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 f17d95b21b3d8..91ac16fe06c76 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 c18a87710fe14..f05fb2a9ae5e0 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 852151e83f59b..388093ec37a54 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -10,23 +10,27 @@ 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 29c61842c671e..90212e86c3b93 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -122,6 +122,7 @@ def __init__(self, loop=None): 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 @@ def add_job(self, target: Callable[..., None], *args: Any) -> None: 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 @@ def _async_add_job_tracking(self, target: Callable[..., None], 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 db17b8926c132..5615f3a319965 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 ad88045039f95..1b20695b3499e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -3,8 +3,7 @@ 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 @@ def __init__(self, logger, domain, hass, 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 @@ def _async_setup_platform(self, platform_type, platform_config, 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 @@ def async_prepare_reload(self): 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 @@ def __init__(self, component, platform, scan_interval, entity_namespace): 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 @@ def add_entities(self, new_entities, update_before_add=False): 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 @@ def async_add_entities(self, new_entities, update_before_add=False): 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 @@ def async_add_entities(self, new_entities, update_before_add=False): 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 60ba924f46cdb..a24f89c0e3f54 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 154754c667a77..38138c878831d 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -26,8 +26,8 @@ '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 mock_secrets(ldr, node): 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 55d6896d41015..a1635e3387c61 100644 --- a/tests/common.py +++ b/tests/common.py @@ -12,8 +12,8 @@ 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 mock_psc(hass, config_input, domain): 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 f1bbb71184868..2fe9e05d9d508 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -30,7 +30,6 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_fail_setup_without_state_topic(self): 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 @@ def test_fail_setup_without_command_topic(self): 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 @@ def test_update_state_via_state_topic(self): 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 @@ def test_ignore_update_state_if_unknown_via_state_topic(self): 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 @@ def test_arm_home_publishes_mqtt(self): 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 @@ def test_arm_home_not_publishes_mqtt_with_invalid_code(self): 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 @@ def test_arm_away_publishes_mqtt(self): 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 @@ def test_arm_away_not_publishes_mqtt_with_invalid_code(self): 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 @@ def test_disarm_publishes_mqtt(self): 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 18e112fc49862..c032c72446ab6 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -5,7 +5,7 @@ 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 ca8eef4fc0d3d..fa7658f340759 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -11,7 +11,7 @@ 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 a2746728fe369..df8baced09096 100644 --- a/tests/components/automation/test_mqtt.py +++ b/tests/components/automation/test_mqtt.py @@ -5,7 +5,8 @@ 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 85842ccf5eb06..8862303da5f92 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -5,7 +5,7 @@ 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 00048f1f577db..f375aec466623 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -10,7 +10,8 @@ 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 bad592a740a6b..47bbf6b680c8c 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -10,7 +10,8 @@ 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 1971fb26d3129..8bdf9f8f439a7 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -5,7 +5,8 @@ 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 8f323dd4b3712..6a76bb887b858 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -9,7 +9,8 @@ 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 f2b304070b4f0..ea216b12a260d 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -5,7 +5,7 @@ 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 f9630ae4b2595..1b756f72f6179 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -3,10 +3,10 @@ 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 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_setting_sensor_value_via_mqtt_message(self): 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 @@ def test_valid_device_class(self): 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 c5b8b6a9f7821..cd11321baa452 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -9,7 +9,7 @@ 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 5fad8e16aeda9..846ecdc320fc7 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 @@ def test_default_setup_params(self): 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 @@ def log_call(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.""" + 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 fa5629e88c4da..b9c2a1739c517 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 07baec9e3ae2b..1c37683969b46 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -8,7 +8,7 @@ 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 81518458e0ec1..1d670d81b6ed4 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -4,9 +4,9 @@ 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 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_state_via_state_topic(self): 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 @@ def test_state_via_template(self): 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 @@ def test_optimistic_state_change(self): 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 @@ def test_send_open_cover_command(self): 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 @@ def test_send_close_cover_command(self): 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 @@ def test_send_stop__cover_command(self): 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 18e2051afd606..2d11e03cb4178 100644 --- a/tests/components/cover/test_rfxtrx.py +++ b/tests/components/cover/test_rfxtrx.py @@ -6,7 +6,7 @@ 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 48160cbb3d531..406087b7b996d 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -17,7 +17,8 @@ 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 340bac254b159..a0433b04d0152 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -16,7 +16,8 @@ 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 @@ def run(self, result=None): 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 3ce3a358b876e..583b9b86383f9 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -43,7 +43,6 @@ def mock_setup_scanner(hass, config, see, discovery_info=None): 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 010c597cc318f..7a1e14a7dfcf0 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -13,7 +13,8 @@ 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 5fa37012c7af5..36f434664d71c 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 f8b46579187b5..391a6d0590328 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -8,7 +8,7 @@ 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 @@ def test_turn_off(self): @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 4f0d4a273b6d2..410f947178c6c 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -100,7 +100,6 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_fail_setup_if_no_command_topic(self): 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 @@ def test_controlling_state_via_topic(self): \ '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 @@ def test_controlling_state_via_topic(self): \ 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 @@ def test_controlling_state_via_topic_with_templates(self): \ '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 @@ def test_sending_mqtt_commands_and_optimistic(self): \ '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 @@ def test_show_brightness_if_only_command_topic(self): '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 @@ def test_show_color_temp_only_if_command_topic(self): '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 4f48181a9176a..55c437cdc79b9 100755 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -53,7 +53,6 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_fail_setup_if_no_command_topic(self): \ 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 @@ def test_no_color_or_brightness_if_no_config(self): \ 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 @@ def test_controlling_state_via_topic(self): \ 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 @@ def test_sending_mqtt_commands_and_optimistic(self): \ 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 @@ def test_flash_short_and_long(self): \ 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 @@ def test_transition(self): 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 e097aba92a9cd..020ded1bd807a 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -45,7 +45,6 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_setup_fails(self): \ 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 @@ def test_state_change_via_topic(self): \ 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 @@ def test_state_brightness_color_effect_change_via_topic(self): \ 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 @@ def test_optimistic(self): \ 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 @@ def test_flash(self): \ 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 @@ def test_transition(self): 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 ca50a9cc92524..135e51380cd04 100644 --- a/tests/components/light/test_rfxtrx.py +++ b/tests/components/light/test_rfxtrx.py @@ -6,7 +6,7 @@ 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 c858d58dfa713..14714e9a3d190 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -23,7 +23,6 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_controlling_state_via_topic(self): 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 @@ def test_sending_mqtt_commands_and_optimistic(self): 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 1ca0846b1fdea..3ccfcd7eb64b9 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 @@ def test_platform_setup(self): 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 cfef8ebcc163e..db9e963d84cec 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -4,7 +4,8 @@ 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 @@ def test_creating_config_with_http_pass(self, mock_mqtt): 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 de13f678ae06f..43c5e78c5dacf 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 @@ } -@asyncio.coroutine -def mock_setup_platform(): - """Mock prepare_setup_platform.""" - return None - - class TestNotifyDemo(unittest.TestCase): """Test the demo notify.""" @@ -52,15 +45,6 @@ def test_setup(self): """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 771aa999210ec..1de9d2f731a6f 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -5,7 +5,7 @@ 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 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_setting_sensor_value_via_mqtt_message(self): 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 2bade2af1a3de..35b6924a35a8f 100644 --- a/tests/components/sensor/test_pilight.py +++ b/tests/components/sensor/test_pilight.py @@ -5,7 +5,8 @@ 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 092a9b60f8532..96b5623b7b1b1 100644 --- a/tests/components/sensor/test_rfxtrx.py +++ b/tests/components/sensor/test_rfxtrx.py @@ -7,7 +7,7 @@ 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 3a5502c815020..33de6de52a9ac 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -22,7 +22,6 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_controlling_state_via_topic(self): 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 @@ def test_sending_mqtt_commands_and_optimistic(self): 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 26af42be4a960..b4eb1259515fb 100644 --- a/tests/components/switch/test_rfxtrx.py +++ b/tests/components/switch/test_rfxtrx.py @@ -6,7 +6,7 @@ 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 c22c431ed037e..62b9f681703d9 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -4,7 +4,7 @@ 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 b07c62e441f36..46de75bf8bdc6 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -39,6 +39,7 @@ def test_webcomponent_in_panels_dir(self, mock_register, _mock_setup): 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 @@ def test_webcomponent_custom_path(self, mock_register, _mock_setup): ) 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 7e47dfb6a5005..a1041777ebc4c 100644 --- a/tests/components/test_rfxtrx.py +++ b/tests/components/test_rfxtrx.py @@ -50,8 +50,8 @@ def test_valid_config(self): '-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 4e8d94ade2197..14aa75eb9635a 100644 --- a/tests/components/test_script.py +++ b/tests/components/test_script.py @@ -6,7 +6,7 @@ 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 4eefe8c00318d..b0d4f06688dee 100644 --- a/tests/components/test_zone.py +++ b/tests/components/test_zone.py @@ -63,11 +63,12 @@ def test_active_zone_skips_passive_zones(self): }, ] }) - + 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 @@ def test_active_zone_skips_passive_zones(self): }, ] }) - + 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 @@ def test_active_zone_prefers_smaller_zone_if_same_distance(self): 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 b2f60cd0a2ee7..5e3f9cd8c88bf 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.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 @@ def setup_platform(hass, config, add_devices_callback, }], }) - # 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 @@ def test_1st_discovers_2nd_component(self, mock_signal): 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 @@ def component2_setup(hass, config): '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 d5ae60cc18ee6..d95ec3a87f822 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -272,6 +272,7 @@ def test_setup_loads_platforms(self): } }) + self.hass.block_till_done() assert component_setup.called assert platform_setup.called @@ -294,6 +295,7 @@ def test_setup_recovers_when_setup_raises(self): ("{} 3".format(DOMAIN), {'platform': 'mod2'}), ])) + self.hass.block_till_done() assert platform1_setup.called assert platform2_setup.called @@ -336,6 +338,7 @@ def platform_setup(hass, config, add_devices, discovery_info=None): } }) + self.hass.block_till_done() assert mock_track.called assert timedelta(seconds=30) == mock_track.call_args[0][2] @@ -360,6 +363,7 @@ def platform_setup(hass, config, add_devices, discovery_info=None): } }) + self.hass.block_till_done() assert mock_track.called assert timedelta(seconds=30) == mock_track.call_args[0][2] @@ -385,6 +389,8 @@ def platform_setup(hass, config, add_devices, discovery_info=None): } }) + 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 59598823911c8..f46f33c333fcb 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -13,13 +13,14 @@ 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 410f1636a8805..173cea1957acc 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -7,12 +7,10 @@ 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 @@ def test_from_config_file(self, mock_upgrade, mock_detect, mock_signal): 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 @@ def test_validate_component_config(self): 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 @@ def test_validate_component_config(self): } }) + 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 @@ def test_validate_platform_config(self): } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(1): @@ -167,6 +159,7 @@ def test_validate_platform_config(self): } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(0): @@ -177,6 +170,7 @@ def test_validate_platform_config(self): } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(1): @@ -187,6 +181,7 @@ def test_validate_platform_config(self): } }) + self.hass.data.pop(bootstrap.DATA_SETUP) self.hass.config.components.remove('platform_conf') with assert_setup_component(1): @@ -197,6 +192,7 @@ def test_validate_platform_config(self): }] }) + 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 @@ def test_component_not_installed_if_requirement_fails(self, mock_install): 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 @@ def test_component_not_setup_missing_dependencies(self): 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 @@ def test_platform_specific_config_validation(self): }) 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 @@ def test_platform_specific_config_validation(self): }) 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 @@ def test_disable_component_if_invalid_return(self): 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 @@ def test_disable_component_if_invalid_return(self): 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 @@ def track_start(event): 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 93e24b5720544..0b3f9653faae9 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -54,33 +54,3 @@ def test_load_order_component(self): # 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 67e19f225d52b..93e747cd16f43 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -68,10 +68,10 @@ '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 284e8c042da72..a3444958e6268 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -15,7 +15,7 @@ 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 9f69acee1b488..a66163b40d165 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 b1233594f8998..db9f4600261db 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 388093ec37a54..3968ea571c5c5 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 38138c878831d..eac0df8bc9016 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,6 +30,8 @@ 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 mock_setup(*kwargs): 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 mock_package_error( # pylint: disable=unused-variable 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 23dde3a824496..63812e1c593e9 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -85,6 +85,7 @@ def test_config_component_platform_fail_validation(self): 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 @@ def test_config_component_platform_fail_validation(self): 'light': []}, res['components'] ) + res['except'].pop(check_config.ERROR_STR) self.assertDictEqual( {'light.mqtt_json': {'platform': 'mqtt_json'}}, res['except'] @@ -138,10 +140,12 @@ def test_component_platform_not_found(self): 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 db9f4600261db..c0ed6db11f70b 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 log_error(msg): # 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 63812e1c593e9..250a8ccc23a61 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -112,7 +112,6 @@ def test_config_component_platform_fail_validation(self): 'light': []}, res['components'] ) - res['except'].pop(check_config.ERROR_STR) self.assertDictEqual( {'light.mqtt_json': {'platform': 'mqtt_json'}}, res['except'] @@ -131,9 +130,11 @@ def test_component_platform_not_found(self): 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 @@ def test_component_platform_not_found(self): 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 b403c191925d3..f952145f822d1 100644 --- a/homeassistant/components/telegram_webhooks.py +++ b/homeassistant/components/telegram_webhooks.py @@ -24,7 +24,8 @@ _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 @@ ] ATTR_COMMAND = 'command' +ATTR_TEXT = 'text' ATTR_USER_ID = 'user_id' ATTR_ARGS = 'args' @@ -118,15 +120,24 @@ def post(self, request): 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 a4a933fe778d6..7b7454d0a2832 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -110,7 +110,7 @@ def update_ble(now): _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 1de0629c7c5aa..f71f8c4271a58 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -82,7 +82,8 @@ def update_bluetooth(now): 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 2af400ba89cba..04537dd6e4d0a 100644 --- a/homeassistant/components/device_tracker/ping.py +++ b/homeassistant/components/device_tracker/ping.py @@ -86,7 +86,7 @@ def update(now): """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 78d4eadc7abdb..eb2957d7b4af5 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -98,9 +98,8 @@ def update(self, now): 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 @@ def identify_device(device): 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 52fe6f69c93a9..05627e8fb5311 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -109,7 +109,7 @@ def update(now): 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 | 184 ++++++++++++++------------ 5 files changed, 153 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index a3444958e6268..4ef4317e22e50 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -6,20 +6,23 @@ 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 @@ 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 + + component, platform = comp_plat - # We do not know how to handle this service. - if not comp_plat: - return + if platform is None: + yield from async_discover(hass, service, info, component, config) + else: + yield from async_load_platform( + hass, component, platform, info, config) - component, platform = comp_plat + @asyncio.coroutine + def scan_devices(_): + """Scan for devices.""" + results = yield from hass.loop.run_in_executor( + None, _discover, netdisco) - if platform is None: - discover(hass, service, info, component, config) - else: - load_platform(hass, component, platform, info, config) + for result in results: + hass.async_add_job(new_service_found(*result)) - # pylint: disable=unused-argument - def start_discovery(event): - """Start discovering.""" - netdisco = DiscoveryService(SCAN_INTERVAL) - netdisco.add_listener(new_service_listener) - netdisco.start() + async_track_point_in_utc_time(hass, scan_devices, + dt_util.utcnow() + SCAN_INTERVAL) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery) + 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 b7d6f04c440f0..7b966e250228f 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -13,7 +13,7 @@ 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 bc201825e83e0..c0c9bd166749b 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -10,7 +10,7 @@ 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 ba0594b00d885..199005803d86a 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 f4bf307df6fc3..bc2be3ed46377 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 @@ } -@patch('netdisco.service.DiscoveryService') -@patch('homeassistant.components.discovery.load_platform') -@patch('homeassistant.components.discovery.discover') -class DiscoveryTest(unittest.TestCase): - """Test the discovery component.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.netdisco = mock.Mock() - - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def setup_discovery_component(self, discovery_service, config): - """Setup the discovery component with mocked netdisco.""" - discovery_service.return_value = self.netdisco - - setup_component(self.hass, discovery.DOMAIN, config) - - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - self.hass.block_till_done() - - def discover_service(self, discovery_service, name): - """Simulate that netdisco discovered a new service.""" - self.assertTrue(self.netdisco.add_listener.called) - - # Extract a refernce to the service listener - args, _ = self.netdisco.add_listener.call_args - listener = args[0] - - # Call the listener (just like netdisco does) - listener(name, SERVICE_INFO) - - 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) - - self.assertFalse(load_platform.called) - self.assertFalse(discover.called) - - 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) - - load_platform.assert_called_with(self.hass, - SERVICE_COMPONENT, - SERVICE, - SERVICE_INFO, - 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) - - 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) - - self.discover_service(discovery_service, SERVICE_NO_PLATFORM) - self.assertFalse(discover.called) - - self.discover_service(discovery_service, SERVICE) - self.assertTrue(load_platform.called) +@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 discover(netdisco): + """Fake discovery.""" + return [('this_service_will_never_be_supported', {'info': 'some'})] + + 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 not mock_discover.called + assert not mock_platform.called + + +@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(netdisco): + """Fake discovery.""" + return [(SERVICE, 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 not mock_discover.called + assert mock_platform.called + mock_platform.assert_called_with( + hass, SERVICE_COMPONENT, SERVICE, SERVICE_INFO, BASE_CONFIG) + + +@asyncio.coroutine +def test_load_component(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)] + + 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 not mock_platform.called + mock_discover.assert_called_with( + 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 discover(netdisco): + """Fake discovery.""" + return [(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 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 2de101af24bbb..864699a2fbdc9 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 2eb508304d478..935d4b4bb3fba 100644 --- a/homeassistant/components/binary_sensor/zigbee.py +++ b/homeassistant/components/binary_sensor/zigbee.py @@ -24,7 +24,8 @@ 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 42ae64a2b1f05..f3e8d5480a8ac 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 @@ def __init__(self, hass, config): """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 66ef19a5b991c..817e7e432db09 100644 --- a/homeassistant/components/zigbee.py +++ b/homeassistant/components/zigbee.py @@ -4,10 +4,9 @@ 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 @@ 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 @@ 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 _frame_received(frame): 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 @@ def __init__(self, hass, config): 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 @@ def handle_frame(frame): # 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 @@ def _set_state(self, state): 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 @@ def __init__(self, hass, config): 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 @@ def handle_frame(frame): ADC_PERCENTAGE, self._config.max_voltage ) - self.update_ha_state() - - subscribe(hass, handle_frame) + self.schedule_update_ha_state() - # 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 4ef4317e22e50..ac68cfaf36799 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -18,7 +18,7 @@ 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 199005803d86a..bdf98c7edcc60 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 8182c8ccf39f2..639dfa01ec685 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -16,8 +16,7 @@ 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 @@ def send_sms(service): 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 @@ def __init__(self, dovado, sensor): """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 @@ def name(self): @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 d9c8584a5e990..f943896227433 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -186,12 +186,12 @@ def sub_callback(topic, callback, qos): 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 mysensors_callback(gateway, node_id): _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 mysensors_callback(gateway, node_id): 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 60aa2aebd3592..21388c292eb6a 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -13,7 +13,7 @@ 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 bdf98c7edcc60..3aea86fce9337 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 51a9226e1b01d..7eb9cdc3051cd 100644 --- a/homeassistant/components/sensor/cpuspeed.py +++ b/homeassistant/components/sensor/cpuspeed.py @@ -13,7 +13,7 @@ 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 3aea86fce9337..180dc77112d37 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 42481c955106f..51a7bc82a8513 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -13,11 +13,12 @@ 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 @@ def __init__(self, hass, device_id, friendly_name, unit_of_measurement, 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)) + + @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)) - async_track_state_change( - hass, entity_ids, template_sensor_state_listener) + 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 0f5e863f3289d..7ba4ca136e08f 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 @@ def test_template(self): } }) + self.hass.start() + state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'It .' @@ -60,6 +67,8 @@ def test_icon_template(self): } }) + self.hass.start() + state = self.hass.states.get('sensor.test_template_sensor') assert 'icon' not in state.attributes @@ -82,6 +91,8 @@ def test_template_syntax_error(self): } } }) + + self.hass.start() assert self.hass.states.all() == [] def test_template_attribute_missing(self): @@ -99,6 +110,8 @@ def test_template_attribute_missing(self): } }) + self.hass.start() + state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'unknown' @@ -116,6 +129,8 @@ def test_invalid_name_does_not_create(self): } } }) + + self.hass.start() assert self.hass.states.all() == [] def test_invalid_sensor_does_not_create(self): @@ -129,6 +144,8 @@ def test_invalid_sensor_does_not_create(self): } } }) + + self.hass.start() assert self.hass.states.all() == [] def test_no_sensors_does_not_create(self): @@ -139,6 +156,8 @@ def test_no_sensors_does_not_create(self): 'platform': 'template' } }) + + self.hass.start() assert self.hass.states.all() == [] def test_missing_template_does_not_create(self): @@ -155,4 +174,39 @@ def test_missing_template_does_not_create(self): } } }) + + 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 ad6c89bcea199..e4c586965a69e 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -246,3 +246,8 @@ def device_state_attributes(self): 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 aa2cdf858fd62..46f23a68515d0 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -41,6 +41,7 @@ def __init__(self, value): 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 @@ def __init__(self, value): _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 84aebffab0e05..7e23a68b88703 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -107,7 +107,7 @@ def update_properties(self): # 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 @@ def _refresh_value(): 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 @@ def __init__(self, value, refresh, delay): 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 ba1df32130d25..cfafe955e2c6d 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -292,3 +292,8 @@ def device_state_attributes(self): 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 f05fb2a9ae5e0..dacc7549c5854 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -708,6 +708,10 @@ def __init__(self, value, domain): 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 @@ def __init__(self, value, domain): 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() + + 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, value): + 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 @@ def value_changed(self, value): 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 @@ def _value_handler(self, method=None, class_id=None, index=None, 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 @@ def _value_handler(self, method=None, class_id=None, index=None, 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 7c67baa2ce4af..21d5bd04adc28 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 0000000000000..8e520952db918 --- /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 69414cb92000c..da5d48c6f1880 --- 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 e69de29bb2d1d..0000000000000 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 0e10199134c4b..1b4b6c6315615 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -19,7 +19,7 @@ # 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 180dc77112d37..4cc886d90cbaa 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 08a3eec17e82e..a33f331b737af 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -123,7 +123,10 @@ def should_poll(self): @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 3387baf76d898..7b65ed4f0775a 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -6,10 +6,15 @@ """ 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 @@ _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 @@ def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: 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 @@ def update(self): 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 @@ def target_temperature(self): @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 @@ def set_temperature(self, **kwargs): 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 @@ def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: 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 13d7eb65257aa..a4cdda2adc47f 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -8,6 +8,9 @@ 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 @@ def test_setup_us(self, mock_ht, mock_sc): 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 @@ def test_setup_us(self, mock_ht, mock_sc): 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 @@ def setup_method(self, method): """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 @@ def test_set_temp(self): 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 @@ def test_attributes(self): """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 @@ def test_with_no_fan(self): 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 35666e0ea5528..fbdfa2eb4ded9 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -15,12 +15,14 @@ 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 @@ def __init__(self, hass, device, friendly_name, device_class, 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)) + + @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)) - async_track_state_change( - hass, entity_ids, template_bsensor_state_listener) + 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 cb52685171019..77818c339e258 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 @@ def teardown_method(self, method): """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 @@ def test_attributes(self): 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() + + 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() - 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 == 'on' @mock.patch('homeassistant.helpers.template.Template.render') def test_update_template_error(self, mock_render): @@ -143,3 +162,38 @@ def test_update_template_error(self, mock_render): 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 18ccff459b036..c9403fbf2ed47 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -25,6 +25,8 @@ ATTR_RESUME_ALL = 'resume_all' DEFAULT_RESUME_ALL = False +TEMPERATURE_HOLD = 'temp' +VACATION_HOLD = 'vacation' DEPENDENCIES = ['ecobee'] @@ -112,6 +114,8 @@ def __init__(self, data, thermostat_index, hold_temp): 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 @@ def fan(self): 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 @@ def operation_mode(self): @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 @@ def device_state_attributes(self): "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 @@ def hold_preference(self): # 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 5a7c3b12e0462..52d7a9f63aae9 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 391a6d0590328..de89d434e899b 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -39,7 +39,7 @@ def test_state_attributes(self): 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 ada7ccc072e8a..d7560d4f7bf2a 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -27,16 +27,16 @@ def test_color_xy_brightness_to_RGB(self): 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 @@ def test_color_xy_brightness_to_hsv(self): 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 c88856c724efa..cbe868954b4e2 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 a1635e3387c61..34cd976569564 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,6 +10,7 @@ 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 @@ 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.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 clear_instance(event): 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 866fea0df0bb4..0a6d487c33193 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -27,8 +27,10 @@ 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 @@ def is_on(self): """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 | 86 +++++++++++++------ requirements_all.txt | 2 +- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 566ad7d69335e..ad0adfb008a0e 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -8,7 +8,6 @@ import logging import hashlib -import aiohttp import voluptuous as vol from homeassistant.core import callback @@ -19,13 +18,13 @@ 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 @@ def __init__(self, atv, name, is_off): 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 @@ def state(self): else: return STATE_STANDBY # Bad or unknown state? - @asyncio.coroutine - def async_update(self): - """Retrieve latest state.""" - if self._is_off: - return - - 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') + @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() + + 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 @@ def _has_playing_media_changed(self, new_playing): 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 @@ def async_play_media(self, media_type, media_id, **kwargs): @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 @@ def media_title(self): 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 4cc886d90cbaa..e1591ceb40c41 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 4c75db36accb8..62c9f9f65964f 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 1aefc11d9c0fb..70477198ea01d 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -155,7 +155,7 @@ def _get_date(date): 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 10a335ff7a286..e72eca9e7fa8b 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 248b0124d7784..25f9257f3937e 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -94,10 +94,13 @@ def __init__(self, hass, partition_number, alarm_name, code, panic_type, _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 acc71da3f46d2..22a3256f9fef5 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -52,8 +52,11 @@ def __init__(self, hass, zone_number, zone_name, zone_type, info, _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 1a870114d65d4..9803f675913a6 100644 --- a/homeassistant/components/sensor/envisalink.py +++ b/homeassistant/components/sensor/envisalink.py @@ -49,10 +49,13 @@ def __init__(self, hass, partition_name, partition_number, info, _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 dacc7549c5854..bd6394867c2c1 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -156,7 +156,7 @@ 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 rename_node(service): _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 start_zwave(_service_or_event): 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 start_zwave(_service_or_event): 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 881f20cd0fc2d..52ccdc0a752da 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -28,6 +28,8 @@ 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 852146421e93e..08cd8069d83f6 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 fbdfa2eb4ded9..396f591923b80 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -16,7 +16,7 @@ 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 @@ 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 + 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 91ac16fe06c76..4ea2d82388d50 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -14,12 +14,13 @@ 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 @@ def __init__(self, hass, device_id, friendly_name, state_template, 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)) + + @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)) - async_track_state_change( - hass, entity_ids, template_switch_state_listener) + 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 7ba4ca136e08f..adfdc08d510cf 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -39,6 +39,7 @@ def test_template(self): }) 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 @@ def test_icon_template(self): }) 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 @@ def test_template_syntax_error(self): }) self.hass.start() + self.hass.block_till_done() assert self.hass.states.all() == [] def test_template_attribute_missing(self): @@ -111,6 +114,7 @@ def test_template_attribute_missing(self): }) 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 @@ def test_invalid_name_does_not_create(self): }) 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 @@ def test_invalid_sensor_does_not_create(self): }) self.hass.start() + assert self.hass.states.all() == [] def test_no_sensors_does_not_create(self): @@ -158,6 +165,8 @@ def test_no_sensors_does_not_create(self): }) 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 @@ def test_missing_template_does_not_create(self): }) 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 2f67564e6e85e..dabdaa2b4d702 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 @@ def test_template_state_text(self): } }) + 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 @@ def test_template_state_boolean_on(self): } }) + 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 @@ def test_template_state_boolean_off(self): } }) + 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 @@ def test_template_syntax_error(self): } } }) + + 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 @@ def test_invalid_name_does_not_create(self): } } }) + + 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 @@ def test_invalid_switch_does_not_create(self): } } }) + + 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 @@ def test_no_switches_does_not_create(self): '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 @@ def test_missing_template_does_not_create(self): } } }) + + 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 @@ def test_missing_on_does_not_create(self): } } }) + + 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 @@ def test_missing_off_does_not_create(self): } } }) + + self.hass.start() + self.hass.block_till_done() + assert self.hass.states.all() == [] def test_on_action(self): @@ -282,6 +321,10 @@ def test_on_action(self): } } }) + + 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 @@ def test_off_action(self): } } }) + + 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 @@ def test_off_action(self): 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 86d72baeadadf..23d0716e0b4e7 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -17,7 +17,7 @@ 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 e1591ceb40c41..4bf689d904c31 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 c5427e7b8baae..8a0289306a812 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -4,6 +4,8 @@ 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 @@ REQUIREMENTS = ['steamodd==4.21'] +_LOGGER = logging.getLogger(__name__) + CONF_ACCOUNTS = 'accounts' ICON = 'mdi:steam' @@ -46,7 +50,7 @@ def __init__(self, account, steamod): @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 @@ def state(self): # 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 @@ def device_state_attributes(self): @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 4cbaa557517dc..6e8b07e6babf9 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -19,7 +19,7 @@ _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 6a991f2889842..b72398c3736c5 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ 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 4bf689d904c31..23b2961086583 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 ad0adfb008a0e..436730b704137 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -44,8 +44,7 @@ @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 on_hass_stop(event): 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 5631f7e5da7aa..071b50688840b 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -60,8 +60,7 @@ @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 d2453a97d1497..9e4faaf3d78ea 100644 --- a/homeassistant/components/input_slider.py +++ b/homeassistant/components/input_slider.py @@ -14,6 +14,7 @@ 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 @@ def state_attributes(self): 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 b927ec48a25f1..bc8921d000a88 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 @@ def test_select_value(self): 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 3a87eeb5cebb5..5a3077350bcec 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -41,6 +41,8 @@ '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 8d55615f66142..4eabf1d071b40 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 @@ def is_safe_callable(self, obj): 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 b213487964015..18656acac5119 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -215,6 +215,21 @@ def test_timestamp_utc(self): 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 c60b95d1cae59..907ae8ba51bb7 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -153,8 +153,8 @@ def async_initialize(self): 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 @@ def run(self): 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 0724313dceab0..c43caefb67cda 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 cbe868954b4e2..8ce3817d93299 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 374e77b95079f..f917d5cdab3e3 100644 --- a/homeassistant/components/notify/twilio_call.py +++ b/homeassistant/components/notify/twilio_call.py @@ -9,21 +9,18 @@ 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 @@ 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 ab3ac89e6b21e..1bdfcb644073b 100644 --- a/homeassistant/components/notify/twilio_sms.py +++ b/homeassistant/components/notify/twilio_sms.py @@ -8,21 +8,18 @@ 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 @@ 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 0000000000000..e4b36d41e145d --- /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 23b2961086583..e06957b6ece4c 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 0d10a470b07af..0c4d61d86d2f0 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -18,6 +18,8 @@ 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 bd6394867c2c1..ba7a0f0f03394 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -70,7 +70,8 @@ [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 | 210 ++++++++++-------- .../device_tracker/test_owntracks.py | 6 +- 2 files changed, 121 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index c03041b63170a..f4737fd26da40 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -4,14 +4,15 @@ 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.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_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 @@ }) +@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 decrypt(ciphertext, key): 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 decrypt_payload(topic, ciphertext): 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 validate_payload(topic, payload, data_type): 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 owntracks_location_update(topic, payload, qos): 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 owntracks_event_update(topic, payload, qos): 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) - - see(**kwargs) - see_beacons(dev_id, kwargs) + 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) + + 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 - - 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) - - 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) - - beacons = MOBILE_BEACONS_ACTIVE[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) + 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) + 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: + 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) if data['event'] == 'enter': enter_event() @@ -271,7 +276,8 @@ def leave_event(): 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 owntracks_waypoint_update(topic, payload, qos): 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 4bea0d3d0b34c..31f9a6b96a079 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 @@ 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 @@ def test_waypoint_import_blacklist(self): 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 @@ def mock_see(**kwargs): 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 e8616e2276162..6bfdde813c143 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -482,17 +482,17 @@ def async_subscribe(self, topic, qos): 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 a93263fada9ea..1f7fa9c1b847f 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -4,11 +4,13 @@ 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 @@ }) -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 f4737fd26da40..156e9d6a08aef 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -55,7 +55,6 @@ }) -@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 decrypt_payload(topic, ciphertext): 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_owntracks_event_update(topic, payload, qos): 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 enter_event(): 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_see_beacons(dev_id, kwargs_param): 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 834efa1b41509..0bf68e68b0d03 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -4,11 +4,13 @@ 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 @@ 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 sensor_event_received(topic, payload, qos): 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 sensor_event_received(topic, payload, qos): 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 583b9b86383f9..08aab93a5a5ea 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 @@ def tearDown(self): # pylint: disable=invalid-name 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 ba7a0f0f03394..30cf16fc9e527 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -26,6 +26,7 @@ from . import const from . import workaround +from .util import value_handler REQUIREMENTS = ['pydispatcher==2.0.5'] @@ -729,8 +730,11 @@ def __init__(self, value, domain): 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 @@ def _update_attributes(self): 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 @@ def should_poll(self): @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 0000000000000..3ce99dbf1ab7d --- /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 453d32bd673c6..11e1f17c2bad9 100644 --- a/homeassistant/components/sensor/waqi.py +++ b/homeassistant/components/sensor/waqi.py @@ -16,7 +16,7 @@ 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 @@ def update(self): """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 e06957b6ece4c..1148aac03d825 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 e14d4149ffe62..135d9a1e02857 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -15,9 +15,10 @@ 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 @@ def __init__(self, hass, url, port, name, username, password): 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 @@ def __init__(self, hass, sensor, cam, delay): 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 @@ def device_state_attributes(self): 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 1148aac03d825..825a03373cff8 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 ac68cfaf36799..421ba321c8d51 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -7,6 +7,7 @@ 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 new_service_found(service, info): 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 bc2be3ed46377..abffc3b17cdec 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -126,3 +126,30 @@ def discover(netdisco): 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 8ce3817d93299..6787c56d63936 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 0000000000000..30948fada8fd1 --- /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 e6c4b3bad96cd..fa8aa72cef344 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -35,18 +35,21 @@ def __init__(self, hass, token): """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 fa8aa72cef344..07b595b3945e4 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 @@ def __init__(self, hass, token): """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 07b595b3945e4..8647ea8792ef5 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 @@ def async_send_message(self, message, **kwargs): @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 8647ea8792ef5..7e1a83cdcb406 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -45,7 +45,7 @@ def async_send_message(self, message, **kwargs): @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 8e19a60bba854..7ec2b17af2dc7 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -21,13 +21,14 @@ 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 @@ 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 @@ def __init__(self, hydroquebec_data, sensor_type, name): self.hydroquebec_data = hydroquebec_data self._state = None - self.update() - @property def name(self): """Return the name of the sensor.""" @@ -153,22 +155,34 @@ def update(self): 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 825a03373cff8..50184c397db3a 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 30cf16fc9e527..5e651f692134a 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -23,6 +23,8 @@ 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 @@ 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 @@ 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 change_association(service): "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 start_zwave(_service_or_event): 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 @@ def dependent_value_ids(self): """ 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 @@ def device_state_attributes(self): 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 52ccdc0a752da..ab4c7604dc4f7 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -34,6 +34,8 @@ 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 08cd8069d83f6..00881602dc0a0 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 d4b8ef1698524..4fc667a53261b 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -16,7 +16,8 @@ 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_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 @@ 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 @@ def __init__(self, hass, name, heater_entity_id, sensor_entity_id, 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 @@ def __init__(self, hass, name, heater_entity_id, sensor_entity_id, 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 @@ def _async_switch_changed(self, entity_id, old_state, new_state): 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 846ecdc320fc7..d4a5b3d21bbac 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 @@ def log_call(call): 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 e6efc1869affb..57ad4d34f1a01 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -91,5 +91,5 @@ def update(self): 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 f64719a6529b1..f0f719fd15fd2 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -18,7 +18,7 @@ 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 50184c397db3a..a18aa2485d5f5 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 b66966da5e753..f2238e43876b2 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 | 42 +++++++-------- 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, 128 insertions(+), 112 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c0ed6db11f70b..3b53010e3e325 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 log_error(msg, link=True): 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 c89148ebc1531..be01f63e657d9 100644 --- a/homeassistant/components/binary_sensor/enocean.py +++ b/homeassistant/components/binary_sensor/enocean.py @@ -67,7 +67,7 @@ def value_changed(self, value, value2): 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 e4c586965a69e..660eb76098d34 100755 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -216,7 +216,7 @@ def set_temperature(self, **kwargs): 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 cbe7c7166e7e0..171c78a2fc86c 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -231,7 +231,7 @@ def __init__(self, node) -> None: # 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 e24aca4902ddf..844cba1e6318f 100644 --- a/homeassistant/components/light/enocean.py +++ b/homeassistant/components/light/enocean.py @@ -106,4 +106,4 @@ def value_changed(self, val): """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 69c948bb1e9cc..039e22e73df03 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -98,7 +98,7 @@ def on_device(self, ipaddr, name, power, hue, sat, bri, kel): 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 @@ def on_color(self, ipaddr, hue, sat, bri, kel): 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 @@ def on_power(self, ipaddr, power): 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 7445977c4f38c..532dc67562fbb 100644 --- a/homeassistant/components/light/scsgate.py +++ b/homeassistant/components/light/scsgate.py @@ -106,7 +106,7 @@ def process_event(self, message): 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 18548dc203b7b..084d6ac740791 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 @@ def check_proximity_state_change(self, entity, old_state, new_state): 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 @@ def check_proximity_state_change(self, entity, old_state, new_state): 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 @@ def check_proximity_state_change(self, entity, old_state, new_state): 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 @@ def check_proximity_state_change(self, entity, old_state, new_state): 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 @@ def check_proximity_state_change(self, entity, old_state, new_state): 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 3c0e66679bc5d..2d497d382737a 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -89,7 +89,7 @@ def update_value(self, value): 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 60d0b29c51d6f..351b85cf90225 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 c035836594caf..6eaf9ad1cf9e6 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -334,7 +334,7 @@ def update_state(self, state, brightness=0): """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 @@ def _send_command(self, command, brightness=0): 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 009718dd72088..5f2f8edf872eb 100644 --- a/homeassistant/components/sensor/enocean.py +++ b/homeassistant/components/sensor/enocean.py @@ -54,7 +54,7 @@ def name(self): 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 a8b125ae54b52..9927b321024c0 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -169,4 +169,4 @@ def _parse(self, line): 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 36330f9bba949..f5b6d8cfba02a 100644 --- a/homeassistant/components/sensor/haveibeenpwned.py +++ b/homeassistant/components/sensor/haveibeenpwned.py @@ -110,7 +110,7 @@ def update_nothrottle(self, dummy=None): 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 5f9a7e7f8e7f9..b5ff92860a05e 100644 --- a/homeassistant/components/sensor/imap_email_content.py +++ b/homeassistant/components/sensor/imap_email_content.py @@ -225,25 +225,23 @@ def get_msg_text(email_message): def update(self): """Read emails and publish state change.""" - while True: - email_message = self._email_reader.read_next() - - if email_message is None: - break - - 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) - - 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() + email_message = self._email_reader.read_next() + + if email_message is None: + return + + 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) + + 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 06d1fd954f2ab..ebd044343b013 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -121,7 +121,7 @@ def unit_of_measurement(self): 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 8a403b4adbfcf..86ca496eb6298 100644 --- a/homeassistant/components/sensor/pilight.py +++ b/homeassistant/components/sensor/pilight.py @@ -89,7 +89,7 @@ def _handle_code(self, call): 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 00a9370a446d0..2d1ad579342ed 100644 --- a/homeassistant/components/sun.py +++ b/homeassistant/components/sun.py @@ -231,7 +231,7 @@ def update_sun_position(self, utc_point_in_time): 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 @@ def point_in_time_listener(self, now): 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 3cc30a3890833..e97745cc0c616 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -188,13 +188,13 @@ def turn_on(self, **kwargs): """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 0a6d487c33193..e646afef17270 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -114,10 +114,10 @@ def turn_on(self): """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 d7670dff067a8..965011d12eaeb 100644 --- a/homeassistant/components/switch/scsgate.py +++ b/homeassistant/components/switch/scsgate.py @@ -141,7 +141,7 @@ def process_event(self, message): 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 bec89787048ea..7fe121d64c92e 100644 --- a/homeassistant/components/weblink.py +++ b/homeassistant/components/weblink.py @@ -54,7 +54,7 @@ def __init__(self, hass, name, url, icon): 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 6bde1600a8212..39256657c4586 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -115,7 +115,7 @@ def force_update(call): """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 @@ def _pubnub_update(self, message): 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 a3bf1a03386e1..895df3d67212b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -180,14 +180,9 @@ def update_ha_state(self, force_refresh=False): 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 @@ def schedule_update_ha_state(self, force_refresh=False): 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 1b20695b3499e..1bf09e1624771 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -309,13 +309,10 @@ def async_block_entities_done(self): 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 3ccfcd7eb64b9..62be4aca2676e 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -140,10 +140,12 @@ def setUp(self): # pylint: disable=invalid-name 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 @@ def test_active_child_state(self): 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 @@ def test_state_children_only(self): 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 @@ def test_state_with_children_and_attrs(self): 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 @@ def test_volume_level(self): 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 @@ def test_media_image_url(self): 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 @@ def test_is_volume_muted_children_only(self): 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 @@ def test_supported_features_children_only(self): 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 @@ def test_supported_features_children_and_cmds(self): 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 @@ def test_service_call_no_active_child(self): 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 @@ def test_service_call_to_child(self): 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 @@ def test_service_call_to_command(self): 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 f8e5caf0dd236..0bba3647c6ce0 100644 --- a/tests/components/sensor/test_imap_email_content.py +++ b/tests/components/sensor/test_imap_email_content.py @@ -4,7 +4,6 @@ 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 @@ def test_allowed_sender(self): 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 @@ def test_multi_part_with_text(self): ['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 @@ def test_multi_part_only_html(self): 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 @@ def test_multi_part_only_other_text(self): ['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 @@ def test_multiple_emails(self): 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 @@ def state_changed_listener(entity_id, from_s, to_s): '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 @@ def test_sender_not_allowed(self): '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 @@ def test_template(self): 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 0971f0a16bd88..6a0db023671d0 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -103,7 +103,8 @@ def test_reload_core_conf(self): 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 @@ def test_reload_core_conf(self): 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 965afde83092b..d0909fa33b70b 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -77,7 +77,8 @@ def setup_method(self, method): 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 @@ def test_overwriting_hidden_property_to_true(self): """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 @@ def test_device_class(self): 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 18b69f81a9dfd..990bd557e705d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -213,7 +213,7 @@ def _compute_state(self, config): 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 0e734d7214d0a..a5fc52c448ea8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -21,6 +21,7 @@ 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 @@ def is_on(self) -> bool: @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 34cd976569564..840dfd50caa34 100644 --- a/tests/common.py +++ b/tests/common.py @@ -131,6 +131,7 @@ def mock_async_start(): @ha.callback def clear_instance(event): + """Clear global instance.""" global INST_COUNT INST_COUNT -= 1 @@ -140,20 +141,18 @@ def clear_instance(event): 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 fa7658f340759..9dc080890116b 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 @@ def setUp(self): """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 @@ def test_reload_config_handles_load_fails(self): 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 | 232 ++++++++++++------ tests/components/sensor/test_mfi.py | 19 +- 6 files changed, 199 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 7b65ed4f0775a..5152519459b2d 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -401,3 +401,4 @@ def _retry(self): return False self._device = devices[0] + return True diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 0971606575114..5463566976675 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -126,6 +126,27 @@ def __ne__(self, other): 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 @@ def device_state_changed_listener(self, entity_id, from_s, to_s): 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 @@ def register(self, call): 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 a4cdda2adc47f..3fcb6a1575c96 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -436,7 +436,8 @@ def test_heat_away_mode(self): 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 c12d984d275b6..3d0a99ec9395d 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -24,7 +24,7 @@ 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 @@ def test_not_allow_invalid_dev_id(self): 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 7246aea330228..628c38ae18076 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 @@ def tearDown(self): # pylint: disable=invalid-name @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 @@ def test_apns_setup_missing_topic(self): 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') + @patch('homeassistant.components.notify.apns._write_device') + def test_register_device_without_name(self, mock_write): + """Test registering a without a name.""" + yaml_file = { + 1234: { + 'name': 'test device 1', + 'tracking_device_id': 'tracking123', + }, + 5678: { + 'name': 'test device 2', + 'tracking_device_id': 'tracking456', + }, + } - self.assertIsNotNone(test_device_1) - self.assertIsNotNone(test_device_2) + written_devices = [] - self.assertEqual('test device', test_device_1.get('name')) + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) - os.remove(devices_path) + mock_write.side_effect = fake_write - def test_register_device_without_name(self): - """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') + 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')) - - os.remove(devices_path) + self.assertIsNone(test_device.name) - 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 @@ def test_update_existing_device(self): 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 @@ def test_update_existing_device_with_tracking_id(self): 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 @@ def test_send_when_disabled(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, 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 @@ def test_send_with_state(self, mock_client): 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 @@ def test_send_with_state(self, mock_client): 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', + }, + } + + written_devices = [] + + def fake_write(_out, device): + """Fake write_device.""" + written_devices.append(device) - self._setup_notify() + 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')) - - os.remove(devices_path) + self.assertEqual(True, test_device_1.disabled) + + +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 82577a5b2a026..a55250c8872d5 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -38,29 +38,31 @@ def teardown_method(self, method): """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 @@ def test_setup_adds_proper_devices(self, mock_sensor, mock_client): 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 | 41 +++++++++++++++---------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index 5463566976675..50842c69a61c7 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -190,7 +190,6 @@ def device_state_changed_listener(self, entity_id, from_s, to_s): 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 628c38ae18076..9bacd1391f1d5 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 @@ } +@patch('homeassistant.components.notify.apns.open', mock_open(), create=True) class TestApns(unittest.TestCase): """Test the APNS component.""" @@ -315,28 +316,34 @@ def test_send_with_state(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, ' - 'tracking_device_id: tracking123}\n') - out.write('5678: {name: test device 2, ' - 'tracking_device_id: tracking456}\n') - - notify_service = apns.ApnsNotificationService( - self.hass, - 'test_app', - 'testapp.appname', - False, - 'test_app.pem' - ) + yaml_file = { + 1234: { + 'name': 'test device 1', + 'tracking_device_id': 'tracking123', + }, + 5678: { + 'name': 'test device 2', + 'tracking_device_id': 'tracking456', + }, + } + + 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 c11e25ae1303c..66977222c5d4f 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_see_service(call): 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 @@ def async_update_stale(self, now: dt_util.dt.datetime): 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 961ee72496e99..f740a5f16141c 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -83,6 +83,7 @@ def device_state_attributes(self): 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 @@ def update(self): # 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 6787c56d63936..baacdc5ccb00b 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 0000000000000..ab64557dd2f12 --- /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 a18aa2485d5f5..e73dcba0a712e 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 baacdc5ccb00b..77398e84b1190 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 0000000000000..7a25b3de85f46 --- /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 e73dcba0a712e..978950046ed7a 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 | 468 ++---------------- tests/test_remote.py | 10 +- tests/test_setup.py | 409 +++++++++++++++ 171 files changed, 970 insertions(+), 957 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 3b53010e3e325..2cca8e1495b41 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -7,7 +7,6 @@ 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 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 a5fc52c448ea8..7233ffc5c663c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -11,7 +11,7 @@ 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 631650077ce40..ab175d1d56f46 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -6,7 +6,7 @@ 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 66977222c5d4f..3e04f46cb50ea 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -14,7 +14,7 @@ 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 e72eca9e7fa8b..0e1caf3e13731 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -18,7 +18,7 @@ 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 6bfdde813c143..331d32e83bec8 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -13,7 +13,7 @@ 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 f943896227433..14ef4f10864a0 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -12,7 +12,7 @@ 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 d1d35e0705467..35a01e254752a 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -11,7 +11,7 @@ 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 5673e61e16b0f..c175290f451f9 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -18,7 +18,7 @@ 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 5615f3a319965..67fa71ece29d3 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -7,7 +7,7 @@ """ 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 1bf09e1624771..26c633820cf99 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -3,7 +3,7 @@ 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 65ff61888eabe..316853bbcb97d 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -21,8 +21,7 @@ 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 @@ def start(self): """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 eac0df8bc9016..f8f4a3e9a6d84 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -9,9 +9,7 @@ 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 @@ 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 0000000000000..b9652787eff1c --- /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 840dfd50caa34..509e72fe3a71c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -13,7 +13,7 @@ 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 f033006c28c31..7bd89d12a0acd 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -3,7 +3,7 @@ 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 2fe9e05d9d508..368a43e611360 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 c032c72446ab6..b468665005792 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 9dc080890116b..271aa58f7fb7d 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -5,7 +5,7 @@ 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 d76a0ee19acf6..e644541549092 100644 --- a/tests/components/automation/test_litejet.py +++ b/tests/components/automation/test_litejet.py @@ -4,7 +4,7 @@ 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 @@ def record_call(service): '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 @@ def simulate_time(self, delta): 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 df8baced09096..d1eb0d63ee8f3 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 8862303da5f92..0fca1d96a6986 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 f375aec466623..afddaa85b0465 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -5,7 +5,7 @@ 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 47bbf6b680c8c..2341d22d63320 100644 --- a/tests/components/automation/test_sun.py +++ b/tests/components/automation/test_sun.py @@ -5,7 +5,7 @@ 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 8bdf9f8f439a7..cf8b7a59c8736 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 6a76bb887b858..738c2251264f5 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -4,7 +4,7 @@ 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 ea216b12a260d..3dc4b75b8ae05 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 80b309c22c30a..98d7456ef9883 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -3,7 +3,7 @@ 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 @@ def test_setup_bad_config(self): '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 ffeba1870a606..64c540f439896 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 1b756f72f6179..85e56fb44ea2d 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 ef8861e12ce1c..d94d887c641a3 100644 --- a/tests/components/binary_sensor/test_nx584.py +++ b/tests/components/binary_sensor/test_nx584.py @@ -6,7 +6,7 @@ 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 fb86e2b3ee5b7..40e0aa35e03b5 100644 --- a/tests/components/binary_sensor/test_sleepiq.py +++ b/tests/components/binary_sensor/test_sleepiq.py @@ -4,7 +4,7 @@ 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 156ebe2c35529..8602de84d2512 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 77818c339e258..4e829b42fe30a 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -5,7 +5,7 @@ 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 @@ def test_setup(self): }, } 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 @@ def test_setup_no_sensors(self): 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 @@ def test_setup_invalid_device(self): 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 @@ def test_setup_invalid_device_class(self): 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 @@ def test_event(self): }, } 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 6af2bbe5b3965..5bc62654a1f6f 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 8b522db4a58a1..dd3c0ba989081 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 @@ def teardown_method(self, method): 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 @@ def test_up(self): 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 @@ def test_down(self): 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 @@ def test__invert_up(self): 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 @@ def test_invert_down(self): 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 @@ def test_attribute_up(self): 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 @@ def test_attribute_down(self): 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 @@ def test_non_numeric(self): 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 @@ def test_invalid_name_does_not_create(self): \ # 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 @@ def test_invalid_sensor_does_not_create(self): \ # 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 @@ def test_invalid_sensor_does_not_create(self): \ 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 ac7b006315865..7b1263dd3da45 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 13286beae61d9..4b69116f01048 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -4,7 +4,7 @@ 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 55ddbd107413d..ccca77386d8d2 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -6,7 +6,7 @@ # 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 cd11321baa452..f949d1e728e75 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -7,7 +7,7 @@ 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 898f6ba2df682..27d79b40aa885 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -4,7 +4,7 @@ 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 d4a5b3d21bbac..16dbe5ae89553 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -7,7 +7,7 @@ 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 1c37683969b46..6f69f88641916 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -5,7 +5,7 @@ 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 9d1552b2e734d..b7049d350219b 100644 --- a/tests/components/cover/test_command_line.py +++ b/tests/components/cover/test_command_line.py @@ -5,7 +5,7 @@ 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 daed13ab6912c..83907de7708bf 100644 --- a/tests/components/cover/test_demo.py +++ b/tests/components/cover/test_demo.py @@ -3,7 +3,7 @@ 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 1d670d81b6ed4..5cd79fdb74c54 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 2d11e03cb4178..be2c456296bce 100644 --- a/tests/components/cover/test_rfxtrx.py +++ b/tests/components/cover/test_rfxtrx.py @@ -3,7 +3,7 @@ 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 406087b7b996d..81d3c7a1900e8 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -6,7 +6,7 @@ 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 a0433b04d0152..4d4f22f2181a3 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -8,7 +8,7 @@ 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 3d0a99ec9395d..d4f301c6fc58c 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -10,7 +10,7 @@ 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 33f1b078166bc..5f3f12ba82a4a 100644 --- a/tests/components/device_tracker/test_locative.py +++ b/tests/components/device_tracker/test_locative.py @@ -4,7 +4,7 @@ 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 08aab93a5a5ea..eb46106297112 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -5,7 +5,7 @@ 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 31f9a6b96a079..434950c175ca5 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -10,7 +10,7 @@ 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 7a1e14a7dfcf0..87e84c000d0bb 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -4,7 +4,7 @@ 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 fdd9bc90946a8..a6f1b71ee7558 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -5,7 +5,7 @@ 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 03b9e993a9bad..3706ce224bed8 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -5,7 +5,7 @@ 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 @@ def setUpClass(cls): 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 2a0de549b9915..078dd56bf1be4 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 d41a1f03d1b43..729e6f22be6d6 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -6,7 +6,7 @@ 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 b01535206fff6..0d8f1a92c7f40 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -5,7 +5,7 @@ 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 36f434664d71c..4428b5043fdd4 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 @@ def get(self, request): @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 2ac64891e954b..816976751a715 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -3,7 +3,7 @@ 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 801db56ed20ab..f398db991c299 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 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 c6490369859f9..a7958b68de7fc 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 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 8bce672e0d995..e35ac8185d0c7 100644 --- a/tests/components/image_processing/test_openalpr_cloud.py +++ b/tests/components/image_processing/test_openalpr_cloud.py @@ -4,7 +4,7 @@ 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 ffe2eadc8d685..fc40f8e17fb50 100644 --- a/tests/components/image_processing/test_openalpr_local.py +++ b/tests/components/image_processing/test_openalpr_local.py @@ -4,7 +4,7 @@ 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 de89d434e899b..f51b5a45b2014 100644 --- a/tests/components/light/test_demo.py +++ b/tests/components/light/test_demo.py @@ -4,7 +4,7 @@ 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 757f144ca57f7..d024df206292b 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 10b205a8c7a06..001c419066f0c 100644 --- a/tests/components/light/test_litejet.py +++ b/tests/components/light/test_litejet.py @@ -3,7 +3,7 @@ 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 @@ def on_load_deactivated(number, callback): 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 410f947178c6c..89a74805361c6 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -76,7 +76,7 @@ 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 55c437cdc79b9..21c88405a6d87 100755 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -30,7 +30,7 @@ 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 020ded1bd807a..52847a7be9afc 100755 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -22,7 +22,7 @@ """ 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 135e51380cd04..eef54a6c258d3 100644 --- a/tests/components/light/test_rfxtrx.py +++ b/tests/components/light/test_rfxtrx.py @@ -3,7 +3,7 @@ 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 e7a086ad51a2c..12007d2b8ade8 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 14714e9a3d190..5815329717c61 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 1e53245d8a5a7..a798c5f39870e 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -3,7 +3,7 @@ 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 58691d44516b1..51751618d57fd 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -5,7 +5,7 @@ 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 255d5f6a96c02..f476ed4be099c 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,7 +8,7 @@ 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 db9e963d84cec..7ce9ec00797e2 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 9bacd1391f1d5..0bd0333a6fb77 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -7,7 +7,7 @@ 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 cebcd2a13fb7b..e66f2647d4f90 100644 --- a/tests/components/notify/test_command_line.py +++ b/tests/components/notify/test_command_line.py @@ -4,7 +4,7 @@ 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 43c5e78c5dacf..5bd3270b9223d 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -3,7 +3,7 @@ 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 ea0aaaaa71d6a..42b9eb9d82d25 100644 --- a/tests/components/notify/test_file.py +++ b/tests/components/notify/test_file.py @@ -3,7 +3,7 @@ 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 1aa07fed583e7..ed988b0f9b58c 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 8277ef12c8ed4..0ede5d52a35b7 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 60a049fa2914e..2cdbf9d904582 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 6e46e55e22102..d84d6ad37f468 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 17ba4ce73043a..37a9aa5b2b557 100644 --- a/tests/components/scene/test_litejet.py +++ b/tests/components/scene/test_litejet.py @@ -3,7 +3,7 @@ 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 @@ def get_scene_name(number): 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 fddcf7894273f..722f5b0fc8fcc 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -3,7 +3,7 @@ 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 @@ def test_setup_bad_config(self): '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 effa7b3dbd808..54453f42d4391 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -9,7 +9,7 @@ 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 29d353e09bafe..db1ccd95a9929 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -4,7 +4,7 @@ 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 a55250c8872d5..8b037209cbc9e 100644 --- a/tests/components/sensor/test_mfi.py +++ b/tests/components/sensor/test_mfi.py @@ -4,7 +4,7 @@ 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 4311493ac97bc..6948a952c3155 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 11b08575f4625..b610775b39bfe 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 3b2eaabac9cef..32cd0206decab 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 1125dab1201ad..4de3d241fc7e0 100644 --- a/tests/components/sensor/test_moon.py +++ b/tests/components/sensor/test_moon.py @@ -4,7 +4,7 @@ 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 1de9d2f731a6f..c70fddb67fc66 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 e85057d827c3b..c79017338e166 100644 --- a/tests/components/sensor/test_mqtt_room.py +++ b/tests/components/sensor/test_mqtt_room.py @@ -4,7 +4,7 @@ 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 35b6924a35a8f..b952377118d49 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 902edfc3ee4fc..eeefef74c02bb 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 1c4910927a53c..99eec9552f7df 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -6,7 +6,7 @@ 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 96b5623b7b1b1..e049eabbe565a 100644 --- a/tests/components/sensor/test_rfxtrx.py +++ b/tests/components/sensor/test_rfxtrx.py @@ -3,7 +3,7 @@ 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 2d754daa6d872..a79db86dc79b4 100644 --- a/tests/components/sensor/test_sleepiq.py +++ b/tests/components/sensor/test_sleepiq.py @@ -4,7 +4,7 @@ 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 75649a0c140c2..d51c88b85d542 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 fe6fa44b020c9..4c1e976ea51d5 100644 --- a/tests/components/sensor/test_tcp.py +++ b/tests/components/sensor/test_tcp.py @@ -6,7 +6,7 @@ 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 adfdc08d510cf..62a38abd317ee 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 40dd4ee0a5d44..9c5392675fbd4 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 4a2dc345f10a5..ee2cec3bb2aa6 100644 --- a/tests/components/sensor/test_wsdot.py +++ b/tests/components/sensor/test_wsdot.py @@ -10,7 +10,7 @@ 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 4823458652bf9..7b46ad99d4130 100644 --- a/tests/components/sensor/test_yahoo_finance.py +++ b/tests/components/sensor/test_yahoo_finance.py @@ -5,7 +5,7 @@ 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 87eb12d850867..40f999fa43bab 100644 --- a/tests/components/switch/test_command_line.py +++ b/tests/components/switch/test_command_line.py @@ -4,7 +4,7 @@ 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 1ee865ef3ac27..b42177a5f0639 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -3,7 +3,7 @@ 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 464bc21dd4ee2..090e3c74bf1d8 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 3d090fd173d57..e0d6e290def26 100644 --- a/tests/components/switch/test_litejet.py +++ b/tests/components/switch/test_litejet.py @@ -3,7 +3,7 @@ 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 @@ def on_switch_released(number, callback): 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 a73b35af2f858..a50c5d449f48d 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 c6c570449cb66..fad5b42439984 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 33de6de52a9ac..e5e68fe021edf 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 38ddad5e9a25d..a2da94b614aea 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -4,7 +4,7 @@ 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 b4eb1259515fb..938093aa95ba2 100644 --- a/tests/components/switch/test_rfxtrx.py +++ b/tests/components/switch/test_rfxtrx.py @@ -3,7 +3,7 @@ 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 dabdaa2b4d702..4a03877b2fa05 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 @@ def teardown_method(self, method): 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 @@ def test_template_state_text(self): 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 @@ def test_template_state_boolean_on(self): 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 @@ def test_template_state_boolean_off(self): 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 @@ def test_template_syntax_error(self): 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 @@ def test_invalid_name_does_not_create(self): 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 @@ def test_invalid_switch_does_not_create(self): 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 @@ def test_no_switches_does_not_create(self): 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 @@ def test_missing_template_does_not_create(self): 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 @@ def test_missing_on_does_not_create(self): 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 @@ def test_missing_off_does_not_create(self): 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 @@ def test_on_action(self): 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 00e4abec25bf0..7fc25068732a2 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 fe980bf05b343..f25eb2b097027 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -7,7 +7,7 @@ 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 mock_service(call): 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 38222ff8f00e0..2cdc359bfeac7 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -9,7 +9,7 @@ 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 9023ee161c5c5..f5624f3c20960 100644 --- a/tests/components/test_apiai.py +++ b/tests/components/test_apiai.py @@ -6,7 +6,7 @@ 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 mock_service(call): 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 abe3a8f36f197..76d582c88560e 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -4,7 +4,7 @@ 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 9691500c451c8..1bb39b053a51a 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -3,7 +3,7 @@ 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 c42b50ef39022..2b11a420fdcfc 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 0af90ad7836c0..9cc706b56907d 100644 --- a/tests/components/test_ffmpeg.py +++ b/tests/components/test_ffmpeg.py @@ -3,7 +3,7 @@ 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 a56fac9ed5d47..0c42d05f3aeef 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -5,7 +5,7 @@ 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 fbaddb1ed3282..004a6e0edafc5 100644 --- a/tests/components/test_google.py +++ b/tests/components/test_google.py @@ -4,7 +4,7 @@ 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 fcbdbd85b1913..280704fdc3186 100644 --- a/tests/components/test_graphite.py +++ b/tests/components/test_graphite.py @@ -4,7 +4,7 @@ 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 00b75c3a854d6..af1cadc246663 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -4,7 +4,7 @@ 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 7324a5e9b322d..d2ea03b1873ec 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -4,7 +4,7 @@ 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 96a6460a2a4b4..c1ad267236506 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -4,7 +4,7 @@ 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 62b9f681703d9..f9042b7de4c35 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -7,7 +7,7 @@ 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 4602b0598377d..e2549acd35fe5 100644 --- a/tests/components/test_input_select.py +++ b/tests/components/test_input_select.py @@ -6,7 +6,7 @@ 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 bc8921d000a88..f4f9efe687f6e 100644 --- a/tests/components/test_input_slider.py +++ b/tests/components/test_input_slider.py @@ -6,7 +6,7 @@ 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 31201db092e23..99b373961cc1d 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 13735df0a1159..aa4bc9fdf8c48 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -12,7 +12,7 @@ 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 5d3a9d79f97c0..bff80c958f31a 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 099137bdf4bae..61cb42e8bb5bc 100644 --- a/tests/components/test_logger.py +++ b/tests/components/test_logger.py @@ -3,7 +3,7 @@ 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 859ac9023af2a..bb95c7e51c19d 100644 --- a/tests/components/test_microsoft_face.py +++ b/tests/components/test_microsoft_face.py @@ -3,7 +3,7 @@ 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 dd08904a8e177..91175024ea64b 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 46de75bf8bdc6..073a2fdcce925 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -4,7 +4,7 @@ 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 @@ def test_webcomponent_in_panels_dir(self, mock_register, _mock_setup): } } - 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 @@ def test_webcomponent_custom_path(self, mock_register, _mock_setup): } 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 cf2fdc23b093f..ec1e5bf365075 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 @@ def test_wrong_config(self): '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 @@ def test_wrong_config(self): '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 079fdaf8078dc..55c7867685843 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 036beb0c139ec..7bdd44136e83a 100644 --- a/tests/components/test_pilight.py +++ b/tests/components/test_pilight.py @@ -6,7 +6,7 @@ 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 532a5e10ab4d3..42f1cbf4b43b7 100644 --- a/tests/components/test_proximity.py +++ b/tests/components/test_proximity.py @@ -4,7 +4,7 @@ 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 8fe9523801ded..a62bddc4a0fa2 100644 --- a/tests/components/test_rest_command.py +++ b/tests/components/test_rest_command.py @@ -4,7 +4,7 @@ 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 a1041777ebc4c..1730d3a5371d6 100644 --- a/tests/components/test_rfxtrx.py +++ b/tests/components/test_rfxtrx.py @@ -5,7 +5,7 @@ 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 14aa75eb9635a..87391ce5c6809 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 16e4296a5b8d6..b75a95e23cdb9 100644 --- a/tests/components/test_shell_command.py +++ b/tests/components/test_shell_command.py @@ -5,7 +5,7 @@ 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 965e0b6304a3f..3c7551d130c4e 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 @@ def test_setup_component_no_login(self): """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 787208503175c..661f53b533aa1 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 b0cba0e41f975..eb8933b77bee0 100644 --- a/tests/components/test_statsd.py +++ b/tests/components/test_statsd.py @@ -4,7 +4,7 @@ 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 9e5b15e6c2fe0..3d5a27294a916 100644 --- a/tests/components/test_sun.py +++ b/tests/components/test_sun.py @@ -4,7 +4,7 @@ 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 8ca136bd8d7de..da9775e17e6cc 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -8,7 +8,7 @@ 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 78cf6b75db769..e8768342db923 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 @@ def tearDown(self): 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 b0d4f06688dee..0ea84324362ea 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 @@ def tearDown(self): # pylint: disable=invalid-name 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 @@ def test_setup(self): '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 @@ def test_setup(self): 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 @@ def test_active_zone_skips_passive_zones(self): 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 @@ def test_active_zone_prefers_smaller_zone_if_same_distance(self): """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 @@ 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, { + assert setup.setup_component(self.hass, zone.DOMAIN, { 'zone': [ { 'name': 'Smallest Zone', @@ -129,7 +129,7 @@ def test_in_zone_works_for_passive_zones(self): """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 3483a4830fade..4cbec95dc2bcd 100644 --- a/tests/components/tts/test_google.py +++ b/tests/components/tts/test_google.py @@ -7,7 +7,7 @@ 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 0db7c1a5befea..d43dcda8baf8c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -12,7 +12,7 @@ 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 b8f734878311e..79629df6d82c5 100644 --- a/tests/components/tts/test_voicerss.py +++ b/tests/components/tts/test_voicerss.py @@ -6,7 +6,7 @@ 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 2baa94ae2b875..b7724d7d9132e 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -4,7 +4,7 @@ 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 8ebe4b5355d3f..1563dd377c4a9 100644 --- a/tests/components/weather/test_weather.py +++ b/tests/components/weather/test_weather.py @@ -7,7 +7,7 @@ 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 33c5d9f09173d..c8afa70173efd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ 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 65fd038629930..42e2697b7a7ea 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -5,7 +5,7 @@ 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 5e3f9cd8c88bf..e1f2e114ba15c 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -4,7 +4,7 @@ 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 @@ def teardown_method(self, method): """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 @@ def callback_multi(service, info): 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 @@ def setup_platform(hass, config, add_devices_callback, 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 @@ def component2_setup(hass, config): 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 d95ec3a87f822..395ef103fd382 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -301,7 +301,7 @@ def test_setup_recovers_when_setup_raises(self): @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 39691097545a3..ac60aae3fabb6 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -6,7 +6,7 @@ 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 f46f33c333fcb..826ddc5dd8245 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -3,7 +3,7 @@ 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 173cea1957acc..9047f26b2d1ee 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 @@ _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 +# 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) + } - def test_component_not_setup_twice_if_loaded_during_other_setup(self): - """Test component setup while waiting for lock is not setup twice.""" - result = [] + with patch_yaml_files(files, True): + yield from bootstrap.async_from_config_file('config.yaml') - @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] + 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 d20acc888575a..eec7b4cf98d0c 100644 --- a/tests/test_remote.py +++ b/tests/test_remote.py @@ -5,9 +5,7 @@ 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 0000000000000..f14561a0c4880 --- /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 | 58 ++++++++++++------- homeassistant/helpers/restore_state.py | 15 ++++- tests/common.py | 5 +- tests/helpers/test_restore_state.py | 2 +- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 907ae8ba51bb7..dcd4eeb0a0ef4 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 @@ def __init__(self, hass: HomeAssistant, purge_days: int, uri: str, 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 @@ def run(self): 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 @@ def run(self): @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 @@ def _setup_connection(self): 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) + + kwargs['connect_args'] = {'check_same_thread': False} + kwargs['poolclass'] = StaticPool + kwargs['pool_reset_on_return'] = None else: - self.engine = create_engine(self.db_url, echo=False) + 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 4ac1e4425466e..5b5678411111e 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -3,6 +3,8 @@ 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 @@ 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 509e72fe3a71c..88d5e146dabc9 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,8 +29,7 @@ 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 826ddc5dd8245..5027e36a7f2f3 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 96a32d1ae6eb9..8ae18ef6e804c 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -4,6 +4,7 @@ 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 5b5678411111e..c022d5ae8f31c 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 039e22e73df03..6b0c8a63f990e 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -230,10 +230,12 @@ def turn_on(self, **kwargs): 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 e84794356b27a..a374d19f4d129 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -47,15 +47,21 @@ def __init__(self, device_info): 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 69fc2eb88a77e..4d7f34ef6829b 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -85,7 +85,7 @@ def update(self): 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 5e651f692134a..3dbb2e4b2240d 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -12,6 +12,7 @@ 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 @@ def __init__(self, value, domain): 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 @@ def value_changed(self): 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 @@ def refresh_from_network(self): 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 3dbb2e4b2240d..2d249146ea46e 100755 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -770,7 +770,7 @@ def __init__(self, value, domain): 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 @@ def value_changed(self): 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 @@ def refresh_from_network(self): @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 88d5e146dabc9..31741ecda677c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,6 @@ 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 @@ 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 clear_instance(event): 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 ca6e5d5ef7cb8..ca0bec297066a 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -142,7 +142,7 @@ def _update_info(self): # 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 a9166c8352f7a..bbae1c1c68c9d 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -5,11 +5,12 @@ 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 9522917ed0909..17dbf1437f306 100644 --- a/homeassistant/components/zwave/workaround.py +++ b/homeassistant/components/zwave/workaround.py @@ -10,10 +10,12 @@ # 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 @@ 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 @@ 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 @@ 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 331d32e83bec8..034d1154679e1 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -9,6 +9,7 @@ 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 f476ed4be099c..f29ef15a37fed 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 0de775562a5dc..b71e37fda1917 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 77398e84b1190..59c0c63ebe53c 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 0000000000000..c7bf966a3e997 --- /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 dcd4eeb0a0ef4..985ec240f71e0 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -43,8 +43,7 @@ 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 @@ def run(self): 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 @@ def run(self): 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 @@ def _setup_connection(self): 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 59c0c63ebe53c..0a226dd147ea9 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 0000000000000..54799ccc6b406 --- /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 978950046ed7a..120d3e169dbdf 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 46e8263999b8b..af58b4cb65485 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -14,7 +14,7 @@ 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 120d3e169dbdf..b1b9e05e8e9e9 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 da498dc3d5bd1..fe029af163ed1 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -25,8 +25,8 @@ 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 476f7b9053e45..e82971e0064e8 100644 --- a/homeassistant/components/notify/webostv.py +++ b/homeassistant/components/notify/webostv.py @@ -5,24 +5,29 @@ 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 b1b9e05e8e9e9..f6d14f384e534 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 421ba321c8d51..2603634245227 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,7 +19,7 @@ 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 f6d14f384e534..87f72ce81aeda 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 31741ecda677c..6646011044944 100644 --- a/tests/common.py +++ b/tests/common.py @@ -56,7 +56,6 @@ def run_loop(): # 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 stop_hass(): """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 c8afa70173efd..bc773de848963 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 .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 e39b94a18d604..78a600ab8d6b0 100644 --- a/homeassistant/components/notify/pushsafer.py +++ b/homeassistant/components/notify/pushsafer.py @@ -6,65 +6,42 @@ """ 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 87f72ce81aeda..1116e5c62e80b 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 10ccf32068ff9..5999957066f8d 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -152,9 +152,6 @@ def reconnect(exc=None): 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 connect(): _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 connect(): 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 555ec9372ba0d..9a83644dcfd0f 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 0a226dd147ea9..cd9ca93b5c871 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 0000000000000..8d84ffb9c90ee --- /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 0000000000000..94635e2ae594d --- /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 0000000000000..685ee5bd0fade --- /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 0000000000000..738f8cb27688d --- /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 1116e5c62e80b..6e4f3912ab21a 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 26c633820cf99..908685205e71e 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -18,6 +18,7 @@ run_callback_threadsafe, run_coroutine_threadsafe) DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) +SLOW_SETUP_WARNING = 10 class EntityComponent(object): @@ -134,8 +135,13 @@ def _async_setup_platform(self, platform_type, platform_config, 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 @@ def _async_setup_platform(self, platform_type, platform_config, 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 b9652787eff1c..4a4737dab0366 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -21,6 +21,8 @@ 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 log_error(msg, link=True): 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 log_error(msg, link=True): _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 395ef103fd382..3af01140c4d2e 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -4,7 +4,7 @@ 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 @@ 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 @@ def create_entity(number): 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 f14561a0c4880..9d29961da106a 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 57ad4d34f1a01..d9f7d0ad63703 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -85,11 +85,11 @@ def turn_off(self): 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 0000000000000..b5a846665adde --- /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 0000000000000..fad5a3de52f89 --- /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 7233ffc5c663c..96d5b0499d2d5 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -15,7 +15,7 @@ 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 @@ 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 06e029ffd8c10..f582ff33a0751 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -14,7 +14,7 @@ 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 @@ vol.Required(ATTR_VISIBLE): cv.boolean }) -SERVICE_RELOAD = 'reload' RELOAD_SERVICE_SCHEMA = vol.Schema({}) _LOGGER = logging.getLogger(__name__) @@ -395,17 +394,16 @@ def async_update(self): 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 cf4843353b5fe..bcab6465dc12b 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,7 @@ 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 @@ 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 e509ed07a08d3..de1b20b70a16a 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 438a682efafa7..07fd6d9e02ec3 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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